Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
This commit is contained in:
684
backend/internal/adapter/httpapi/server.go
Normal file
684
backend/internal/adapter/httpapi/server.go
Normal file
@@ -0,0 +1,684 @@
|
||||
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/events", s.events)
|
||||
api.Post("/api/events", s.createEvent)
|
||||
api.Get("/api/events/{eventID}", s.getEvent)
|
||||
api.Put("/api/events/{eventID}", s.updateEvent)
|
||||
api.Get("/api/events/{eventID}/tournament", s.getEventTournament)
|
||||
api.Get("/api/events/{eventID}/rsvps", s.rsvps)
|
||||
api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP)
|
||||
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.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
||||
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}/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 requireAdmin(r *http.Request) error {
|
||||
if !who(r).account.IsAdmin() {
|
||||
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"`
|
||||
}
|
||||
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)
|
||||
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) events(w http.ResponseWriter, r *http.Request) {
|
||||
from := time.Now().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)
|
||||
}
|
||||
|
||||
func (s *Server) createEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var in domain.Event
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.CreateEvent(r.Context(), who(r).account, in)
|
||||
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) 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) 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) saveRuleset(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireAdmin(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 := requireAdmin(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 := requireAdmin(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.IsAdmin() {
|
||||
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 := requireAdmin(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
|
||||
}
|
||||
446
backend/internal/adapter/postgres/store.go
Normal file
446
backend/internal/adapter/postgres/store.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Store struct{ pool *pgxpool.Pool }
|
||||
|
||||
func Open(ctx context.Context, url string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
func (s *Store) Ready(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account, admin bool) (domain.Account, domain.Player, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
role := domain.RolePlayer
|
||||
if admin {
|
||||
role = domain.RoleAdmin
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(discord_id) DO UPDATE
|
||||
SET username=excluded.username,avatar_url=excluded.avatar_url,
|
||||
role=CASE WHEN accounts.role='admin' THEN accounts.role ELSE excluded.role END
|
||||
RETURNING id,discord_id,username,avatar_url,role,created_at`,
|
||||
account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt).
|
||||
Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
|
||||
RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,created_at,updated_at`,
|
||||
p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
if err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
return account, p, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, token, accountID string, expires time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO sessions(token_hash,account_id,expires_at) VALUES($1,$2,$3)`, HashToken(token), accountID, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash=$1`, HashToken(token))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Account, domain.Player, error) {
|
||||
var a domain.Account
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
|
||||
p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.created_at,p.updated_at
|
||||
FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id
|
||||
WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)).
|
||||
Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt,
|
||||
&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||
}
|
||||
return a, p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Player, error) {
|
||||
preferredRoles, _ := json.Marshal(p.PreferredRoles)
|
||||
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
|
||||
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
|
||||
preferred_roles=$6,preferred_player_ids=$7,updated_at=$8
|
||||
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
|
||||
return p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,account_id,display_name,tank_rating,damage_rating,support_rating,
|
||||
preferred_roles,preferred_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Player
|
||||
for rows.Next() {
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
&preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at`,
|
||||
e.ID, e.Name, e.Description, e.StartsAt, e.EndsAt, e.RegistrationDeadline, e.CreatedBy, e.CreatedAt, e.UpdatedAt).
|
||||
Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt)
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (s *Store) GetEvent(ctx context.Context, id string) (domain.Event, error) {
|
||||
var event domain.Event
|
||||
err := s.pool.QueryRow(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at
|
||||
FROM events WHERE id=$1`, id).
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt)
|
||||
return event, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateEvent(ctx context.Context, event domain.Event) (domain.Event, error) {
|
||||
err := s.pool.QueryRow(ctx, `UPDATE events
|
||||
SET name=$2,description=$3,starts_at=$4,ends_at=$5,registration_deadline=$6,updated_at=$7
|
||||
WHERE id=$1
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at`,
|
||||
event.ID, event.Name, event.Description, event.StartsAt, event.EndsAt, event.RegistrationDeadline, event.UpdatedAt).
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt)
|
||||
return event, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListEvents(ctx context.Context, from time.Time) ([]domain.Event, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at FROM events WHERE ends_at >= $1 ORDER BY starts_at`, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Event
|
||||
for rows.Next() {
|
||||
var e domain.Event
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRSVP(ctx context.Context, r domain.RSVP) (domain.RSVP, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(event_id,player_id) DO UPDATE
|
||||
SET status=excluded.status,actor_account_id=excluded.actor_account_id,source=excluded.source,updated_at=excluded.updated_at
|
||||
RETURNING event_id,player_id,status,actor_account_id,source,updated_at`,
|
||||
r.EventID, r.PlayerID, r.Status, r.ActorAccountID, r.Source, r.UpdatedAt).
|
||||
Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *Store) ListRSVPs(ctx context.Context, eventID string) ([]domain.RSVP, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT event_id,player_id,status,actor_account_id,source,updated_at FROM rsvps WHERE event_id=$1 ORDER BY player_id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.RSVP
|
||||
for rows.Next() {
|
||||
var r domain.RSVP
|
||||
if err := rows.Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveTeams(ctx context.Context, eventID string, teams []domain.Team) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM teams WHERE event_id=$1`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range teams {
|
||||
body, _ := json.Marshal(t.Slots)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO teams(id,event_id,name,captain_player_id,slots) VALUES($1,$2,$3,NULLIF($4,''),$5)`, t.ID, eventID, t.Name, t.CaptainPlayerID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) ListTeams(ctx context.Context, eventID string) ([]domain.Team, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,event_id,name,COALESCE(captain_player_id,''),slots FROM teams WHERE event_id=$1 ORDER BY id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Team
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
var body []byte
|
||||
if err := rows.Scan(&t.ID, &t.EventID, &t.Name, &t.CaptainPlayerID, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &t.Slots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AssignCaptain(ctx context.Context, teamID, playerID string) (domain.Team, error) {
|
||||
var eventID string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT event_id FROM teams WHERE id=$1 AND slots @> $2::jsonb`, teamID, fmt.Sprintf(`[{"playerId":%q}]`, playerID)).Scan(&eventID); err != nil {
|
||||
return domain.Team{}, mapError(err)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE teams SET captain_player_id=$2 WHERE id=$1`, teamID, playerID); err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
teams, err := s.ListTeams(ctx, eventID)
|
||||
for _, t := range teams {
|
||||
if t.ID == teamID {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
return domain.Team{}, domain.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *Store) SaveRuleset(ctx context.Context, r domain.Ruleset) (domain.Ruleset, error) {
|
||||
body, _ := json.Marshal(r)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO rulesets(id,name,body) VALUES($1,$2,$3)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name,body=excluded.body`, r.ID, r.Name, body)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *Store) ListRulesets(ctx context.Context) ([]domain.Ruleset, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT body FROM rulesets ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var rulesets []domain.Ruleset
|
||||
for rows.Next() {
|
||||
var body []byte
|
||||
var ruleset domain.Ruleset
|
||||
if err := rows.Scan(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &ruleset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rulesets = append(rulesets, ruleset)
|
||||
}
|
||||
return rulesets, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetRuleset(ctx context.Context, id string) (domain.Ruleset, error) {
|
||||
var body []byte
|
||||
var r domain.Ruleset
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM rulesets WHERE id=$1`, id).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &r)
|
||||
}
|
||||
return r, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) SaveSeries(ctx context.Context, series domain.Series) (domain.Series, error) {
|
||||
body, _ := json.Marshal(series)
|
||||
tag, err := s.pool.Exec(ctx, `INSERT INTO series(id,tournament_id,body,version) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version
|
||||
WHERE series.version=excluded.version-1`, series.ID, series.TournamentID, body, series.Version)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return series, err
|
||||
}
|
||||
|
||||
func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error) {
|
||||
var body []byte
|
||||
var out domain.Series
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM series WHERE id=$1`, id).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||
body, _ := json.Marshal(t)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body`, t.ID, t.EventID, body)
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) GetTournament(ctx context.Context, id string) (domain.Tournament, error) {
|
||||
var body []byte
|
||||
var out domain.Tournament
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM tournaments WHERE id=$1`, id).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) GetTournamentByEvent(ctx context.Context, eventID string) (domain.Tournament, error) {
|
||||
var body []byte
|
||||
var out domain.Tournament
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM tournaments WHERE event_id=$1 ORDER BY id DESC LIMIT 1`, eventID).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) SaveDraft(ctx context.Context, id, kind string, value any, expectedVersion int) error {
|
||||
body, _ := json.Marshal(value)
|
||||
if expectedVersion < 0 {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO draft_states(id,kind,body,version) VALUES($1,$2,$3,0)`, id, kind, body)
|
||||
return err
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE draft_states SET body=$3,version=version+1,updated_at=now()
|
||||
WHERE id=$1 AND kind=$2 AND version=$4`, id, kind, body, expectedVersion)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, int, error) {
|
||||
var kind string
|
||||
var version int
|
||||
var body []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT kind,version,body FROM draft_states WHERE id=$1`, id).Scan(&kind, &version, &body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, target)
|
||||
}
|
||||
return kind, version, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
|
||||
body, _ := json.Marshal(payload)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO audit_log(actor_account_id,action,subject_id,payload) VALUES($1,$2,$3,$4)`, actor, action, subject, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, poolURL, directory string) error {
|
||||
pool, err := pgxpool.New(ctx, poolURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
if _, err = pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations(version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(directory, "*.sql"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, file := range files {
|
||||
version := filepath.Base(file)
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, version).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
up := strings.Split(string(body), "-- +mixmaker Down")[0]
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, up); err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, version)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit(ctx)
|
||||
} else {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply %s: %w", version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
26
backend/internal/adapter/postgres/store_integration_test.go
Normal file
26
backend/internal/adapter/postgres/store_integration_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationsAndReadiness(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not configured")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.Ready(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user