189 lines
5.6 KiB
Go
189 lines
5.6 KiB
Go
// 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
|
|
}
|