244 lines
7.8 KiB
Go
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)
|
|
}
|