// Package application defines the application record domain model, status // machine, and sentinel errors owned by Game Lobby Service for public-game // enrollment requests. package application import ( "fmt" "strings" "time" "galaxy/lobby/internal/domain/common" ) // Application stores one durable application record owned by Game Lobby // Service. Applications are used exclusively by public games; private // games use the invite flow instead. type Application struct { // ApplicationID identifies the record. ApplicationID common.ApplicationID // GameID identifies the game this application belongs to. GameID common.GameID // ApplicantUserID stores the platform user id of the applicant. ApplicantUserID string // RaceName stores the desired in-game name submitted with the // application. RaceName string // Status stores the current lifecycle state. Status Status // CreatedAt stores when the record was created. CreatedAt time.Time // DecidedAt stores when the record transitioned out of submitted. It // is nil while the application is still submitted. DecidedAt *time.Time } // NewApplicationInput groups all fields required to create a submitted // application record. type NewApplicationInput struct { // ApplicationID identifies the new record. ApplicationID common.ApplicationID // GameID identifies the game the applicant is applying to. GameID common.GameID // ApplicantUserID stores the platform user id of the applicant. ApplicantUserID string // RaceName stores the desired in-game name submitted by the // applicant. RaceName string // Now stores the creation wall-clock used for CreatedAt. Now time.Time } // New validates input and returns a submitted Application record. // Validation errors are returned verbatim so callers can surface them as // invalid_request. func New(input NewApplicationInput) (Application, error) { if err := input.Validate(); err != nil { return Application{}, err } record := Application{ ApplicationID: input.ApplicationID, GameID: input.GameID, ApplicantUserID: strings.TrimSpace(input.ApplicantUserID), RaceName: strings.TrimSpace(input.RaceName), Status: StatusSubmitted, CreatedAt: input.Now.UTC(), } if err := record.Validate(); err != nil { return Application{}, err } return record, nil } // Validate reports whether input satisfies the frozen application-record // invariants required to construct a submitted record. func (input NewApplicationInput) Validate() error { if err := input.ApplicationID.Validate(); err != nil { return fmt.Errorf("application id: %w", err) } if err := input.GameID.Validate(); err != nil { return fmt.Errorf("game id: %w", err) } if strings.TrimSpace(input.ApplicantUserID) == "" { return fmt.Errorf("applicant user id must not be empty") } if strings.TrimSpace(input.RaceName) == "" { return fmt.Errorf("race name must not be empty") } if input.Now.IsZero() { return fmt.Errorf("now must not be zero") } 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 Application) Validate() error { if err := record.ApplicationID.Validate(); err != nil { return fmt.Errorf("application id: %w", err) } if err := record.GameID.Validate(); err != nil { return fmt.Errorf("game id: %w", err) } if strings.TrimSpace(record.ApplicantUserID) == "" { return fmt.Errorf("applicant user id must not be empty") } if strings.TrimSpace(record.RaceName) == "" { return fmt.Errorf("race name 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.Status == StatusSubmitted { if record.DecidedAt != nil { return fmt.Errorf("decided at must be nil for submitted applications") } } else { if record.DecidedAt == nil { return fmt.Errorf("decided at must not be nil for %q applications", record.Status) } 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 }