227 lines
7.7 KiB
Go
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
|
|
}
|