Files
mixmaker/backend/internal/application/service.go
2026-07-19 01:21:00 +03:00

316 lines
11 KiB
Go

package application
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"mixmaker/backend/internal/domain"
)
type Store interface {
Ready(context.Context) error
UpsertDiscordAccount(context.Context, domain.Account, bool) (domain.Account, domain.Player, error)
AccountBySession(context.Context, string) (domain.Account, domain.Player, error)
CreateSession(context.Context, string, string, time.Time) error
DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
ListPlayers(context.Context) ([]domain.Player, error)
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
CreateEvent(context.Context, domain.Event) (domain.Event, error)
GetEvent(context.Context, string) (domain.Event, error)
UpdateEvent(context.Context, domain.Event) (domain.Event, error)
ListEvents(context.Context, time.Time) ([]domain.Event, error)
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error)
ListRSVPs(context.Context, string) ([]domain.RSVP, error)
SaveTeams(context.Context, string, []domain.Team) error
ListTeams(context.Context, string) ([]domain.Team, error)
AssignCaptain(context.Context, string, string) (domain.Team, error)
SaveRuleset(context.Context, domain.Ruleset) (domain.Ruleset, error)
ListRulesets(context.Context) ([]domain.Ruleset, error)
GetRuleset(context.Context, string) (domain.Ruleset, error)
SaveSeries(context.Context, domain.Series) (domain.Series, error)
GetSeries(context.Context, string) (domain.Series, error)
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
GetTournament(context.Context, string) (domain.Tournament, error)
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
SaveDraft(context.Context, string, string, any, int) error
GetDraft(context.Context, string, any) (string, int, error)
AppendAudit(context.Context, string, string, string, any) error
}
type Publisher interface{ Publish(topic string, value any) }
type Service struct {
Store Store
Bus Publisher
Now func() time.Time
}
func New(store Store, bus Publisher) *Service {
return &Service{Store: store, Bus: bus, Now: func() time.Time { return time.Now().UTC() }}
}
func NewID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func (s *Service) Authenticate(ctx context.Context, session string) (domain.Account, domain.Player, error) {
if session == "" {
return domain.Account{}, domain.Player{}, domain.ErrUnauthorized
}
return s.Store.AccountBySession(ctx, session)
}
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs []string) (domain.Player, error) {
if actor.ID != current.AccountID {
return domain.Player{}, domain.ErrForbidden
}
if err := ratings.Validate(); err != nil {
return domain.Player{}, err
}
if displayName != "" {
current.DisplayName = displayName
}
current.Ratings = ratings
current.PreferredRoles = preferredRoles
current.PreferredPlayerIDs = preferredPlayerIDs
current.UpdatedAt = s.Now()
if err := current.ValidatePreferences(); err != nil {
return domain.Player{}, err
}
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return domain.Player{}, err
}
knownPlayers := make(map[string]bool, len(players))
for _, player := range players {
knownPlayers[player.ID] = true
}
for _, playerID := range current.PreferredPlayerIDs {
if !knownPlayers[playerID] {
return domain.Player{}, fmt.Errorf("%w: preferred teammate does not exist", domain.ErrInvalid)
}
}
return s.Store.UpdatePlayer(ctx, current)
}
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) {
if !actor.IsAdmin() {
return domain.Event{}, domain.ErrForbidden
}
if event.RegistrationDeadline.IsZero() {
event.RegistrationDeadline = event.StartsAt
}
event.ID, event.CreatedBy, event.CreatedAt, event.UpdatedAt = NewID(), actor.ID, s.Now(), s.Now()
if err := event.Validate(); err != nil {
return domain.Event{}, err
}
out, err := s.Store.CreateEvent(ctx, event)
if err == nil {
s.Bus.Publish("events", out)
}
return out, err
}
func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) {
if !actor.IsAdmin() {
return domain.Event{}, domain.ErrForbidden
}
current, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.Event{}, err
}
if changes.RegistrationDeadline.IsZero() {
changes.RegistrationDeadline = changes.StartsAt
}
current.Name = changes.Name
current.Description = changes.Description
current.StartsAt = changes.StartsAt
current.EndsAt = changes.EndsAt
current.RegistrationDeadline = changes.RegistrationDeadline
current.UpdatedAt = s.Now()
if err := current.Validate(); err != nil {
return domain.Event{}, err
}
out, err := s.Store.UpdateEvent(ctx, current)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "event.updated", eventID, out)
s.Bus.Publish("events", out)
}
return out, err
}
func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer domain.Player, eventID, playerID string, status domain.RSVPStatus) (domain.RSVP, error) {
source := domain.SourcePlayer
if playerID == "" {
playerID = actorPlayer.ID
}
if playerID != actorPlayer.ID {
if !actor.IsAdmin() {
return domain.RSVP{}, domain.ErrForbidden
}
source = domain.SourceAdmin
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.RSVP{}, err
}
if !actor.IsAdmin() && s.Now().After(event.RegistrationDeadline) {
return domain.RSVP{}, fmt.Errorf("%w: registration deadline has passed", domain.ErrConflict)
}
rsvp := domain.RSVP{EventID: eventID, PlayerID: playerID, ActorAccountID: actor.ID, Status: status, Source: source, UpdatedAt: s.Now()}
if err := rsvp.Validate(); err != nil {
return domain.RSVP{}, err
}
out, err := s.Store.UpsertRSVP(ctx, rsvp)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "rsvp.set", eventID, out)
s.Bus.Publish("event:"+eventID, out)
}
return out, err
}
func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, eventID, displayName string, ratings domain.Ratings, status domain.RSVPStatus) (domain.Player, domain.RSVP, error) {
if !actor.IsAdmin() {
return domain.Player{}, domain.RSVP{}, domain.ErrForbidden
}
displayName = strings.TrimSpace(displayName)
if displayName == "" {
return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: display name is required", domain.ErrInvalid)
}
if err := ratings.Validate(); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
if _, err := s.Store.GetEvent(ctx, eventID); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
now := s.Now()
player := domain.Player{
ID: NewID(),
DisplayName: displayName,
Ratings: ratings,
CreatedAt: now,
UpdatedAt: now,
}
rsvp := domain.RSVP{
EventID: eventID,
PlayerID: player.ID,
ActorAccountID: actor.ID,
Status: status,
Source: domain.SourceAdmin,
UpdatedAt: now,
}
if err := rsvp.Validate(); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
player, rsvp, err := s.Store.CreateParticipant(ctx, player, rsvp)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "participant.created", eventID, map[string]any{"player": player, "rsvp": rsvp})
s.Bus.Publish("event:"+eventID, rsvp)
}
return player, rsvp, err
}
func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) {
if !actor.IsAdmin() {
return nil, domain.ErrForbidden
}
rsvps, err := s.Store.ListRSVPs(ctx, eventID)
if err != nil {
return nil, err
}
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return nil, err
}
going := map[string]bool{}
for _, r := range rsvps {
going[r.PlayerID] = r.Status == domain.Going
}
eligible := players[:0]
for _, p := range players {
if going[p.ID] {
eligible = append(eligible, p)
}
}
return domain.Balance(eventID, eligible)
}
func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate) error {
if !actor.IsAdmin() {
return domain.ErrForbidden
}
if err := s.Store.SaveTeams(ctx, eventID, candidate.Teams); err != nil {
return err
}
_ = s.Store.AppendAudit(ctx, actor.ID, "balance.selected", eventID, candidate)
s.Bus.Publish("event:"+eventID, candidate.Teams)
return nil
}
func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamID, playerID string) (domain.Team, error) {
if !actor.IsAdmin() {
return domain.Team{}, domain.ErrForbidden
}
team, err := s.Store.AssignCaptain(ctx, teamID, playerID)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "captain.assigned", teamID, map[string]string{"playerId": playerID})
s.Bus.Publish("team:"+teamID, team)
}
return team, err
}
func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool {
return actor.IsAdmin() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID)
}
func (s *Service) RecordMap(ctx context.Context, actor domain.Account, seriesID, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
if !actor.IsAdmin() {
return domain.Series{}, domain.ErrForbidden
}
series, err := s.Store.GetSeries(ctx, seriesID)
if err != nil {
return domain.Series{}, err
}
if series.Version != expectedVersion {
return domain.Series{}, fmt.Errorf("%w: stale series version", domain.ErrConflict)
}
if err := series.RecordResult(domain.MapResult{MapName: mapName, Outcome: outcome, ActorAccountID: actor.ID, RecordedAt: s.Now()}); err != nil {
return domain.Series{}, err
}
out, err := s.Store.SaveSeries(ctx, series)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "series.map_recorded", seriesID, out.Results[len(out.Results)-1])
s.Bus.Publish("series:"+seriesID, out)
}
return out, err
}
func (s *Service) CorrectMap(ctx context.Context, actor domain.Account, seriesID string, resultIndex int, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
if !actor.IsAdmin() {
return domain.Series{}, domain.ErrForbidden
}
series, err := s.Store.GetSeries(ctx, seriesID)
if err != nil {
return domain.Series{}, err
}
if series.Version != expectedVersion {
return domain.Series{}, fmt.Errorf("%w: stale series version", domain.ErrConflict)
}
if err := series.CorrectResult(resultIndex, domain.MapResult{MapName: mapName, Outcome: outcome, ActorAccountID: actor.ID, RecordedAt: s.Now()}); err != nil {
return domain.Series{}, err
}
out, err := s.Store.SaveSeries(ctx, series)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "series.map_corrected", seriesID, out.Results[len(out.Results)-1])
s.Bus.Publish("series:"+seriesID, out)
}
return out, err
}