// Package declineinvite implements the `lobby.invite.decline` message type. // It transitions the invite from created to declined when the actor is the // invitee. Per `lobby/README.md` §Invite Lifecycle, no notification is // published in v1. package declineinvite 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 decline use case. type Service struct { invites ports.InviteStore clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Invites loads the target invite and applies the // created → declined 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 decline. Optional; nil disables metric emission. Telemetry *telemetry.Runtime } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { if deps.Invites == nil { return nil, errors.New("new decline 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{ invites: deps.Invites, clock: clock, logger: logger.With("service", "lobby.declineinvite"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to decline one invite. type Input struct { // Actor identifies the caller. Must be the invitee. 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 invitee, validates the invite state, transitions the // invite to declined, 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("decline invite: nil service") } if ctx == nil { return invite.Invite{}, errors.New("decline invite: nil context") } if err := input.Actor.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("decline invite: actor: %w", err) } if !input.Actor.IsUser() { return invite.Invite{}, fmt.Errorf( "%w: only the invited user may decline an invite", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("decline invite: %w", err) } if err := input.InviteID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("decline invite: %w", err) } inv, err := service.invites.Get(ctx, input.InviteID) if err != nil { return invite.Invite{}, fmt.Errorf("decline invite: %w", err) } if inv.GameID != input.GameID { return invite.Invite{}, fmt.Errorf( "decline invite: invite %q does not belong to game %q: %w", inv.InviteID, input.GameID, invite.ErrNotFound, ) } if inv.InviteeUserID != input.Actor.UserID { return invite.Invite{}, fmt.Errorf( "%w: invite is addressed to a different user", shared.ErrForbidden, ) } if inv.Status != invite.StatusCreated { return invite.Invite{}, fmt.Errorf( "decline invite: status %q is not %q: %w", inv.Status, invite.StatusCreated, invite.ErrConflict, ) } now := service.clock().UTC() if err := service.invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: inv.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusDeclined, At: now, }); err != nil { return invite.Invite{}, fmt.Errorf("decline invite: %w", err) } declined, err := service.invites.Get(ctx, inv.InviteID) if err != nil { return invite.Invite{}, fmt.Errorf("decline invite: %w", err) } service.telemetry.RecordInviteOutcome(ctx, "declined") logArgs := []any{ "game_id", declined.GameID.String(), "invite_id", declined.InviteID.String(), "user_id", declined.InviteeUserID, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "invite declined", logArgs...) return declined, nil }