// Package membership defines the membership record domain model, status // machine, and sentinel errors owned by Game Lobby Service for platform // participant roster state. package membership import ( "fmt" "strings" "time" "galaxy/lobby/internal/domain/common" ) // Membership stores one durable membership record owned by Game Lobby // Service. Memberships are the platform roster entry that binds a user to // a game with a confirmed race name. type Membership struct { // MembershipID identifies the record. MembershipID common.MembershipID // GameID identifies the game this membership belongs to. GameID common.GameID // UserID stores the platform user id of the member. UserID string // RaceName stores the confirmed in-game name in its original casing. // It is reserved in the Race Name Directory under CanonicalKey. RaceName string // CanonicalKey stores the policy-derived canonical form of RaceName. // It is the join key used by the Race Name Directory and downstream // readers (capability evaluation, cascade release) so they never need // to re-derive canonical keys from the original-casing RaceName. CanonicalKey string // Status stores the current membership status. Status Status // JoinedAt stores when the record entered the active status. JoinedAt time.Time // RemovedAt stores when the record transitioned out of active. It is // nil while the membership is still active. RemovedAt *time.Time } // NewMembershipInput groups all fields required to create an active // membership record. type NewMembershipInput struct { // MembershipID identifies the new record. MembershipID common.MembershipID // GameID identifies the game the member is joining. GameID common.GameID // UserID stores the platform user id of the member. UserID string // RaceName stores the confirmed in-game name reserved for the member // in its original casing. RaceName string // CanonicalKey stores the policy-derived canonical form of RaceName. // Callers obtain it from RaceNameDirectory.Canonicalize before // constructing the membership so that the record carries the same key // the directory uses internally. CanonicalKey string // Now stores the creation wall-clock used for JoinedAt. Now time.Time } // New validates input and returns an active Membership record. Validation // errors are returned verbatim so callers can surface them as // invalid_request. func New(input NewMembershipInput) (Membership, error) { if err := input.Validate(); err != nil { return Membership{}, err } record := Membership{ MembershipID: input.MembershipID, GameID: input.GameID, UserID: strings.TrimSpace(input.UserID), RaceName: strings.TrimSpace(input.RaceName), CanonicalKey: strings.TrimSpace(input.CanonicalKey), Status: StatusActive, JoinedAt: input.Now.UTC(), } if err := record.Validate(); err != nil { return Membership{}, err } return record, nil } // Validate reports whether input satisfies the frozen membership-record // invariants required to construct an active record. func (input NewMembershipInput) Validate() error { if err := input.MembershipID.Validate(); err != nil { return fmt.Errorf("membership id: %w", err) } if err := input.GameID.Validate(); err != nil { return fmt.Errorf("game id: %w", err) } if strings.TrimSpace(input.UserID) == "" { return fmt.Errorf("user id must not be empty") } if strings.TrimSpace(input.RaceName) == "" { return fmt.Errorf("race name must not be empty") } if strings.TrimSpace(input.CanonicalKey) == "" { return fmt.Errorf("canonical key 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 Membership) Validate() error { if err := record.MembershipID.Validate(); err != nil { return fmt.Errorf("membership id: %w", err) } if err := record.GameID.Validate(); err != nil { return fmt.Errorf("game id: %w", err) } if strings.TrimSpace(record.UserID) == "" { return fmt.Errorf("user id must not be empty") } if strings.TrimSpace(record.RaceName) == "" { return fmt.Errorf("race name must not be empty") } if strings.TrimSpace(record.CanonicalKey) == "" { return fmt.Errorf("canonical key must not be empty") } if strings.TrimSpace(record.CanonicalKey) != record.CanonicalKey { return fmt.Errorf("canonical key must not contain surrounding whitespace") } if !record.Status.IsKnown() { return fmt.Errorf("status %q is unsupported", record.Status) } if record.JoinedAt.IsZero() { return fmt.Errorf("joined at must not be zero") } if record.Status == StatusActive { if record.RemovedAt != nil { return fmt.Errorf("removed at must be nil for active memberships") } } else { if record.RemovedAt == nil { return fmt.Errorf("removed at must not be nil for %q memberships", record.Status) } if record.RemovedAt.IsZero() { return fmt.Errorf("removed at must not be zero when present") } if record.RemovedAt.Before(record.JoinedAt) { return fmt.Errorf("removed at must not be before joined at") } } return nil }