package diplomail import ( "context" "time" "galaxy/backend/internal/config" "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. type Deps struct { Store *Store Memberships MembershipLookup Notification NotificationPublisher Config config.DiplomailConfig Logger *zap.Logger Now func() 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`) that we persist on each new // message / recipient row. type ActiveMembership struct { UserID uuid.UUID GameID uuid.UUID GameName string UserName string RaceName 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. type MemberSnapshot struct { UserID uuid.UUID GameID uuid.UUID GameName string UserName string RaceName 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 }