package ports import ( "context" "fmt" "strings" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/invite" ) // InviteStore stores invite records and their secondary indexes. Adapters // are responsible for maintaining the per-game set and per-invitee set // together with the record. type InviteStore interface { // Save persists a new created invite record. Save rejects records // whose status is not created and is create-only: re-saving an // existing invite id returns invite.ErrConflict. Save(ctx context.Context, record invite.Invite) error // Get returns the record identified by inviteID. It returns // invite.ErrNotFound when no record exists. Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) // GetByGame returns every invite attached to gameID. The order is // adapter-defined; callers may reorder as needed. GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) // GetByUser returns every invite addressed to inviteeUserID. The // order is adapter-defined; callers may reorder as needed. GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) // GetByInviter returns every invite created by inviterUserID. The // order is adapter-defined; callers may reorder as needed. The // secondary index is maintained alongside the per-game and per-user // indexes; cascade-release callers read it without touching // the per-game listings. GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) // UpdateStatus applies one status transition in a compare-and-swap // fashion. The adapter must first call invite.Transition to reject // invalid pairs without touching the store; on success it must // verify that the current status equals input.ExpectedFrom, update // the primary record, set DecidedAt to input.At, and apply // input.RaceName when transitioning to redeemed. input.RaceName must // be non-empty when To is redeemed and empty otherwise. UpdateStatus(ctx context.Context, input UpdateInviteStatusInput) error } // UpdateInviteStatusInput stores the arguments required to apply one // status transition through an InviteStore. type UpdateInviteStatusInput struct { // InviteID identifies the record to mutate. InviteID common.InviteID // ExpectedFrom stores the status the caller believes the record // currently has. A mismatch results in invite.ErrConflict. ExpectedFrom invite.Status // To stores the destination status. To invite.Status // At stores the wall-clock used for DecidedAt. At time.Time // RaceName carries the invitee's confirmed in-game name. It is // required when To is redeemed and must be empty otherwise. RaceName string } // Validate reports whether input contains a structurally valid status // transition request. func (input UpdateInviteStatusInput) Validate() error { if err := input.InviteID.Validate(); err != nil { return fmt.Errorf("update invite status: invite id: %w", err) } if !input.ExpectedFrom.IsKnown() { return fmt.Errorf( "update invite status: expected from status %q is unsupported", input.ExpectedFrom, ) } if !input.To.IsKnown() { return fmt.Errorf( "update invite status: to status %q is unsupported", input.To, ) } if input.At.IsZero() { return fmt.Errorf("update invite status: at must not be zero") } if input.To == invite.StatusRedeemed { if strings.TrimSpace(input.RaceName) == "" { return fmt.Errorf( "update invite status: race name must not be empty when redeeming", ) } } else if input.RaceName != "" { return fmt.Errorf( "update invite status: race name must be empty when transitioning to %q", input.To, ) } return nil }