// Package revokeinvite implements the `lobby.invite.revoke` message type. It // transitions the invite from created to revoked when the actor is the // private-game owner. Per `lobby/README.md` §Invite Lifecycle, no // notification is published in v1. package revokeinvite import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/telemetry" ) // Service executes the private-game invite revoke use case. type Service struct { games ports.GameStore invites ports.InviteStore clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games loads the game record for the owner-authorization check. Games ports.GameStore // Invites loads the target invite and applies the // created → revoked transition. Invites ports.InviteStore // Clock supplies the wall-clock used for DecidedAt. Clock func() time.Time // Logger records structured service-level events. Logger *slog.Logger // Telemetry records the `lobby.invite.outcomes` counter on each // successful revoke. Optional; nil disables metric emission. Telemetry *telemetry.Runtime } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Games == nil: return nil, errors.New("new revoke invite service: nil game store") case deps.Invites == nil: return nil, errors.New("new revoke invite service: nil invite store") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, invites: deps.Invites, clock: clock, logger: logger.With("service", "lobby.revokeinvite"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to revoke one invite. type Input struct { // Actor identifies the caller. Must be the private-game owner. Actor shared.Actor // GameID identifies the game referenced by the request path; it must // match the loaded invite's GameID. GameID common.GameID // InviteID identifies the target invite. InviteID common.InviteID } // Handle authorizes the owner, validates the invite state, transitions the // invite to revoked, and returns the updated record. func (service *Service) Handle(ctx context.Context, input Input) (invite.Invite, error) { if service == nil { return invite.Invite{}, errors.New("revoke invite: nil service") } if ctx == nil { return invite.Invite{}, errors.New("revoke invite: nil context") } if err := input.Actor.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: actor: %w", err) } if !input.Actor.IsUser() { return invite.Invite{}, fmt.Errorf( "%w: only the private-game owner may revoke an invite", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: %w", err) } if err := input.InviteID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: %w", err) } inv, err := service.invites.Get(ctx, input.InviteID) if err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: %w", err) } if inv.GameID != input.GameID { return invite.Invite{}, fmt.Errorf( "revoke invite: invite %q does not belong to game %q: %w", inv.InviteID, input.GameID, invite.ErrNotFound, ) } if inv.Status != invite.StatusCreated { return invite.Invite{}, fmt.Errorf( "revoke invite: status %q is not %q: %w", inv.Status, invite.StatusCreated, invite.ErrConflict, ) } gameRecord, err := service.games.Get(ctx, input.GameID) if err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: %w", err) } if gameRecord.OwnerUserID != input.Actor.UserID { return invite.Invite{}, fmt.Errorf( "%w: actor is not the owner of game %q", shared.ErrForbidden, gameRecord.GameID, ) } now := service.clock().UTC() if err := service.invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: inv.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRevoked, At: now, }); err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: %w", err) } revoked, err := service.invites.Get(ctx, inv.InviteID) if err != nil { return invite.Invite{}, fmt.Errorf("revoke invite: %w", err) } service.telemetry.RecordInviteOutcome(ctx, "revoked") logArgs := []any{ "game_id", revoked.GameID.String(), "invite_id", revoked.InviteID.String(), "invitee_user_id", revoked.InviteeUserID, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "invite revoked", logArgs...) return revoked, nil }