package lobby import ( "context" "fmt" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" ) // IssueInviteInput is the parameter struct for Service.IssueInvite. type IssueInviteInput struct { GameID uuid.UUID InviterUserID uuid.UUID InvitedUserID *uuid.UUID RaceName string ExpiresAt *time.Time } // IssueInvite creates a new pending invite. When InvitedUserID is set // the invite is user-bound; otherwise the service generates a hex code // for code-based redemption. The game must be a private game owned by // inviterUserID and in `enrollment_open` (or `draft`/`ready_to_start`). func (s *Service) IssueInvite(ctx context.Context, in IssueInviteInput) (Invite, error) { game, err := s.GetGame(ctx, in.GameID) if err != nil { return Invite{}, err } if game.Visibility != VisibilityPrivate { return Invite{}, fmt.Errorf("%w: only private games accept invites", ErrConflict) } if err := s.checkOwner(game, &in.InviterUserID, false); err != nil { return Invite{}, err } switch game.Status { case GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart: default: return Invite{}, fmt.Errorf("%w: cannot issue invite while game is %q", ErrConflict, game.Status) } displayName := strings.TrimSpace(in.RaceName) if displayName != "" { validated, err := ValidateDisplayName(displayName) if err != nil { return Invite{}, err } displayName = validated } now := s.deps.Now().UTC() expires := now.Add(s.deps.Config.InviteDefaultTTL) if in.ExpiresAt != nil { expires = in.ExpiresAt.UTC() } if !expires.After(now) { return Invite{}, fmt.Errorf("%w: expires_at must be in the future", ErrInvalidInput) } var code string if in.InvitedUserID == nil { generated, err := generateInviteCode() if err != nil { return Invite{}, err } code = generated } invite, err := s.deps.Store.InsertInvite(ctx, inviteInsert{ InviteID: uuid.New(), GameID: in.GameID, InviterUserID: in.InviterUserID, InvitedUserID: in.InvitedUserID, Code: code, RaceName: displayName, ExpiresAt: expires, }) if err != nil { return Invite{}, err } if in.InvitedUserID != nil { intent := LobbyNotification{ Kind: NotificationLobbyInviteReceived, IdempotencyKey: "invite-received:" + invite.InviteID.String(), Recipients: []uuid.UUID{*in.InvitedUserID}, Payload: map[string]any{ "game_id": game.GameID.String(), "inviter_user_id": in.InviterUserID.String(), }, } if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { s.deps.Logger.Warn("invite issued notification failed", zap.String("invite_id", invite.InviteID.String()), zap.Error(pubErr)) } } return invite, nil } // RedeemInvite turns a pending invite into a membership for redeemerUserID. // User-bound invites require the recipient to match // `invited_user_id`; code-based invites accept any caller. func (s *Service) RedeemInvite(ctx context.Context, redeemerUserID uuid.UUID, gameID, inviteID uuid.UUID) (Invite, error) { invite, err := s.deps.Store.LoadInvite(ctx, inviteID) if err != nil { return Invite{}, err } if invite.GameID != gameID { return Invite{}, ErrNotFound } if invite.Status != InviteStatusPending { return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status) } now := s.deps.Now().UTC() if !invite.ExpiresAt.After(now) { return Invite{}, fmt.Errorf("%w: invite expired at %s", ErrConflict, invite.ExpiresAt.UTC().Format(time.RFC3339)) } if invite.InvitedUserID != nil && *invite.InvitedUserID != redeemerUserID { return Invite{}, fmt.Errorf("%w: invite is bound to a different user", ErrForbidden) } game, err := s.GetGame(ctx, gameID) if err != nil { return Invite{}, err } switch game.Status { case GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart: default: return Invite{}, fmt.Errorf("%w: cannot redeem invite while game is %q", ErrConflict, game.Status) } displayName := invite.RaceName if displayName == "" { return Invite{}, fmt.Errorf("%w: invite carries no race_name; ask issuer to re-issue", ErrInvalidInput) } canonical, err := s.deps.Policy.Canonical(displayName) if err != nil { return Invite{}, err } if err := s.assertRaceNameAvailable(ctx, canonical, redeemerUserID, gameID); err != nil { return Invite{}, err } if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{ Name: displayName, Canonical: canonical, Status: RaceNameStatusReservation, OwnerUserID: redeemerUserID, GameID: gameID, ReservedAt: &now, }); err != nil { return Invite{}, err } membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{ MembershipID: uuid.New(), GameID: gameID, UserID: redeemerUserID, RaceName: displayName, CanonicalKey: canonical, }) if err != nil { _ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID) return Invite{}, err } updated, err := s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusRedeemed, now) if err != nil { return Invite{}, err } s.deps.Cache.PutMembership(membership) s.deps.Cache.PutRaceName(RaceNameEntry{ Name: displayName, Canonical: canonical, Status: RaceNameStatusReservation, OwnerUserID: redeemerUserID, GameID: gameID, ReservedAt: &now, }) return updated, nil } // DeclineInvite transitions a pending recipient-bound invite to // `declined`. Code-based invites cannot be declined (the code holder // just never redeems them). func (s *Service) DeclineInvite(ctx context.Context, callerUserID uuid.UUID, gameID, inviteID uuid.UUID) (Invite, error) { invite, err := s.deps.Store.LoadInvite(ctx, inviteID) if err != nil { return Invite{}, err } if invite.GameID != gameID { return Invite{}, ErrNotFound } if invite.InvitedUserID == nil { return Invite{}, fmt.Errorf("%w: code-based invites cannot be declined", ErrConflict) } if *invite.InvitedUserID != callerUserID { return Invite{}, fmt.Errorf("%w: caller is not the invite recipient", ErrForbidden) } if invite.Status != InviteStatusPending { return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status) } now := s.deps.Now().UTC() return s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusDeclined, now) } // RevokeInvite transitions a pending invite to `revoked`. Only the // inviter (or admin) may revoke. func (s *Service) RevokeInvite(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, inviteID uuid.UUID) (Invite, error) { invite, err := s.deps.Store.LoadInvite(ctx, inviteID) if err != nil { return Invite{}, err } if invite.GameID != gameID { return Invite{}, ErrNotFound } if !callerIsAdmin { if callerUserID == nil || invite.InviterUserID != *callerUserID { return Invite{}, fmt.Errorf("%w: caller is not the inviter", ErrForbidden) } } if invite.Status != InviteStatusPending { return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status) } now := s.deps.Now().UTC() updated, err := s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusRevoked, now) if err != nil { return Invite{}, err } if invite.InvitedUserID != nil { intent := LobbyNotification{ Kind: NotificationLobbyInviteRevoked, IdempotencyKey: "invite-revoked:" + inviteID.String(), Recipients: []uuid.UUID{*invite.InvitedUserID}, Payload: map[string]any{ "game_id": gameID.String(), }, } if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { s.deps.Logger.Warn("invite revoked notification failed", zap.String("invite_id", inviteID.String()), zap.Error(pubErr)) } } return updated, nil } // ListMyInvites returns every invite where userID is the recipient. func (s *Service) ListMyInvites(ctx context.Context, userID uuid.UUID) ([]Invite, error) { return s.deps.Store.ListMyInvites(ctx, userID) }