package lobby import ( "context" "fmt" "github.com/google/uuid" ) // InsertMembershipDirectInput is the parameter struct for // Service.InsertMembershipDirect. type InsertMembershipDirectInput struct { GameID uuid.UUID UserID uuid.UUID RaceName string } // InsertMembershipDirect grants a membership to userID inside gameID // bypassing the application/approval flow. It performs the same DB // writes as ApproveApplication: the per-game race-name reservation // row plus the membership row, and refreshes the in-memory caches. // // The method is intended for boot-time provisioning by // `backend/internal/devsandbox` and similar trusted callers. It is // not exposed through any HTTP handler. The caller must guarantee // game.Status == GameStatusEnrollmentOpen — the function returns // ErrConflict otherwise — and that the race-name policy and // canonical-key invariants are honoured (the implementation reuses // the lobby's own Policy and assertRaceNameAvailable so a duplicate // or unsuitable name still fails). // // Idempotency: if a membership for (GameID, UserID) already exists // the function returns the existing row without modifying state. // This makes the helper safe to call on every backend boot from // devsandbox.Bootstrap. func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) { displayName, err := ValidateDisplayName(in.RaceName) if err != nil { return Membership{}, err } game, err := s.GetGame(ctx, in.GameID) if err != nil { return Membership{}, err } if game.Status != GameStatusEnrollmentOpen { return Membership{}, fmt.Errorf("%w: game status is %q, want enrollment_open", ErrConflict, game.Status) } canonical, err := s.deps.Policy.Canonical(displayName) if err != nil { return Membership{}, err } existing, err := s.deps.Store.ListMembershipsForGame(ctx, in.GameID) if err != nil { return Membership{}, err } for _, m := range existing { if m.UserID == in.UserID && m.Status == MembershipStatusActive { return m, nil } } if err := s.assertRaceNameAvailable(ctx, canonical, in.UserID, in.GameID); err != nil { return Membership{}, err } now := s.deps.Now().UTC() if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{ Name: displayName, Canonical: canonical, Status: RaceNameStatusReservation, OwnerUserID: in.UserID, GameID: in.GameID, ReservedAt: &now, }); err != nil { return Membership{}, err } membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{ MembershipID: uuid.New(), GameID: in.GameID, UserID: in.UserID, RaceName: displayName, CanonicalKey: canonical, }) if err != nil { _ = s.deps.Store.DeleteRaceName(ctx, canonical, in.GameID) return Membership{}, err } s.deps.Cache.PutMembership(membership) s.deps.Cache.PutRaceName(RaceNameEntry{ Name: displayName, Canonical: canonical, Status: RaceNameStatusReservation, OwnerUserID: in.UserID, GameID: in.GameID, ReservedAt: &now, }) return membership, nil }