// Package invite defines the invite record domain model, status machine, // and sentinel errors owned by Game Lobby Service for private-game // enrollment. package invite import ( "fmt" "strings" "time" "galaxy/lobby/internal/domain/common" ) // Invite stores one durable invite record owned by Game Lobby Service. // Invites are used exclusively by private games; public games use the // application flow instead. type Invite struct { // InviteID identifies the record. InviteID common.InviteID // GameID identifies the game this invite belongs to. GameID common.GameID // InviterUserID stores the platform user id of the private-game owner // who created the invite. InviterUserID string // InviteeUserID stores the platform user id of the invited user. InviteeUserID string // RaceName stores the invitee's chosen in-game name. It is empty until // the invite transitions to redeemed. RaceName string // Status stores the current lifecycle state. Status Status // CreatedAt stores when the record was created. CreatedAt time.Time // ExpiresAt stores the business deadline after which the invite is no // longer actionable. It equals enrollment_ends_at of the parent game // at creation time. ExpiresAt time.Time // DecidedAt stores when the record transitioned out of created. It is // nil while the invite is still created. DecidedAt *time.Time } // NewInviteInput groups all fields required to create an invite record. type NewInviteInput struct { // InviteID identifies the new record. InviteID common.InviteID // GameID identifies the game the invitee is being invited to. GameID common.GameID // InviterUserID stores the platform user id of the private-game owner. InviterUserID string // InviteeUserID stores the platform user id of the invited user. InviteeUserID string // Now stores the creation wall-clock used for CreatedAt. Now time.Time // ExpiresAt stores the business deadline propagated from the parent // game's enrollment_ends_at. ExpiresAt time.Time } // New validates input and returns a created Invite record. Validation // errors are returned verbatim so callers can surface them as // invalid_request. func New(input NewInviteInput) (Invite, error) { if err := input.Validate(); err != nil { return Invite{}, err } record := Invite{ InviteID: input.InviteID, GameID: input.GameID, InviterUserID: strings.TrimSpace(input.InviterUserID), InviteeUserID: strings.TrimSpace(input.InviteeUserID), Status: StatusCreated, CreatedAt: input.Now.UTC(), ExpiresAt: input.ExpiresAt.UTC(), } if err := record.Validate(); err != nil { return Invite{}, err } return record, nil } // Validate reports whether input satisfies the frozen invite-record // invariants required to construct a created record. func (input NewInviteInput) Validate() error { if err := input.InviteID.Validate(); err != nil { return fmt.Errorf("invite id: %w", err) } if err := input.GameID.Validate(); err != nil { return fmt.Errorf("game id: %w", err) } if strings.TrimSpace(input.InviterUserID) == "" { return fmt.Errorf("inviter user id must not be empty") } if strings.TrimSpace(input.InviteeUserID) == "" { return fmt.Errorf("invitee user id must not be empty") } if strings.TrimSpace(input.InviterUserID) == strings.TrimSpace(input.InviteeUserID) { return fmt.Errorf("inviter and invitee must not be the same user") } if input.Now.IsZero() { return fmt.Errorf("now must not be zero") } if input.ExpiresAt.IsZero() { return fmt.Errorf("expires at must not be zero") } if !input.ExpiresAt.After(input.Now) { return fmt.Errorf("expires at must be after now") } return nil } // Validate reports whether record satisfies the full invariants. // Every marshal and unmarshal round-trip calls Validate to guarantee that // the Redis store never exposes malformed records. func (record Invite) Validate() error { if err := record.InviteID.Validate(); err != nil { return fmt.Errorf("invite id: %w", err) } if err := record.GameID.Validate(); err != nil { return fmt.Errorf("game id: %w", err) } if strings.TrimSpace(record.InviterUserID) == "" { return fmt.Errorf("inviter user id must not be empty") } if strings.TrimSpace(record.InviteeUserID) == "" { return fmt.Errorf("invitee user id must not be empty") } if !record.Status.IsKnown() { return fmt.Errorf("status %q is unsupported", record.Status) } if record.CreatedAt.IsZero() { return fmt.Errorf("created at must not be zero") } if record.ExpiresAt.IsZero() { return fmt.Errorf("expires at must not be zero") } if record.ExpiresAt.Before(record.CreatedAt) { return fmt.Errorf("expires at must not be before created at") } switch record.Status { case StatusCreated: if record.DecidedAt != nil { return fmt.Errorf("decided at must be nil for created invites") } if record.RaceName != "" { return fmt.Errorf("race name must be empty for created invites") } case StatusRedeemed: if strings.TrimSpace(record.RaceName) == "" { return fmt.Errorf("race name must not be empty for redeemed invites") } if record.DecidedAt == nil { return fmt.Errorf("decided at must not be nil for redeemed invites") } default: if record.RaceName != "" { return fmt.Errorf("race name must be empty for %q invites", record.Status) } if record.DecidedAt == nil { return fmt.Errorf("decided at must not be nil for %q invites", record.Status) } } if record.DecidedAt != nil { if record.DecidedAt.IsZero() { return fmt.Errorf("decided at must not be zero when present") } if record.DecidedAt.Before(record.CreatedAt) { return fmt.Errorf("decided at must not be before created at") } } return nil }