feat: backend service
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user