Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
This commit is contained in:
272
backend/internal/application/service.go
Normal file
272
backend/internal/application/service.go
Normal file
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user