package diplomail import ( "context" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/diplomail/detector" "galaxy/backend/internal/diplomail/translator" "github.com/google/uuid" "go.uber.org/zap" ) // Deps aggregates every collaborator the diplomail Service depends on. // // Store and Memberships are required. Logger and Now default to // zap.NewNop / time.Now when nil. Notification falls back to a no-op // publisher so unit tests can construct a Service with only the // required collaborators populated. Entitlements and Games are // optional — they are used by Stage C surfaces (paid-tier player // broadcast, multi-game admin broadcast, bulk cleanup). Wiring may // pass nil for tests that do not exercise those paths. type Deps struct { Store *Store Memberships MembershipLookup Notification NotificationPublisher Entitlements EntitlementReader Games GameLookup Detector detector.LanguageDetector Translator translator.Translator Config config.DiplomailConfig Logger *zap.Logger Now func() time.Time } // EntitlementReader is the read-only surface diplomail uses to gate // the paid-tier player broadcast. The canonical implementation in // `cmd/backend/main` reads // `*user.Service.GetEntitlementSnapshot(userID).IsPaid`. type EntitlementReader interface { IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) } // GameLookup exposes the slim view of `games` the multi-game admin // broadcast and bulk-cleanup paths consume. The canonical // implementation walks the lobby cache plus an explicit store call // for finished-game pruning. type GameLookup interface { // ListRunningGames returns every game whose `status` is one of // the still-active values (running, paused, starting, …). The // admin `all_running` broadcast scope iterates over the result. ListRunningGames(ctx context.Context) ([]GameSnapshot, error) // ListFinishedGamesBefore returns every game whose `finished_at` // is older than `cutoff`. The bulk-purge admin endpoint reads // this to compose the cascade-delete IN list. ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error) // GetGame returns one game snapshot identified by id, or // ErrNotFound. Used by the multi-game broadcast to verify the // caller-supplied id list before enqueuing fan-out work. GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error) } // GameSnapshot is the trim view of `games` consumed by the multi-game // admin broadcast and the cleanup paths. The struct intentionally // avoids the full `lobby.GameRecord` so the diplomail package stays // decoupled from the lobby domain. type GameSnapshot struct { GameID uuid.UUID GameName string Status string FinishedAt *time.Time } // ActiveMembership is the slim view of a single (user, game) roster // row the diplomail package needs at send time: it confirms the // participant is active in the game and captures the snapshot fields // (`game_name`, `user_name`, `race_name`, `preferred_language`) that // we persist on each new message / recipient row. type ActiveMembership struct { UserID uuid.UUID GameID uuid.UUID GameName string UserName string RaceName string PreferredLanguage string } // MembershipLookup is the read-only surface diplomail uses to verify // "is this user an active member of this game" and to snapshot the // roster metadata. The canonical implementation in `cmd/backend/main` // adapts the `*lobby.Service` membership cache to this interface. // // GetActiveMembership returns ErrNotFound (the diplomail sentinel) // when the user is not an active member of the game; the service // boundary maps that to 403 forbidden. // // GetMembershipAnyStatus returns the same shape regardless of // membership status (`active`, `removed`, `blocked`). Used by the // inbox read path to check whether a kicked recipient still belongs // to the game's roster; ErrNotFound is surfaced when the user has // never been a member. // // ListMembers returns every roster row matching scope, in stable // order. Scope values are `active`, `active_and_removed`, and // `all_members` (the spec calls these out by name). Used by the // broadcast composition step in admin / owner sends. type MembershipLookup interface { GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error) } // Recipient scope values accepted by ListMembers and by the // `recipients` request field on admin / owner broadcasts. const ( RecipientScopeActive = "active" RecipientScopeActiveAndRemoved = "active_and_removed" RecipientScopeAllMembers = "all_members" ) // MemberSnapshot is the slim view of a membership row that survives // all three status values. RaceName is the immutable string captured // at registration time; an empty value is legal for rare cases where // the row was inserted without one. PreferredLanguage is included so // the broadcast and lifecycle paths can decide whether the recipient // needs to wait for a translation before delivery. type MemberSnapshot struct { UserID uuid.UUID GameID uuid.UUID GameName string UserName string RaceName string PreferredLanguage string Status string } // NotificationPublisher is the outbound surface diplomail uses to // emit the `diplomail.message.received` push event. The canonical // implementation in `cmd/backend/main` adapts the notification.Service // the same way it adapts `lobby.NotificationPublisher`; tests pass // the no-op publisher below to avoid wiring the dispatcher. type NotificationPublisher interface { PublishDiplomailEvent(ctx context.Context, ev DiplomailNotification) error } // DiplomailNotification is the open shape carried by a per-recipient // push intent. The struct lives in the diplomail package so the // producer vocabulary stays here; the publisher adapter translates it // into a `notification.Intent` at the wiring boundary. type DiplomailNotification struct { Kind string IdempotencyKey string Recipient uuid.UUID Payload map[string]any } // NewNoopNotificationPublisher returns a publisher that logs every // call at debug level and returns nil. Used by unit tests and as the // fallback inside NewService when callers leave Deps.Notification nil. func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher { if logger == nil { logger = zap.NewNop() } return &noopNotificationPublisher{logger: logger.Named("diplomail.notify.noop")} } type noopNotificationPublisher struct { logger *zap.Logger } func (p *noopNotificationPublisher) PublishDiplomailEvent(_ context.Context, ev DiplomailNotification) error { p.logger.Debug("noop notification", zap.String("kind", ev.Kind), zap.String("idempotency_key", ev.IdempotencyKey), zap.String("recipient", ev.Recipient.String()), ) return nil }