package notification import ( "context" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/user" "galaxy/backend/push" "github.com/google/uuid" "go.uber.org/zap" ) // PushPublisher is the publisher contract notification uses to emit a // `client_event` push frame to gateway. The real implementation lives // in `backend/push` (`*push.Service`); NewNoopPushPublisher satisfies // the interface for tests that do not exercise push behaviour. // // `event` is a typed `push.Event`: the publisher invokes Marshal on // the event at publish time, so producers stay decoupled from the // wire encoding. Every catalog kind has a FlatBuffers schema in // `pkg/schema/fbs/notification.fbs` and is built by // `buildClientPushEvent`; an unknown kind falls back to // `push.JSONEvent` so a misconfigured producer keeps the pipeline // flowing. // // Implementations must be concurrency-safe. The deviceSessionID pointer // narrows the event to a single device session when non-nil; nil means // fan out to every active session of userID. eventID, requestID and // traceID are correlation identifiers that gateway forwards verbatim // into the signed client envelope; empty strings are forwarded // unchanged. type PushPublisher interface { PublishClientEvent(ctx context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event push.Event, eventID, requestID, traceID string) error } // Mailer is the email surface notification uses for outbound mail. The // canonical implementation is `*mail.Service.EnqueueTemplate`; tests // substitute a recording fake. The contract matches mail's existing // signature so the wiring layer can pass the concrete service directly. type Mailer interface { EnqueueTemplate(ctx context.Context, templateID, recipient string, payload map[string]any, idempotencyKey string) error } // AccountResolver looks up the recipient profile (email + preferred // language) by user_id. The canonical implementation is // `*user.Service.GetAccount`. The narrow interface keeps the Service // from depending on every part of the user surface. type AccountResolver interface { GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error) } // Deps aggregates every collaborator the Service depends on. // // Store, Mail, and Accounts must be non-nil. Push defaults to the no-op // publisher when omitted; Now defaults to time.Now; Logger defaults to // zap.NewNop. Config carries the worker interval, the max-attempts cap, // and the optional admin-email destination from `BACKEND_NOTIFICATION_*`. type Deps struct { Store *Store Mail Mailer Push PushPublisher Accounts AccountResolver Config config.NotificationConfig // Now overrides time.Now for deterministic tests. A nil Now defaults // to time.Now in NewService. Now func() time.Time // Logger is named under "notification" by NewService. Nil falls back // to zap.NewNop. Logger *zap.Logger } // NewNoopPushPublisher returns a PushPublisher that logs every event // at debug level and returns nil. The canonical publisher lives in // `backend/internal/push`; this constructor exists for tests. func NewNoopPushPublisher(logger *zap.Logger) PushPublisher { if logger == nil { logger = zap.NewNop() } return &noopPushPublisher{logger: logger.Named("push.noop")} } type noopPushPublisher struct { logger *zap.Logger } func (p *noopPushPublisher) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event push.Event, eventID, requestID, traceID string) error { kind := "" if event != nil { kind = event.Kind() } fields := []zap.Field{ zap.String("user_id", userID.String()), zap.String("kind", kind), } if deviceSessionID != nil { fields = append(fields, zap.String("device_session_id", deviceSessionID.String())) } if eventID != "" { fields = append(fields, zap.String("event_id", eventID)) } if requestID != "" { fields = append(fields, zap.String("request_id", requestID)) } if traceID != "" { fields = append(fields, zap.String("trace_id", traceID)) } p.logger.Debug("client event (noop publisher)", fields...) return nil }