Files
galaxy-game/backend/internal/lobby/invites.go
T
2026-05-06 10:14:55 +03:00

244 lines
7.8 KiB
Go

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)
}