273 lines
9.4 KiB
Go
273 lines
9.4 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"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)
|
|
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) 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
|
|
}
|