Files
mixmaker/backend/internal/adapter/httpapi/server.go
lemintare c3624376b0
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Add Discord guild ID configuration and enhance event announcement options
This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
2026-07-19 11:34:11 +03:00

782 lines
25 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
"mixmaker/backend/internal/realtime"
)
type Config struct {
DiscordClientID, DiscordClientSecret, DiscordRedirectURL string
FrontendURL, CookieName string
SecureCookies bool
SessionTTL time.Duration
AdminDiscordIDs map[string]bool
}
type Server struct {
service *application.Service
store application.Store
hub *realtime.Hub
cfg Config
client *http.Client
}
type identity struct {
account domain.Account
player domain.Player
session string
}
type identityKey struct{}
func New(service *application.Service, store application.Store, hub *realtime.Hub, cfg Config) http.Handler {
s := &Server{service: service, store: store, hub: hub, cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/readyz", s.ready)
r.Get("/api/auth/discord", s.discordLogin)
r.Get("/api/auth/discord/callback", s.discordCallback)
r.Post("/api/auth/logout", s.logout)
r.Group(func(api chi.Router) {
api.Use(s.authenticate)
api.Get("/api/auth/me", s.me)
api.Get("/api/me", s.me)
api.Patch("/api/me/player", s.updateProfile)
api.Get("/api/players", s.players)
api.Get("/api/accounts", s.accounts)
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
api.Get("/api/events", s.events)
api.Post("/api/events", s.createEvent)
api.Get("/api/events/{eventID}", s.getEvent)
api.Put("/api/events/{eventID}", s.updateEvent)
api.Delete("/api/events/{eventID}", s.deleteEvent)
api.Post("/api/events/{eventID}/cancel", s.cancelEvent)
api.Get("/api/events/{eventID}/tournament", s.getEventTournament)
api.Get("/api/events/{eventID}/rsvps", s.rsvps)
api.Post("/api/events/{eventID}/participants", s.createParticipant)
api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP)
api.Delete("/api/events/{eventID}/rsvps/{playerID}", s.removeParticipant)
api.Post("/api/events/{eventID}/balance", s.balance)
api.Put("/api/events/{eventID}/teams", s.selectBalance)
api.Get("/api/events/{eventID}/teams", s.teams)
api.Post("/api/events/{eventID}/registration/close", s.closeRegistration)
api.Post("/api/events/{eventID}/balance/generate", s.generateWorkflowBalance)
api.Get("/api/events/{eventID}/roster", s.getRoster)
api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance)
api.Post("/api/events/{eventID}/roster/swap", s.swapRoster)
api.Post("/api/events/{eventID}/roster/move", s.moveRosterPlayer)
api.Post("/api/events/{eventID}/roster/place-reserve", s.placeReservePlayer)
api.Post("/api/events/{eventID}/roster/remove", s.removeRosterPlayer)
api.Post("/api/events/{eventID}/roster/substitute", s.substituteRoster)
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
api.Get("/api/events/{eventID}/bracket-draft", s.getBracketDraft)
api.Post("/api/events/{eventID}/bracket-draft/initialize", s.initializeBracket)
api.Put("/api/events/{eventID}/bracket-draft", s.updateBracket)
api.Post("/api/events/{eventID}/bracket-draft/reset", s.resetBracket)
api.Post("/api/events/{eventID}/bracket-draft/confirm", s.confirmBracket)
api.Post("/api/events/{eventID}/workflow/back", s.revertWorkflowStage)
api.Post("/api/events/{eventID}/start", s.startScrim)
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
api.Put("/api/teams/{teamID}/name", s.renameTeam)
api.Post("/api/rulesets", s.saveRuleset)
api.Get("/api/rulesets", s.listRulesets)
api.Get("/api/rulesets/{id}", s.getRuleset)
api.Post("/api/coin-toss", s.coinToss)
api.Post("/api/drafts/maps", s.createMapDraft)
api.Post("/api/drafts/{id}/map-bans", s.mapBan)
api.Post("/api/drafts/heroes", s.createHeroDraft)
api.Post("/api/drafts/{id}/hero-bans", s.heroBan)
api.Post("/api/tournaments", s.createTournament)
api.Get("/api/tournaments/{id}", s.getTournament)
api.Get("/api/series/{id}", s.getSeries)
api.Post("/api/series/{id}/coin-toss", s.tossSeriesCoin)
api.Post("/api/series/{id}/map-bans", s.banSeriesMap)
api.Post("/api/series/{id}/map-pick", s.pickSeriesMap)
api.Post("/api/series/{id}/hero-bans", s.banSeriesHero)
api.Post("/api/series/{id}/map-result", s.recordSeriesResult)
api.Post("/api/series/{id}/results", s.recordResult)
api.Patch("/api/series/{id}/results/{index}", s.correctResult)
api.Get("/api/events/stream", s.stream)
})
return r
}
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
if err := s.store.Ready(r.Context()); err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func randomToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) cookie(name, value string, expires time.Time) *http.Cookie {
return &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, Expires: expires}
}
func (s *Server) discordLogin(w http.ResponseWriter, r *http.Request) {
state := randomToken()
http.SetCookie(w, s.cookie("mixmaker_oauth_state", state, time.Now().Add(10*time.Minute)))
q := url.Values{"client_id": {s.cfg.DiscordClientID}, "redirect_uri": {s.cfg.DiscordRedirectURL}, "response_type": {"code"}, "scope": {"identify"}, "state": {state}}
http.Redirect(w, r, "https://discord.com/oauth2/authorize?"+q.Encode(), http.StatusFound)
}
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("mixmaker_oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
writeError(w, fmt.Errorf("%w: invalid OAuth state", domain.ErrInvalid))
return
}
form := url.Values{"client_id": {s.cfg.DiscordClientID}, "client_secret": {s.cfg.DiscordClientSecret}, "grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {s.cfg.DiscordRedirectURL}}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
writeError(w, err)
return
}
defer resp.Body.Close()
var token struct {
AccessToken string `json:"access_token"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil {
writeError(w, fmt.Errorf("Discord token exchange failed"))
return
}
req, _ = http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil)
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err = s.client.Do(req)
if err != nil {
writeError(w, err)
return
}
defer resp.Body.Close()
var user struct{ ID, Username, Avatar string }
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&user) != nil {
writeError(w, fmt.Errorf("Discord identity request failed"))
return
}
now := time.Now().UTC()
account := domain.Account{ID: application.NewID(), DiscordID: user.ID, Username: user.Username, AvatarURL: user.Avatar, CreatedAt: now}
account, _, err = s.store.UpsertDiscordAccount(r.Context(), account, s.cfg.AdminDiscordIDs[user.ID])
if err != nil {
writeError(w, err)
return
}
session := randomToken()
ttl := s.cfg.SessionTTL
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
expires := now.Add(ttl)
if err := s.store.CreateSession(r.Context(), session, account.ID, expires); err != nil {
writeError(w, err)
return
}
http.SetCookie(w, s.cookie(s.cfg.CookieName, session, expires))
http.SetCookie(w, s.cookie("mixmaker_oauth_state", "", time.Unix(1, 0)))
http.Redirect(w, r, s.cfg.FrontendURL, http.StatusFound)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(s.cfg.CookieName); err == nil {
_ = s.store.DeleteSession(r.Context(), c.Value)
}
http.SetCookie(w, s.cookie(s.cfg.CookieName, "", time.Unix(1, 0)))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(s.cfg.CookieName)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
a, p, err := s.service.Authenticate(r.Context(), c.Value)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{a, p, c.Value})))
})
}
func who(r *http.Request) identity { return r.Context().Value(identityKey{}).(identity) }
func requireStaff(r *http.Request) error {
if !who(r).account.IsStaff() {
return domain.ErrForbidden
}
return nil
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
id := who(r)
writeJSON(w, 200, map[string]any{"account": id.account, "player": id.player})
}
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var in struct {
DisplayName string `json:"displayName"`
Ratings domain.Ratings `json:"ratings"`
PreferredRoles []domain.Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
AvoidedPlayerIDs []string `json:"avoidedPlayerIds"`
}
if !decode(w, r, &in) {
return
}
id := who(r)
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
respond(w, out, err, http.StatusOK)
}
func (s *Server) players(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListPlayers(r.Context())
respond(w, out, err, 200)
}
func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
out, err := s.service.ListAccounts(r.Context(), who(r).account)
respond(w, out, err, http.StatusOK)
}
func (s *Server) setModerator(w http.ResponseWriter, r *http.Request) {
var in struct {
Moderator bool `json:"moderator"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.SetModerator(r.Context(), who(r).account, chi.URLParam(r, "accountID"), in.Moderator)
respond(w, out, err, http.StatusOK)
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
from := time.Unix(0, 0).UTC()
if raw := r.URL.Query().Get("from"); raw != "" {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
from = parsed
}
}
out, err := s.store.ListEvents(r.Context(), from)
respond(w, out, err, 200)
}
type createEventRequest struct {
Name string `json:"name"`
Description string `json:"description"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
RegistrationDeadline time.Time `json:"registrationDeadline"`
PingEveryone *bool `json:"pingEveryone"`
}
func (in createEventRequest) options() application.CreateEventOptions {
return application.CreateEventOptions{PingEveryone: in.PingEveryone == nil || *in.PingEveryone}
}
func (s *Server) createEvent(w http.ResponseWriter, r *http.Request) {
var in createEventRequest
if !decode(w, r, &in) {
return
}
out, err := s.service.CreateEvent(r.Context(), who(r).account, domain.Event{
Name: in.Name,
Description: in.Description,
StartsAt: in.StartsAt,
EndsAt: in.EndsAt,
RegistrationDeadline: in.RegistrationDeadline,
}, in.options())
respond(w, out, err, http.StatusCreated)
}
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetEvent(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) updateEvent(w http.ResponseWriter, r *http.Request) {
var in domain.Event
if !decode(w, r, &in) {
return
}
out, err := s.service.UpdateEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in)
respond(w, out, err, http.StatusOK)
}
func (s *Server) getEventTournament(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetTournamentByEvent(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) rsvps(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListRSVPs(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, 200)
}
func (s *Server) createParticipant(w http.ResponseWriter, r *http.Request) {
var in struct {
DisplayName string `json:"displayName"`
Ratings domain.Ratings `json:"ratings"`
Status domain.RSVPStatus `json:"status"`
}
if !decode(w, r, &in) {
return
}
player, rsvp, err := s.service.CreateParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.DisplayName, in.Ratings, in.Status)
respond(w, map[string]any{"player": player, "rsvp": rsvp}, err, http.StatusCreated)
}
func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) {
var in struct {
Status domain.RSVPStatus `json:"status"`
}
if !decode(w, r, &in) {
return
}
id := who(r)
out, err := s.service.SetRSVP(r.Context(), id.account, id.player, chi.URLParam(r, "eventID"), chi.URLParam(r, "playerID"), in.Status)
respond(w, out, err, 200)
}
func (s *Server) removeParticipant(w http.ResponseWriter, r *http.Request) {
err := s.service.RemoveParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), chi.URLParam(r, "playerID"))
respond(w, nil, err, http.StatusNoContent)
}
func (s *Server) balance(w http.ResponseWriter, r *http.Request) {
out, err := s.service.Balance(r.Context(), who(r).account, chi.URLParam(r, "eventID"))
respond(w, out, err, 200)
}
func (s *Server) selectBalance(w http.ResponseWriter, r *http.Request) {
var in domain.BalanceCandidate
if !decode(w, r, &in) {
return
}
err := s.service.SelectBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in)
respond(w, map[string]string{"status": "selected"}, err, 200)
}
func (s *Server) teams(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListTeams(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, 200)
}
func (s *Server) assignCaptain(w http.ResponseWriter, r *http.Request) {
var in struct {
PlayerID string `json:"playerId"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.AssignCaptain(r.Context(), who(r).account, chi.URLParam(r, "teamID"), in.PlayerID)
respond(w, out, err, 200)
}
func (s *Server) renameTeam(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
}
if !decode(w, r, &in) {
return
}
id := who(r)
out, err := s.service.RenameTeam(r.Context(), id.account, id.player, chi.URLParam(r, "teamID"), in.Name)
respond(w, out, err, http.StatusOK)
}
func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
var rules domain.Ruleset
if !decode(w, r, &rules) {
return
}
if rules.ID == "" {
rules.ID = application.NewID()
}
if err := rules.Validate(); err != nil {
writeError(w, err)
return
}
out, err := s.store.SaveRuleset(r.Context(), rules)
respond(w, out, err, 201)
}
func (s *Server) getRuleset(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetRuleset(r.Context(), chi.URLParam(r, "id"))
respond(w, out, err, 200)
}
func (s *Server) listRulesets(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListRulesets(r.Context())
respond(w, out, err, 200)
}
func (s *Server) coinToss(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamAID string `json:"teamAId"`
TeamBID string `json:"teamBId"`
Seed string `json:"seed"`
EventID string `json:"eventId"`
ActingTeamID string `json:"actingTeamId"`
}
if !decode(w, r, &in) {
return
}
if err := s.authorizeTeam(r, in.EventID, in.ActingTeamID); err != nil {
writeError(w, err)
return
}
out, err := domain.TossCoin(in.TeamAID, in.TeamBID, in.Seed, time.Now().UTC())
if err == nil {
_ = s.store.AppendAudit(r.Context(), who(r).account.ID, "coin.tossed", in.TeamAID+":"+in.TeamBID, out)
}
respond(w, out, err, 200)
}
func (s *Server) createMapDraft(w http.ResponseWriter, r *http.Request) {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
var in struct {
ID, FirstTeamID, RulesetID string
PoolIndex int
TeamIDs [2]string
PlayedMaps []string
}
if !decode(w, r, &in) {
return
}
rules, err := s.store.GetRuleset(r.Context(), in.RulesetID)
if err == nil && (in.PoolIndex < 0 || in.PoolIndex >= len(rules.MapPools)) {
err = fmt.Errorf("%w: map pool index is outside the ruleset", domain.ErrInvalid)
}
var pool []string
if err == nil {
for _, name := range rules.MapPools[in.PoolIndex] {
if !slices.Contains(in.PlayedMaps, name) {
pool = append(pool, name)
}
}
if len(pool) == 0 {
err = fmt.Errorf("%w: no unplayed maps remain in pool", domain.ErrInvalid)
}
}
var draft *domain.MapDraft
if err == nil {
draft, err = domain.NewMapDraft(pool, in.FirstTeamID, in.TeamIDs)
}
if err == nil {
if in.ID == "" {
in.ID = application.NewID()
}
err = s.store.SaveDraft(r.Context(), in.ID, "map", draft, -1)
}
respond(w, map[string]any{"id": in.ID, "draft": draft, "version": 0}, err, 201)
}
func (s *Server) mapBan(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID, Map, EventID string
Version int
}
if !decode(w, r, &in) {
return
}
if err := s.authorizeTeam(r, in.EventID, in.TeamID); err != nil {
writeError(w, err)
return
}
var draft domain.MapDraft
kind, version, err := s.store.GetDraft(r.Context(), chi.URLParam(r, "id"), &draft)
if err == nil && (kind != "map" || version != in.Version) {
err = domain.ErrConflict
}
if err == nil {
err = draft.Ban(in.TeamID, in.Map, who(r).account.ID, time.Now().UTC())
}
if err == nil {
err = s.store.SaveDraft(r.Context(), chi.URLParam(r, "id"), "map", draft, version)
}
respond(w, map[string]any{"draft": draft, "version": version + 1}, err, 200)
}
func (s *Server) createHeroDraft(w http.ResponseWriter, r *http.Request) {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
var in struct {
ID, FirstTeamID, RulesetID string
TeamIDs [2]string
PreviousBans map[string][]string
}
if !decode(w, r, &in) {
return
}
rules, err := s.store.GetRuleset(r.Context(), in.RulesetID)
var draft *domain.HeroDraft
if err == nil {
draft, err = domain.NewHeroDraft(rules.Heroes, in.TeamIDs, in.FirstTeamID, rules.HeroBansPerTeam, in.PreviousBans)
}
if err == nil {
if in.ID == "" {
in.ID = application.NewID()
}
err = s.store.SaveDraft(r.Context(), in.ID, "hero", draft, -1)
}
respond(w, map[string]any{"id": in.ID, "draft": draft, "version": 0}, err, 201)
}
func (s *Server) heroBan(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID, Hero, EventID string
Version int
}
if !decode(w, r, &in) {
return
}
if err := s.authorizeTeam(r, in.EventID, in.TeamID); err != nil {
writeError(w, err)
return
}
var draft domain.HeroDraft
kind, version, err := s.store.GetDraft(r.Context(), chi.URLParam(r, "id"), &draft)
if err == nil && (kind != "hero" || version != in.Version) {
err = domain.ErrConflict
}
if err == nil {
err = draft.Ban(in.TeamID, in.Hero, who(r).account.ID, time.Now().UTC())
}
if err == nil {
err = s.store.SaveDraft(r.Context(), chi.URLParam(r, "id"), "hero", draft, version)
}
respond(w, map[string]any{"draft": draft, "version": version + 1}, err, 200)
}
func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error {
id := who(r)
if id.account.IsStaff() {
return nil
}
teams, err := s.store.ListTeams(r.Context(), eventID)
if err != nil {
return err
}
for _, team := range teams {
if team.ID == teamID && application.CanActForTeam(id.account, id.player, team) {
return nil
}
}
return domain.ErrForbidden
}
func (s *Server) createTournament(w http.ResponseWriter, r *http.Request) {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
var in struct {
ID, EventID, Name string
TeamIDs []string
}
if !decode(w, r, &in) {
return
}
if in.ID == "" {
in.ID = application.NewID()
}
t, err := domain.NewTournament(in.ID, in.EventID, in.Name, in.TeamIDs)
if err == nil {
_, err = s.store.SaveTournament(r.Context(), *t)
}
if err == nil {
for _, series := range t.Rounds[0] {
if _, err = s.store.SaveSeries(r.Context(), series); err != nil {
break
}
}
}
respond(w, t, err, 201)
}
func (s *Server) getTournament(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetTournament(r.Context(), chi.URLParam(r, "id"))
respond(w, out, err, 200)
}
func (s *Server) getSeries(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
respond(w, out, err, 200)
}
func (s *Server) recordResult(w http.ResponseWriter, r *http.Request) {
var in struct {
MapName string `json:"mapName"`
Outcome domain.MapOutcome `json:"outcome"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.RecordMap(r.Context(), who(r).account, chi.URLParam(r, "id"), in.MapName, in.Outcome, in.ExpectedVersion)
if err == nil && out.WinnerTeamID != "" && out.TournamentID != "" {
var t domain.Tournament
t, err = s.store.GetTournament(r.Context(), out.TournamentID)
if err == nil {
for round := range t.Rounds {
for match := range t.Rounds[round] {
if t.Rounds[round][match].ID == out.ID {
t.Rounds[round][match] = out
err = t.Advance(round, match)
if err == nil {
_, err = s.store.SaveTournament(r.Context(), t)
}
if err == nil && round+1 < len(t.Rounds) {
next := t.Rounds[round+1][match/2]
if next.TeamAID != "" && next.TeamBID != "" {
_, err = s.store.SaveSeries(r.Context(), next)
}
}
}
}
}
}
}
respond(w, out, err, 200)
}
func (s *Server) correctResult(w http.ResponseWriter, r *http.Request) {
index, err := strconv.Atoi(chi.URLParam(r, "index"))
if err != nil || index < 0 {
writeError(w, fmt.Errorf("%w: invalid result index", domain.ErrInvalid))
return
}
var in struct {
MapName string `json:"mapName"`
Outcome domain.MapOutcome `json:"outcome"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.CorrectMap(r.Context(), who(r).account, chi.URLParam(r, "id"), index, in.MapName, in.Outcome, in.ExpectedVersion)
respond(w, out, err, 200)
}
func (s *Server) stream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, errors.New("streaming unsupported"))
return
}
topic := r.URL.Query().Get("topic")
if topic == "" {
topic = "*"
}
ch, cancel := s.hub.Subscribe(topic)
defer cancel()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprint(w, ": connected\n\n")
flusher.Flush()
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case event := <-ch:
fmt.Fprintf(w, "event: update\ndata: %s\n\n", mustJSON(event))
flusher.Flush()
}
}
}
func decode(w http.ResponseWriter, r *http.Request, target any) bool {
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
writeError(w, fmt.Errorf("%w: %v", domain.ErrInvalid, err))
return false
}
return true
}
func respond(w http.ResponseWriter, value any, err error, status int) {
if err != nil {
writeError(w, err)
return
}
writeJSON(w, status, value)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, domain.ErrInvalid):
status = http.StatusBadRequest
case errors.Is(err, domain.ErrUnauthorized):
status = http.StatusUnauthorized
case errors.Is(err, domain.ErrForbidden):
status = http.StatusForbidden
case errors.Is(err, domain.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrDraftComplete):
status = http.StatusConflict
default:
slog.Error("request failed", "error", err)
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func mustJSON(v any) []byte {
out, _ := json.Marshal(v)
return out
}