Files
galaxy-game/lobby/internal/domain/membership/model.go
T
2026-04-25 23:20:55 +02:00

168 lines
5.2 KiB
Go

// 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
}