540 lines
19 KiB
Go
540 lines
19 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)
|
|
ListAccounts(context.Context) ([]domain.Account, error)
|
|
UpdateAccountRole(context.Context, string, domain.GlobalRole) (domain.Account, error)
|
|
CreateSession(context.Context, string, string, time.Time) error
|
|
DeleteSession(context.Context, string) error
|
|
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
|
|
GetPlayer(context.Context, string) (domain.Player, error)
|
|
ListPlayers(context.Context) ([]domain.Player, error)
|
|
EnqueueDiscordRoleSync(context.Context, string, string, int64) 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)
|
|
SaveEventWorkflow(context.Context, domain.Event, int) (domain.Event, error)
|
|
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
|
|
GetRoster(context.Context, string) (domain.RosterDraft, error)
|
|
ResetRoster(context.Context, string) error
|
|
SaveBracketDraft(context.Context, domain.BracketDraft, int) (domain.BracketDraft, error)
|
|
GetBracketDraft(context.Context, string) (domain.BracketDraft, error)
|
|
DeleteBracketDraft(context.Context, string) error
|
|
StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error
|
|
DeleteEvent(context.Context, string) error
|
|
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error)
|
|
ListRSVPs(context.Context, string) ([]domain.RSVP, error)
|
|
DeleteRSVP(context.Context, string, string) error
|
|
SaveTeams(context.Context, string, []domain.Team) error
|
|
ListTeams(context.Context, string) ([]domain.Team, error)
|
|
GetTeam(context.Context, string) (domain.Team, error)
|
|
RenameTeam(context.Context, string, 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)
|
|
ListSeriesByEvent(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)
|
|
GetCommunitySettings(context.Context) (domain.CommunitySettings, error)
|
|
UpdateCommunitySettings(context.Context, domain.CommunitySettings) (domain.CommunitySettings, error)
|
|
AppendAudit(context.Context, string, string, string, any) error
|
|
}
|
|
|
|
type Publisher interface{ Publish(topic string, value any) }
|
|
|
|
type EventAnnouncer interface {
|
|
AnnounceEventCreated(context.Context, domain.Event, bool) error
|
|
}
|
|
|
|
type CreateEventOptions struct {
|
|
PingEveryone bool
|
|
}
|
|
|
|
func (s *Service) enqueueDiscordRoleSync(ctx context.Context, eventID, action string) {
|
|
_ = s.Store.EnqueueDiscordRoleSync(ctx, eventID, action, s.Now().UnixNano())
|
|
}
|
|
|
|
type Service struct {
|
|
Store Store
|
|
Bus Publisher
|
|
EventAnnouncer EventAnnouncer
|
|
AnnouncementTimeout time.Duration
|
|
Now func() time.Time
|
|
}
|
|
|
|
func New(store Store, bus Publisher, eventAnnouncer EventAnnouncer) *Service {
|
|
return &Service{
|
|
Store: store,
|
|
Bus: bus,
|
|
EventAnnouncer: eventAnnouncer,
|
|
AnnouncementTimeout: 3 * time.Second,
|
|
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) ListAccounts(ctx context.Context, actor domain.Account) ([]domain.Account, error) {
|
|
if !actor.IsAdmin() {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
return s.Store.ListAccounts(ctx)
|
|
}
|
|
|
|
func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accountID string, moderator bool) (domain.Account, error) {
|
|
if !actor.IsAdmin() {
|
|
return domain.Account{}, domain.ErrForbidden
|
|
}
|
|
role := domain.RolePlayer
|
|
if moderator {
|
|
role = domain.RoleModerator
|
|
}
|
|
account, err := s.Store.UpdateAccountRole(ctx, accountID, role)
|
|
if err == nil {
|
|
_ = s.Store.AppendAudit(ctx, actor.ID, "account.role_changed", accountID, map[string]domain.GlobalRole{"role": role})
|
|
s.Bus.Publish("accounts", account)
|
|
}
|
|
return account, err
|
|
}
|
|
|
|
type UpdateOwnProfileInput struct {
|
|
DisplayName *string
|
|
BattleTag *string
|
|
Ratings domain.Ratings
|
|
PreferredRoles []domain.Role
|
|
PreferredPlayerIDs []string
|
|
AvoidedPlayerIDs []string
|
|
}
|
|
|
|
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, input UpdateOwnProfileInput) (domain.Player, error) {
|
|
if actor.ID != current.AccountID {
|
|
return domain.Player{}, domain.ErrForbidden
|
|
}
|
|
if err := input.Ratings.Validate(); err != nil {
|
|
return domain.Player{}, err
|
|
}
|
|
if input.DisplayName != nil {
|
|
current.DisplayName = *input.DisplayName
|
|
}
|
|
if input.BattleTag != nil {
|
|
current.BattleTag = *input.BattleTag
|
|
}
|
|
if err := current.ValidateIdentity(); err != nil {
|
|
return domain.Player{}, err
|
|
}
|
|
current.Ratings = input.Ratings
|
|
current.PreferredRoles = input.PreferredRoles
|
|
current.PreferredPlayerIDs = input.PreferredPlayerIDs
|
|
current.AvoidedPlayerIDs = input.AvoidedPlayerIDs
|
|
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)
|
|
}
|
|
}
|
|
for _, playerID := range current.AvoidedPlayerIDs {
|
|
if !knownPlayers[playerID] {
|
|
return domain.Player{}, fmt.Errorf("%w: avoided teammate does not exist", domain.ErrInvalid)
|
|
}
|
|
}
|
|
out, err := s.Store.UpdatePlayer(ctx, current)
|
|
if err == nil {
|
|
s.Bus.Publish("players", out)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (s *Service) GetPlayerProfile(ctx context.Context, playerID string) (domain.PlayerProfile, error) {
|
|
player, err := s.Store.GetPlayer(ctx, playerID)
|
|
if err != nil {
|
|
return domain.PlayerProfile{}, err
|
|
}
|
|
events, err := s.Store.ListEvents(ctx, time.Unix(0, 0).UTC())
|
|
if err != nil {
|
|
return domain.PlayerProfile{}, err
|
|
}
|
|
matches := make([]domain.PlayerSeriesContext, 0)
|
|
for _, event := range events {
|
|
if event.State != domain.Completed {
|
|
continue
|
|
}
|
|
teams, listErr := s.Store.ListTeams(ctx, event.ID)
|
|
if listErr != nil {
|
|
return domain.PlayerProfile{}, listErr
|
|
}
|
|
var ownTeam *domain.Team
|
|
for index := range teams {
|
|
for _, slot := range teams[index].Slots {
|
|
if slot.PlayerID == playerID {
|
|
ownTeam = &teams[index]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ownTeam == nil {
|
|
continue
|
|
}
|
|
series, listErr := s.Store.ListSeriesByEvent(ctx, event.ID)
|
|
if listErr != nil {
|
|
return domain.PlayerProfile{}, listErr
|
|
}
|
|
byID := make(map[string]domain.Team, len(teams))
|
|
for _, team := range teams {
|
|
byID[team.ID] = team
|
|
}
|
|
for _, item := range series {
|
|
teamA, okA := byID[item.TeamAID]
|
|
teamB, okB := byID[item.TeamBID]
|
|
if okA && okB {
|
|
matches = append(matches, domain.PlayerSeriesContext{Event: event, Series: item, TeamA: teamA, TeamB: teamB})
|
|
}
|
|
}
|
|
}
|
|
return domain.BuildPlayerProfile(player, matches, 10), nil
|
|
}
|
|
|
|
func (s *Service) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
|
|
return s.Store.GetCommunitySettings(ctx)
|
|
}
|
|
|
|
func (s *Service) UpdateCommunitySettings(ctx context.Context, actor domain.Account, inviteURL string) (domain.CommunitySettings, error) {
|
|
if !actor.IsAdmin() {
|
|
return domain.CommunitySettings{}, domain.ErrForbidden
|
|
}
|
|
settings := domain.CommunitySettings{DiscordInviteURL: inviteURL, UpdatedAt: s.Now(), UpdatedBy: actor.ID}
|
|
if err := settings.Validate(); err != nil {
|
|
return domain.CommunitySettings{}, fmt.Errorf("%w: invalid Discord invite URL", err)
|
|
}
|
|
out, err := s.Store.UpdateCommunitySettings(ctx, settings)
|
|
if err == nil {
|
|
_ = s.Store.AppendAudit(ctx, actor.ID, "community.settings_updated", "community", out)
|
|
s.Bus.Publish("community-settings", out)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
|
|
if !actor.IsStaff() {
|
|
return domain.Event{}, domain.ErrForbidden
|
|
}
|
|
event.RegistrationDeadline = event.StartsAt
|
|
event.ID, event.CreatedBy, event.CreatedAt, event.UpdatedAt = NewID(), actor.ID, s.Now(), s.Now()
|
|
event.State, event.Version = domain.RegistrationOpen, 0
|
|
if event.RulesetID == "" {
|
|
event.RulesetID = "standard-control-hybrid-control"
|
|
}
|
|
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)
|
|
if s.EventAnnouncer != nil {
|
|
announcementCtx, cancel := context.WithTimeout(ctx, s.AnnouncementTimeout)
|
|
_ = s.EventAnnouncer.AnnounceEventCreated(announcementCtx, out, options.PingEveryone)
|
|
cancel()
|
|
}
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) {
|
|
if !actor.IsStaff() {
|
|
return domain.Event{}, domain.ErrForbidden
|
|
}
|
|
current, err := s.Store.GetEvent(ctx, eventID)
|
|
if err != nil {
|
|
return domain.Event{}, err
|
|
}
|
|
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)
|
|
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP)
|
|
}
|
|
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.IsStaff() {
|
|
return domain.RSVP{}, domain.ErrForbidden
|
|
}
|
|
source = domain.SourceAdmin
|
|
}
|
|
event, err := s.Store.GetEvent(ctx, eventID)
|
|
if err != nil {
|
|
return domain.RSVP{}, err
|
|
}
|
|
if event.State != domain.RegistrationOpen {
|
|
return domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict)
|
|
}
|
|
if !actor.IsStaff() && 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)
|
|
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error {
|
|
if !actor.IsStaff() {
|
|
return domain.ErrForbidden
|
|
}
|
|
event, err := s.Store.GetEvent(ctx, eventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if event.State != domain.RegistrationOpen {
|
|
return fmt.Errorf("%w: registration is closed", domain.ErrConflict)
|
|
}
|
|
if err := s.Store.DeleteRSVP(ctx, eventID, playerID); err != nil {
|
|
return err
|
|
}
|
|
_ = s.Store.AppendAudit(ctx, actor.ID, "participant.removed", eventID, map[string]string{"playerId": playerID})
|
|
s.Bus.Publish("event:"+eventID, map[string]string{"removedPlayerId": playerID})
|
|
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP)
|
|
return nil
|
|
}
|
|
|
|
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.IsStaff() {
|
|
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
|
|
}
|
|
event, err := s.Store.GetEvent(ctx, eventID)
|
|
if err != nil {
|
|
return domain.Player{}, domain.RSVP{}, err
|
|
}
|
|
if event.State != domain.RegistrationOpen {
|
|
return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict)
|
|
}
|
|
now := s.Now()
|
|
player := domain.Player{
|
|
ID: NewID(),
|
|
DisplayName: displayName,
|
|
Ratings: ratings,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := player.ValidateIdentity(); err != nil {
|
|
return domain.Player{}, domain.RSVP{}, err
|
|
}
|
|
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)
|
|
s.Bus.Publish("players", player)
|
|
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP)
|
|
}
|
|
return player, rsvp, err
|
|
}
|
|
|
|
func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) {
|
|
if !actor.IsStaff() {
|
|
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.IsStaff() {
|
|
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.IsStaff() {
|
|
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 (s *Service) RenameTeam(ctx context.Context, actor domain.Account, player domain.Player, teamID, name string) (domain.Team, error) {
|
|
team, err := s.Store.GetTeam(ctx, teamID)
|
|
if err != nil {
|
|
return team, err
|
|
}
|
|
event, err := s.Store.GetEvent(ctx, team.EventID)
|
|
if err != nil {
|
|
return team, err
|
|
}
|
|
if event.State != domain.Live {
|
|
return team, fmt.Errorf("%w: team names can only be changed during a live scrim", domain.ErrConflict)
|
|
}
|
|
if !CanActForTeam(actor, player, team) {
|
|
return team, domain.ErrForbidden
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if len([]rune(name)) < 2 || len([]rune(name)) > 32 {
|
|
return team, fmt.Errorf("%w: team name must contain 2 to 32 characters", domain.ErrInvalid)
|
|
}
|
|
team, err = s.Store.RenameTeam(ctx, teamID, name)
|
|
if err == nil {
|
|
_ = s.Store.AppendAudit(ctx, actor.ID, "team.renamed", teamID, map[string]string{"name": name})
|
|
s.Bus.Publish("team:"+teamID, team)
|
|
s.Bus.Publish("event:"+team.EventID, team)
|
|
s.enqueueDiscordRoleSync(ctx, team.EventID, DiscordRoleActionReconcile)
|
|
}
|
|
return team, err
|
|
}
|
|
|
|
func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool {
|
|
return actor.IsStaff() || (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.IsStaff() {
|
|
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.IsStaff() {
|
|
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
|
|
}
|