535e27008f
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail channel; the existing `mail` package is a transactional email outbox and the `notification` catalog is one-way platform events. Stage A lands the schema (diplomail_messages / _recipients / _translations), a single-recipient personal send/read/delete service path, a `diplomail.message.received` push kind plumbed through the notification pipeline, and an unread-counts endpoint that drives the lobby badge. Admin / system mail, lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk purge and language detection / translation cache come in stages B–D. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
3.2 KiB
Go
95 lines
3.2 KiB
Go
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.
|
|
//
|
|
// Implementations must return ErrNotFound (the diplomail sentinel)
|
|
// when the user is not an active member of the game; the service
|
|
// boundary maps that to 403 forbidden.
|
|
type MembershipLookup interface {
|
|
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
|
|
}
|
|
|
|
// 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
|
|
}
|