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