Files
2026-05-06 10:14:55 +03:00

227 lines
7.7 KiB
Go

package lobby
import (
"context"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SubmitApplicationInput is the parameter struct for
// Service.SubmitApplication.
type SubmitApplicationInput struct {
GameID uuid.UUID
ApplicantUserID uuid.UUID
RaceName string
}
// SubmitApplication creates a new application bound to (gameID,
// applicantUserID, raceName). The game must be `enrollment_open`. The
// race name is recorded for context but the per-game canonical
// reservation is created at approval time.
func (s *Service) SubmitApplication(ctx context.Context, in SubmitApplicationInput) (Application, error) {
displayName, err := ValidateDisplayName(in.RaceName)
if err != nil {
return Application{}, err
}
game, err := s.GetGame(ctx, in.GameID)
if err != nil {
return Application{}, err
}
if game.Visibility != VisibilityPublic {
return Application{}, fmt.Errorf("%w: only public games accept applications", ErrConflict)
}
if game.Status != GameStatusEnrollmentOpen {
return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict)
}
app, err := s.deps.Store.InsertApplication(ctx, applicationInsert{
ApplicationID: uuid.New(),
GameID: in.GameID,
ApplicantUserID: in.ApplicantUserID,
RaceName: displayName,
})
if err != nil {
return Application{}, err
}
intent := LobbyNotification{
Kind: NotificationLobbyApplicationSubmitted,
IdempotencyKey: "application:" + app.ApplicationID.String(),
Payload: map[string]any{
"game_id": game.GameID.String(),
"application_id": app.ApplicationID.String(),
},
}
if game.OwnerUserID != nil {
intent.Recipients = []uuid.UUID{*game.OwnerUserID}
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
// Notification failures never roll back the canonical write.
s.deps.Logger.Warn("application submitted notification failed",
zap.String("application_id", app.ApplicationID.String()),
zap.Error(pubErr))
}
return app, nil
}
// ApproveApplication transitions a pending application to `approved`,
// creates the matching membership, and reserves the race-name canonical
// in the Race Name Directory.
func (s *Service) ApproveApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) {
app, err := s.deps.Store.LoadApplication(ctx, applicationID)
if err != nil {
return Application{}, err
}
if app.GameID != gameID {
return Application{}, ErrNotFound
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Application{}, err
}
if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil {
return Application{}, err
}
if app.Status != ApplicationStatusPending {
return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status)
}
if game.Status != GameStatusEnrollmentOpen {
return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict)
}
canonical, err := s.deps.Policy.Canonical(app.RaceName)
if err != nil {
return Application{}, err
}
if err := s.assertRaceNameAvailable(ctx, canonical, app.ApplicantUserID, gameID); err != nil {
return Application{}, err
}
now := s.deps.Now().UTC()
if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
Name: app.RaceName,
Canonical: canonical,
Status: RaceNameStatusReservation,
OwnerUserID: app.ApplicantUserID,
GameID: gameID,
ReservedAt: &now,
}); err != nil {
return Application{}, err
}
membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{
MembershipID: uuid.New(),
GameID: gameID,
UserID: app.ApplicantUserID,
RaceName: app.RaceName,
CanonicalKey: canonical,
})
if err != nil {
// Best-effort cleanup of the race-name reservation if the
// membership insert lost the race; the cascade still records
// the rejection.
_ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID)
return Application{}, err
}
updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusApproved, now)
if err != nil {
return Application{}, err
}
s.deps.Cache.PutMembership(membership)
s.deps.Cache.PutRaceName(RaceNameEntry{
Name: app.RaceName,
Canonical: canonical,
Status: RaceNameStatusReservation,
OwnerUserID: app.ApplicantUserID,
GameID: gameID,
ReservedAt: &now,
})
intent := LobbyNotification{
Kind: NotificationLobbyApplicationApproved,
IdempotencyKey: "application-approved:" + applicationID.String(),
Recipients: []uuid.UUID{app.ApplicantUserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("application approved notification failed",
zap.String("application_id", updated.ApplicationID.String()),
zap.Error(pubErr))
}
return updated, nil
}
// RejectApplication transitions a pending application to `rejected`.
func (s *Service) RejectApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) {
app, err := s.deps.Store.LoadApplication(ctx, applicationID)
if err != nil {
return Application{}, err
}
if app.GameID != gameID {
return Application{}, ErrNotFound
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Application{}, err
}
if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil {
return Application{}, err
}
if app.Status != ApplicationStatusPending {
return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status)
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusRejected, now)
if err != nil {
return Application{}, err
}
intent := LobbyNotification{
Kind: NotificationLobbyApplicationRejected,
IdempotencyKey: "application-rejected:" + applicationID.String(),
Recipients: []uuid.UUID{app.ApplicantUserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("application rejected notification failed",
zap.String("application_id", updated.ApplicationID.String()),
zap.Error(pubErr))
}
return updated, nil
}
// ListMyApplications returns every application owned by userID.
func (s *Service) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) {
return s.deps.Store.ListMyApplications(ctx, userID)
}
// checkGameAdminOrOwner enforces that the caller is either an admin or
// (for private games) the owner. Public games admin-only — same rule as
// transition().
func (s *Service) checkGameAdminOrOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error {
return s.checkOwner(game, callerUserID, callerIsAdmin)
}
// assertRaceNameAvailable returns nil when canonical is free for
// userID inside gameID. Free means: no `registered` / `reservation` /
// `pending_registration` owned by anyone else.
func (s *Service) assertRaceNameAvailable(ctx context.Context, canonical CanonicalKey, userID, gameID uuid.UUID) error {
_ = gameID
rows, err := s.deps.Store.FindRaceNameByCanonical(ctx, canonical)
if err != nil {
return err
}
for _, r := range rows {
if r.OwnerUserID == userID {
// Same user already binds this canonical — the per-game PK
// handles same-game collisions, and a user is allowed to
// hold the same canonical across multiple active games.
continue
}
switch r.Status {
case RaceNameStatusRegistered, RaceNameStatusReservation, RaceNameStatusPendingRegistration:
return fmt.Errorf("%w: race name held by another user", ErrRaceNameTaken)
}
}
return nil
}