100 lines
3.6 KiB
Go
100 lines
3.6 KiB
Go
package notification
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/config"
|
|
"galaxy/backend/internal/user"
|
|
|
|
"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/internal/push` ; NewNoopPushPublisher satisfies
|
|
// the interface for tests that do not exercise push behaviour.
|
|
//
|
|
// 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, kind string, payload map[string]any, 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, kind string, payload map[string]any, eventID, requestID, traceID string) error {
|
|
fields := []zap.Field{
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("kind", kind),
|
|
zap.Int("payload_keys", len(payload)),
|
|
}
|
|
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
|
|
}
|