diplomail (Stage A): add in-game personal mail subsystem
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>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// Package diplomail owns the diplomatic-mail subsystem of the Galaxy
|
||||
// backend service. Messages live in the lobby-side domain (their
|
||||
// storage and lifecycle are tied to a game), but they are surfaced
|
||||
// in-game: lobby exposes only an unread-count badge per game while the
|
||||
// in-game mail view reads and writes through this package.
|
||||
//
|
||||
// Stage A implements the personal single-recipient subset:
|
||||
//
|
||||
// - send/read/mark-read/soft-delete handlers for a player addressing
|
||||
// one other active member of the game;
|
||||
// - a push event (`diplomail.message.received`) materialised through
|
||||
// the existing notification pipeline so the recipient gets a live
|
||||
// toast when online;
|
||||
// - an unread-counts endpoint that drives the lobby badge.
|
||||
//
|
||||
// Later stages add admin/owner/system mail, lifecycle hooks, paid-tier
|
||||
// player broadcasts, multi-game broadcasts, bulk purge, and the
|
||||
// language-detection / translation cache.
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Kind values stored verbatim in `diplomail_messages.kind`. The schema
|
||||
// CHECK constraint pins this to the closed set declared below.
|
||||
const (
|
||||
// KindPersonal is a replyable player-to-player message. The
|
||||
// sender is always a `sender_kind='player'`.
|
||||
KindPersonal = "personal"
|
||||
|
||||
// KindAdmin is a non-replyable administrative notification.
|
||||
// The sender is either a human admin (`sender_kind='admin'`)
|
||||
// or the system itself (`sender_kind='system'`).
|
||||
KindAdmin = "admin"
|
||||
)
|
||||
|
||||
// Sender kind values stored verbatim in `diplomail_messages.sender_kind`.
|
||||
const (
|
||||
// SenderKindPlayer marks the sender as an end-user account.
|
||||
// `sender_user_id` and `sender_username` carry the player's id
|
||||
// and immutable `accounts.user_name`.
|
||||
SenderKindPlayer = "player"
|
||||
|
||||
// SenderKindAdmin marks the sender as a site administrator.
|
||||
// `sender_username` carries `admin_accounts.username`.
|
||||
SenderKindAdmin = "admin"
|
||||
|
||||
// SenderKindSystem marks the sender as the service itself
|
||||
// (lifecycle hooks). Both id and username are NULL.
|
||||
SenderKindSystem = "system"
|
||||
)
|
||||
|
||||
// Broadcast scope values stored verbatim in
|
||||
// `diplomail_messages.broadcast_scope`. Stage A only emits `single`;
|
||||
// Stage B / C add `game_broadcast` and `multi_game_broadcast`.
|
||||
const (
|
||||
BroadcastScopeSingle = "single"
|
||||
BroadcastScopeGameBroadcast = "game_broadcast"
|
||||
BroadcastScopeMultiGameBroadcast = "multi_game_broadcast"
|
||||
)
|
||||
|
||||
// LangUndetermined is the BCP 47 placeholder stored in
|
||||
// `diplomail_messages.body_lang` when language detection has not yet
|
||||
// been performed or could not produce a result. Stage A writes this
|
||||
// value unconditionally; Stage D replaces it with the detected tag.
|
||||
const LangUndetermined = "und"
|
||||
|
||||
// Service is the diplomatic-mail entry point. Every public method is
|
||||
// goroutine-safe; concurrency safety is delegated to Postgres for
|
||||
// persisted state.
|
||||
type Service struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
// NewService constructs a Service from deps. Logger and Now are
|
||||
// defaulted; Store must be non-nil and Memberships must be non-nil
|
||||
// because every send path queries the active membership roster.
|
||||
func NewService(deps Deps) *Service {
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = zap.NewNop()
|
||||
}
|
||||
deps.Logger = deps.Logger.Named("diplomail")
|
||||
if deps.Now == nil {
|
||||
deps.Now = time.Now
|
||||
}
|
||||
if deps.Notification == nil {
|
||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Config.MaxBodyBytes <= 0 {
|
||||
deps.Config.MaxBodyBytes = 4096
|
||||
}
|
||||
if deps.Config.MaxSubjectBytes < 0 {
|
||||
deps.Config.MaxSubjectBytes = 256
|
||||
}
|
||||
return &Service{deps: deps}
|
||||
}
|
||||
|
||||
// Config returns the service's runtime configuration. Tests and the
|
||||
// HTTP layer occasionally surface the limits to clients (the OpenAPI
|
||||
// schema documents them too).
|
||||
func (s *Service) Config() config.DiplomailConfig {
|
||||
if s == nil {
|
||||
return config.DiplomailConfig{}
|
||||
}
|
||||
return s.deps.Config
|
||||
}
|
||||
|
||||
// Logger returns the package-named logger. Used by the optional async
|
||||
// worker and by tests asserting on log output.
|
||||
func (s *Service) Logger() *zap.Logger {
|
||||
if s == nil {
|
||||
return zap.NewNop()
|
||||
}
|
||||
return s.deps.Logger
|
||||
}
|
||||
|
||||
// nowUTC returns the configured clock normalised to UTC. Matches the
|
||||
// convention used everywhere else in `backend` so persisted
|
||||
// timestamps compare cleanly regardless of host timezone.
|
||||
func (s *Service) nowUTC() time.Time {
|
||||
return s.deps.Now().UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user