From 535e27008f1c9f4f3a95fc05a6301e8c4cee2c33 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 18:28:55 +0200 Subject: [PATCH] diplomail (Stage A): add in-game personal mail subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/README.md | 3 + backend/cmd/backend/main.go | 81 +++ backend/internal/config/config.go | 41 ++ backend/internal/diplomail/README.md | 98 ++++ backend/internal/diplomail/deps.go | 94 ++++ backend/internal/diplomail/diplomail.go | 127 +++++ .../internal/diplomail/diplomail_e2e_test.go | 404 +++++++++++++++ backend/internal/diplomail/errors.go | 32 ++ backend/internal/diplomail/service.go | 238 +++++++++ backend/internal/diplomail/store.go | 473 ++++++++++++++++++ backend/internal/diplomail/types.go | 80 +++ backend/internal/notification/catalog.go | 5 + backend/internal/notification/catalog_test.go | 1 + backend/internal/notification/events_test.go | 21 +- .../jet/backend/model/diplomail_messages.go | 29 ++ .../jet/backend/model/diplomail_recipients.go | 26 + .../backend/model/diplomail_translations.go | 23 + .../jet/backend/table/diplomail_messages.go | 114 +++++ .../jet/backend/table/diplomail_recipients.go | 105 ++++ .../backend/table/diplomail_translations.go | 96 ++++ .../jet/backend/table/table_use_schema.go | 3 + .../postgres/migrations/00001_init.sql | 97 +++- backend/internal/postgres/migrations_test.go | 4 + backend/internal/server/contract_test.go | 6 + backend/internal/server/handlers_user_mail.go | 424 ++++++++++++++++ backend/internal/server/router.go | 15 + backend/openapi.yaml | 390 +++++++++++++++ docs/ARCHITECTURE.md | 51 +- 28 files changed, 3069 insertions(+), 12 deletions(-) create mode 100644 backend/internal/diplomail/README.md create mode 100644 backend/internal/diplomail/deps.go create mode 100644 backend/internal/diplomail/diplomail.go create mode 100644 backend/internal/diplomail/diplomail_e2e_test.go create mode 100644 backend/internal/diplomail/errors.go create mode 100644 backend/internal/diplomail/service.go create mode 100644 backend/internal/diplomail/store.go create mode 100644 backend/internal/diplomail/types.go create mode 100644 backend/internal/postgres/jet/backend/model/diplomail_messages.go create mode 100644 backend/internal/postgres/jet/backend/model/diplomail_recipients.go create mode 100644 backend/internal/postgres/jet/backend/model/diplomail_translations.go create mode 100644 backend/internal/postgres/jet/backend/table/diplomail_messages.go create mode 100644 backend/internal/postgres/jet/backend/table/diplomail_recipients.go create mode 100644 backend/internal/postgres/jet/backend/table/diplomail_translations.go create mode 100644 backend/internal/server/handlers_user_mail.go diff --git a/backend/README.md b/backend/README.md index 9e1ddc7..c7ce842 100644 --- a/backend/README.md +++ b/backend/README.md @@ -45,6 +45,7 @@ backend/ │ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations │ ├── auth/ # email-code challenges, device sessions, Ed25519 keys │ ├── config/ # env-var loader, Validate +│ ├── diplomail/ # diplomatic-mail messages, recipients, translations │ ├── dockerclient/ # docker/docker wrapper for container ops │ ├── engineclient/ # net/http client to galaxy-game containers │ ├── geo/ # geoip lookup, declared_country, per-user counters @@ -131,6 +132,8 @@ fast. | `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. | | `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. | | `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. | +| `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` | no | `4096` | Maximum size of `diplomail_messages.body` enforced at send time. Tune at runtime without a migration. | +| `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` | no | `256` | Maximum size of `diplomail_messages.subject`. Subject is optional; empty is always accepted. | If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index be1b997..e132e96 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -25,6 +25,7 @@ import ( "galaxy/backend/internal/auth" "galaxy/backend/internal/config" "galaxy/backend/internal/devsandbox" + "galaxy/backend/internal/diplomail" "galaxy/backend/internal/dockerclient" "galaxy/backend/internal/engineclient" "galaxy/backend/internal/geo" @@ -301,6 +302,15 @@ func run(ctx context.Context) (err error) { userNotifyCascade.svc = notifSvc lobbyNotifyPublisher.svc = notifSvc runtimeNotifyPublisher.svc = notifSvc + + diplomailStore := diplomail.NewStore(db) + diplomailSvc := diplomail.NewService(diplomail.Deps{ + Store: diplomailStore, + Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc}, + Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc}, + Config: cfg.Diplomail, + Logger: logger, + }) if email := cfg.Notification.AdminEmail; email == "" { logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped") } else { @@ -328,6 +338,7 @@ func run(ctx context.Context) (err error) { adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger) adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger) userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger) + userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, logger) ready := func() bool { return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() @@ -359,6 +370,7 @@ func run(ctx context.Context) (err error) { AdminNotifications: adminNotificationsHandlers, AdminGeo: adminGeoHandlers, UserGames: userGamesHandlers, + UserMail: userMailHandlers, }) if err != nil { return fmt.Errorf("build backend router: %w", err) @@ -579,3 +591,72 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co } return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload) } + +// diplomailMembershipAdapter implements `diplomail.MembershipLookup` +// by walking the lobby cache for the active (game_id, user_id) row +// and stitching the snapshot fields together with the immutable +// `user_name` read through `*user.Service`. +type diplomailMembershipAdapter struct { + lobby *lobby.Service + users *user.Service +} + +func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) { + if a == nil || a.lobby == nil || a.users == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + cache := a.lobby.Cache() + if cache == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + game, ok := cache.GetGame(gameID) + if !ok { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + var found *lobby.Membership + for _, m := range cache.MembershipsForGame(gameID) { + if m.UserID == userID { + mm := m + found = &mm + break + } + } + if found == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + account, err := a.users.GetAccount(ctx, userID) + if err != nil { + return diplomail.ActiveMembership{}, err + } + return diplomail.ActiveMembership{ + UserID: userID, + GameID: gameID, + GameName: game.GameName, + UserName: account.UserName, + RaceName: found.RaceName, + }, nil +} + +// diplomailNotificationPublisherAdapter implements +// `diplomail.NotificationPublisher` by translating each +// DiplomailNotification into a notification.Intent and routing it +// through `*notification.Service.Submit`. The publisher leaves the +// `diplomail.message.received` catalog entry to handle channel +// fan-out (push only in Stage A). +type diplomailNotificationPublisherAdapter struct { + svc *notification.Service +} + +func (a *diplomailNotificationPublisherAdapter) PublishDiplomailEvent(ctx context.Context, ev diplomail.DiplomailNotification) error { + if a == nil || a.svc == nil { + return nil + } + intent := notification.Intent{ + Kind: ev.Kind, + IdempotencyKey: ev.IdempotencyKey, + Recipients: []uuid.UUID{ev.Recipient}, + Payload: ev.Payload, + } + _, err := a.svc.Submit(ctx, intent) + return err +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 536e206..fb46b62 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -96,6 +96,9 @@ const ( envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL" envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS" + envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES" + envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES" + envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL" envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE" envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION" @@ -163,6 +166,9 @@ const ( defaultNotificationWorkerInterval = 5 * time.Second defaultNotificationMaxAttempts = 8 + defaultDiplomailMaxBodyBytes = 4096 + defaultDiplomailMaxSubjectBytes = 256 + defaultDevSandboxEngineVersion = "0.1.0" defaultDevSandboxPlayerCount = 20 ) @@ -201,6 +207,7 @@ type Config struct { Engine EngineConfig Runtime RuntimeConfig Notification NotificationConfig + Diplomail DiplomailConfig DevSandbox DevSandboxConfig // FreshnessWindow mirrors the gateway freshness window and is used by the @@ -397,6 +404,22 @@ type RuntimeConfig struct { StopGracePeriod time.Duration } +// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits +// are enforced in the service layer, so they can be tuned at runtime +// without a schema migration. Body and subject are stored as plain +// UTF-8 text; HTML is neither parsed nor sanitised on the server. +type DiplomailConfig struct { + // MaxBodyBytes caps the length of `diplomail_messages.body` in + // bytes (not runes). A send whose body exceeds the limit is + // rejected with ErrInvalidInput. + MaxBodyBytes int + + // MaxSubjectBytes caps the length of `diplomail_messages.subject` + // in bytes. Subjects are optional; the empty-string default + // passes the limit trivially. + MaxSubjectBytes int +} + // NotificationConfig configures the notification fan-out module // implemented in `backend/internal/notification`. AdminEmail receives // admin-channel kinds (the `runtime.*` set in `backend/README.md` §10); @@ -494,6 +517,10 @@ func DefaultConfig() Config { WorkerInterval: defaultNotificationWorkerInterval, MaxAttempts: defaultNotificationMaxAttempts, }, + Diplomail: DiplomailConfig{ + MaxBodyBytes: defaultDiplomailMaxBodyBytes, + MaxSubjectBytes: defaultDiplomailMaxSubjectBytes, + }, DevSandbox: DevSandboxConfig{ EngineVersion: defaultDevSandboxEngineVersion, PlayerCount: defaultDevSandboxPlayerCount, @@ -657,6 +684,13 @@ func LoadFromEnv() (Config, error) { return Config{}, err } + if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil { + return Config{}, err + } + if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil { + return Config{}, err + } + cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email)) cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage)) cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion)) @@ -853,6 +887,13 @@ func (c Config) Validate() error { if c.Notification.MaxAttempts <= 0 { return fmt.Errorf("%s must be positive", envNotificationMaxAttempts) } + + if c.Diplomail.MaxBodyBytes <= 0 { + return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes) + } + if c.Diplomail.MaxSubjectBytes < 0 { + return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes) + } if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" { if _, err := netmail.ParseAddress(email); err != nil { return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err) diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md new file mode 100644 index 0000000..f35e360 --- /dev/null +++ b/backend/internal/diplomail/README.md @@ -0,0 +1,98 @@ +# diplomail + +`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 inside the game UI +— the lobby exposes only an unread-count badge per game. + +## Stages + +The package ships in four staged increments. Stage A is the surface +described below; the remaining stages add admin / system mail, +lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk +purge, and the language-detection / translation cache. + +| Stage | Scope | Status | +|-------|-------|--------| +| A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped | +| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick) | planned | +| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge | planned | +| D | Body-language detection (whatlanggo) + translation cache + async worker | planned | + +## Tables + +Three Postgres tables in the `backend` schema: + +- `diplomail_messages` — one row per send (personal, admin, or + system). Captures `game_name` and IP at insert time so audit + rendering survives renames and purges. +- `diplomail_recipients` — one row per (message, recipient). Holds + per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at` + state. Snapshot fields (`recipient_user_name`, + `recipient_race_name`) are captured at insert time and survive + membership revocation. +- `diplomail_translations` — cached per (message, target_lang) + rendering. One translation is reused across every recipient that + asks for that language. + +## Permissions + +| Action | Caller | Pre-conditions | +|--------|--------|----------------| +| Send personal | user | active membership in game; recipient is active member | +| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)` | +| Mark read | the recipient | row exists; idempotent if already marked | +| Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) | + +Stage B introduces the admin / owner send matrix and the strict +soft-access rule for kicked players (post-kick read access restricted +to `kind='admin'` rows). Stage C adds the paid-tier broadcast and the +bulk-purge admin endpoint. + +## Content rules + +- Body is plain UTF-8 text. The server does **not** parse, sanitise, + or escape HTML — the UI renders messages via `textContent`. +- Body length is capped by `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default + 4096). Subject length is capped by + `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). Both limits + live in the service layer so they can be tuned without a schema + migration. +- `body_lang` is stored as the BCP 47 `und` (undetermined) sentinel + until Stage D wires the auto-detector. + +## Push integration + +Every successful send emits a `diplomail.message.received` push +intent through the existing notification pipeline. The catalog entry +limits delivery to the push channel — email is intentionally absent; +the inbox endpoint is the durable fallback for offline users. The +payload includes the recipient's freshly recomputed unread count for +the lobby badge and for the in-game header. + +## Lifecycle hooks (Stage B) + +The lobby module is the producer of system mail. Stage B will add a +`DiplomailPublisher` collaborator on `lobby.Service` and call it on +`paused` / `cancelled` transitions and on `BlockMembership` / +`AdminBanMember`. The publisher constructs a +`kind='admin', sender_kind='system'` message with a templated body; +the recipient receives the durable copy in their inbox even after the +membership is revoked. + +If a future stage adds inactivity-based player removal at the lobby +sweeper, that path **must** call the same publisher so the kicked +player has the explanation in their inbox. + +## Wiring + +`cmd/backend/main.go` constructs `*diplomail.Service` with three +collaborators: + +- `*Store` over the shared Postgres pool; +- `MembershipLookup` adapter that walks the lobby cache for the + active `(game_id, user_id)` row and stitches in the immutable + `accounts.user_name`; +- `NotificationPublisher` adapter that translates each + `DiplomailNotification` into a `notification.Intent` and routes it + through `*notification.Service.Submit`. diff --git a/backend/internal/diplomail/deps.go b/backend/internal/diplomail/deps.go new file mode 100644 index 0000000..0601cb6 --- /dev/null +++ b/backend/internal/diplomail/deps.go @@ -0,0 +1,94 @@ +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 +} diff --git a/backend/internal/diplomail/diplomail.go b/backend/internal/diplomail/diplomail.go new file mode 100644 index 0000000..4d350e1 --- /dev/null +++ b/backend/internal/diplomail/diplomail.go @@ -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() +} diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go new file mode 100644 index 0000000..34c9d89 --- /dev/null +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -0,0 +1,404 @@ +package diplomail_test + +import ( + "context" + "database/sql" + "errors" + "net/url" + "sync" + "testing" + "time" + + "galaxy/backend/internal/config" + "galaxy/backend/internal/diplomail" + backendpg "galaxy/backend/internal/postgres" + pgshared "galaxy/postgres" + + "github.com/google/uuid" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + testImage = "postgres:16-alpine" + testUser = "galaxy" + testPassword = "galaxy" + testDatabase = "galaxy_backend" + testSchema = "backend" + testStartup = 90 * time.Second + testOpTimeout = 10 * time.Second +) + +// startPostgres mirrors the harness used by `lobby_e2e_test.go`. It +// spins up a postgres:16-alpine container, applies the embedded +// migrations, and returns a ready-to-use `*sql.DB`. The container is +// torn down via t.Cleanup. +func startPostgres(t *testing.T) *sql.DB { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + pgContainer, err := tcpostgres.Run(ctx, testImage, + tcpostgres.WithDatabase(testDatabase), + tcpostgres.WithUsername(testUser), + tcpostgres.WithPassword(testPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(testStartup), + ), + ) + if err != nil { + t.Skipf("postgres testcontainer unavailable, skipping: %v", err) + } + t.Cleanup(func() { + if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil { + t.Errorf("terminate postgres container: %v", termErr) + } + }) + + baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("connection string: %v", err) + } + scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema) + if err != nil { + t.Fatalf("scope dsn: %v", err) + } + cfg := pgshared.DefaultConfig() + cfg.PrimaryDSN = scopedDSN + cfg.OperationTimeout = testOpTimeout + db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...) + if err != nil { + t.Fatalf("open primary: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("close db: %v", err) + } + }) + if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil { + t.Fatalf("ping: %v", err) + } + if err := backendpg.ApplyMigrations(ctx, db); err != nil { + t.Fatalf("apply migrations: %v", err) + } + return db +} + +func dsnWithSearchPath(baseDSN, schema string) (string, error) { + parsed, err := url.Parse(baseDSN) + if err != nil { + return "", err + } + values := parsed.Query() + values.Set("search_path", schema) + if values.Get("sslmode") == "" { + values.Set("sslmode", "disable") + } + parsed.RawQuery = values.Encode() + return parsed.String(), nil +} + +// recordingPublisher captures every emitted DiplomailNotification so +// the test can assert push fan-out without booting the real +// notification pipeline. +type recordingPublisher struct { + mu sync.Mutex + captured []diplomail.DiplomailNotification +} + +func (p *recordingPublisher) PublishDiplomailEvent(_ context.Context, ev diplomail.DiplomailNotification) error { + p.mu.Lock() + defer p.mu.Unlock() + p.captured = append(p.captured, ev) + return nil +} + +func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]diplomail.DiplomailNotification, len(p.captured)) + copy(out, p.captured) + return out +} + +// staticMembershipLookup serves an in-memory fixture. The test seeds +// memberships up-front and the lookup is keyed on (gameID, userID). +type staticMembershipLookup struct { + rows map[[2]uuid.UUID]diplomail.ActiveMembership +} + +func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) { + if l == nil || l.rows == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + row, ok := l.rows[[2]uuid.UUID{gameID, userID}] + if !ok { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + return row, nil +} + +// seedAccount inserts a minimal accounts row so memberships and mail +// recipient FKs are satisfiable. +func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO backend.accounts ( + user_id, email, user_name, preferred_language, time_zone + ) VALUES ($1, $2, $3, 'en', 'UTC') + `, userID, userID.String()+"@test.local", "user-"+userID.String()[:8]) + if err != nil { + t.Fatalf("seed account %s: %v", userID, err) + } +} + +// seedGame inserts a minimal games row so the diplomail_messages.game_id +// FK is satisfiable. +func seedGame(t *testing.T, db *sql.DB, gameID uuid.UUID, name string) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO backend.games ( + game_id, visibility, status, game_name, + min_players, max_players, start_gap_hours, start_gap_players, + enrollment_ends_at, turn_schedule, target_engine_version, + runtime_snapshot + ) VALUES ( + $1, 'private', 'enrollment_open', $2, + 1, 4, 1, 1, + now() + interval '1 day', '0 0 * * *', '1.0.0', + '{}'::jsonb + ) + `, gameID, name) + if err != nil { + t.Fatalf("seed game %s: %v", gameID, err) + } +} + +func TestDiplomailPersonalFlow(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + other := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedAccount(t, db, other) + seedGame(t, db, gameID, "Stage A Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Stage A Test Game", + UserName: "sender", RaceName: "Senders", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Stage A Test Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + } + publisher := &recordingPublisher{} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // 1. SendPersonal happy path. + msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Trade proposal", + Body: "Care to talk gas mining?", + SenderIP: "203.0.113.4", + }) + if err != nil { + t.Fatalf("send personal: %v", err) + } + if msg.Kind != diplomail.KindPersonal { + t.Fatalf("kind = %q, want personal", msg.Kind) + } + if rcpt.UserID != recipient { + t.Fatalf("recipient.UserID = %s, want %s", rcpt.UserID, recipient) + } + if rcpt.ReadAt != nil { + t.Fatalf("freshly sent message should be unread, read_at=%v", rcpt.ReadAt) + } + if got := publisher.snapshot(); len(got) != 1 { + t.Fatalf("publisher captured %d events, want 1", len(got)) + } else if got[0].Recipient != recipient { + t.Fatalf("push recipient = %s, want %s", got[0].Recipient, recipient) + } + + // 2. ListInbox shows the message for the recipient. + inbox, err := svc.ListInbox(ctx, gameID, recipient) + if err != nil { + t.Fatalf("list inbox: %v", err) + } + if len(inbox) != 1 || inbox[0].MessageID != msg.MessageID { + t.Fatalf("inbox = %+v, want one matching entry", inbox) + } + + // 3. ListSent surfaces the message for the sender. + sent, err := svc.ListSent(ctx, gameID, sender) + if err != nil { + t.Fatalf("list sent: %v", err) + } + if len(sent) != 1 || sent[0].MessageID != msg.MessageID { + t.Fatalf("sent = %+v, want one matching entry", sent) + } + + // 4. Non-recipient reads are 404. + if _, err := svc.GetMessage(ctx, other, msg.MessageID); !errors.Is(err, diplomail.ErrNotFound) { + t.Fatalf("non-recipient get: %v, want ErrNotFound", err) + } + + // 5. Delete before read is a conflict. + if _, err := svc.DeleteMessage(ctx, recipient, msg.MessageID); !errors.Is(err, diplomail.ErrConflict) { + t.Fatalf("delete before read: %v, want ErrConflict", err) + } + + // 6. MarkRead sets read_at; second call is a no-op. + read, err := svc.MarkRead(ctx, recipient, msg.MessageID) + if err != nil { + t.Fatalf("mark read: %v", err) + } + if read.ReadAt == nil { + t.Fatalf("mark read returned no read_at") + } + again, err := svc.MarkRead(ctx, recipient, msg.MessageID) + if err != nil { + t.Fatalf("mark read idempotent: %v", err) + } + if !again.ReadAt.Equal(*read.ReadAt) { + t.Fatalf("mark read idempotent shifted read_at: %v -> %v", read.ReadAt, again.ReadAt) + } + + // 7. Unread counts go to zero after the read. + counts, err := svc.UnreadCountsForUser(ctx, recipient) + if err != nil { + t.Fatalf("unread counts: %v", err) + } + if len(counts) != 0 { + t.Fatalf("unread counts = %+v, want empty after read", counts) + } + + // 8. Soft delete now succeeds. + deleted, err := svc.DeleteMessage(ctx, recipient, msg.MessageID) + if err != nil { + t.Fatalf("delete after read: %v", err) + } + if deleted.DeletedAt == nil { + t.Fatalf("delete after read returned no deleted_at") + } + + // 9. Inbox now excludes the soft-deleted message. + inbox, err = svc.ListInbox(ctx, gameID, recipient) + if err != nil { + t.Fatalf("list inbox after delete: %v", err) + } + if len(inbox) != 0 { + t.Fatalf("inbox after delete = %+v, want empty", inbox) + } +} + +func TestDiplomailRejectsNonActiveSender(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Solo Test Game") + + // Only the recipient is on the active roster. + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Solo Test Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + } + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Hi", + Body: "Trade?", + }) + if !errors.Is(err, diplomail.ErrForbidden) { + t.Fatalf("send from non-member: %v, want ErrForbidden", err) + } +} + +func TestDiplomailRejectsOverlongBody(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Length Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Length Test Game", + UserName: "sender", RaceName: "Senders", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Length Test Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + } + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Config: config.DiplomailConfig{ + MaxBodyBytes: 32, + MaxSubjectBytes: 256, + }, + }) + + bigBody := make([]byte, 64) + for i := range bigBody { + bigBody[i] = 'a' + } + _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Body: string(bigBody), + }) + if !errors.Is(err, diplomail.ErrInvalidInput) { + t.Fatalf("send overlong: %v, want ErrInvalidInput", err) + } +} diff --git a/backend/internal/diplomail/errors.go b/backend/internal/diplomail/errors.go new file mode 100644 index 0000000..1ec1604 --- /dev/null +++ b/backend/internal/diplomail/errors.go @@ -0,0 +1,32 @@ +package diplomail + +import "errors" + +// Sentinel errors surface common rejection reasons across the +// diplomail package. Handlers map them to HTTP envelopes through +// `respondDiplomailError` in `internal/server/handlers_user_mail.go`. +// +// Adding a new sentinel here is a deliberate API change: it appears in +// the handler error map and may surface as a new wire `code` value. +// Reuse the existing set when the behaviour overlaps. +var ( + // ErrInvalidInput reports request-level validation failures + // (empty body, body or subject over the configured byte limit, + // invalid UUID, non-UTF-8 bytes). Maps to 400 invalid_request. + ErrInvalidInput = errors.New("diplomail: invalid input") + + // ErrNotFound reports that the requested message does not exist + // or is not visible to the caller. Maps to 404 not_found. + ErrNotFound = errors.New("diplomail: not found") + + // ErrForbidden reports that the caller is authenticated but not + // authorised for the requested action (not an active member of + // the game; not a recipient of the message). Maps to 403 + // forbidden. + ErrForbidden = errors.New("diplomail: forbidden") + + // ErrConflict reports that the requested action conflicts with + // the current persisted state (e.g. soft-deleting a message + // that has not been marked read yet). Maps to 409 conflict. + ErrConflict = errors.New("diplomail: conflict") +) diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go new file mode 100644 index 0000000..fe0e24e --- /dev/null +++ b/backend/internal/diplomail/service.go @@ -0,0 +1,238 @@ +package diplomail + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// previewMaxRunes bounds the body excerpt embedded in the push event +// so the gRPC payload stays small. The value matches the UI's +// "two lines" tease and is intentionally not configurable — clients +// drive their own truncation off the canonical fetch. +const previewMaxRunes = 120 + +// SendPersonal persists a single-recipient personal message and +// fan-outs a `diplomail.message.received` push event to the +// recipient. Validation rules: +// +// - both sender and recipient must be active members of GameID; +// - the recipient must differ from the sender; +// - the body must be non-empty, valid UTF-8, and within the +// configured byte limit; +// - the subject must be valid UTF-8 and within the configured +// byte limit (zero is allowed). +// +// On any rule violation the function returns ErrInvalidInput or +// ErrForbidden; the inserted Message is never persisted in those +// cases. +func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) { + if in.SenderUserID == in.RecipientUserID { + return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput) + } + subject := strings.TrimRight(in.Subject, " \t") + body := strings.TrimRight(in.Body, " \t\n") + if err := s.validateContent(subject, body); err != nil { + return Message{}, Recipient{}, err + } + + sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden) + } + return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err) + } + recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden) + } + return Message{}, Recipient{}, fmt.Errorf("diplomail: load recipient membership: %w", err) + } + + username := sender.UserName + msgInsert := MessageInsert{ + MessageID: uuid.New(), + GameID: in.GameID, + GameName: sender.GameName, + Kind: KindPersonal, + SenderKind: SenderKindPlayer, + SenderUserID: &in.SenderUserID, + SenderUsername: &username, + SenderIP: in.SenderIP, + Subject: subject, + Body: body, + BodyLang: LangUndetermined, + BroadcastScope: BroadcastScopeSingle, + } + raceName := recipient.RaceName + var raceNamePtr *string + if raceName != "" { + raceNamePtr = &raceName + } + rcptInsert := RecipientInsert{ + RecipientID: uuid.New(), + MessageID: msgInsert.MessageID, + GameID: in.GameID, + UserID: in.RecipientUserID, + RecipientUserName: recipient.UserName, + RecipientRaceName: raceNamePtr, + } + + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert}) + if err != nil { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: %w", err) + } + if len(recipients) != 1 { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients)) + } + + s.publishMessageReceived(ctx, msg, recipients[0]) + return msg, recipients[0], nil +} + +// GetMessage returns the InboxEntry for messageID addressed to +// userID. ErrNotFound is returned when the caller is not a recipient +// of the message — handlers translate that to 404 so the existence +// of the message is not leaked. +func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) { + entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID) + if err != nil { + return InboxEntry{}, err + } + return entry, nil +} + +// ListInbox returns every non-deleted message addressed to userID in +// gameID, newest first. Read state is preserved per entry; the HTTP +// layer renders both the message and the recipient row. +func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) { + return s.deps.Store.ListInbox(ctx, gameID, userID) +} + +// ListSent returns personal messages authored by senderUserID in +// gameID, newest first. Admin/system rows have no `sender_user_id` +// and are therefore excluded; the user surface does not need them. +func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { + return s.deps.Store.ListSent(ctx, gameID, senderUserID) +} + +// MarkRead transitions a recipient row to `read`. Idempotent: a +// second call on an already-read row is a no-op. Returns the +// resulting Recipient. ErrNotFound is surfaced when the caller is +// not a recipient of the message. +func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) { + return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC()) +} + +// DeleteMessage soft-deletes the recipient row identified by +// (messageID, userID). The row must already have `read_at` set, or +// the call returns ErrConflict (item 10 of the spec: open-then-delete). +// Returns ErrNotFound when the caller is not a recipient. +func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) { + return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC()) +} + +// UnreadCountsForUser returns the lobby badge breakdown. +func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) { + return s.deps.Store.UnreadCountsForUser(ctx, userID) +} + +// validateContent enforces the body/subject byte limits and rejects +// non-UTF-8 input. Stage A applies the rules to plain text only; HTML +// is treated as plain text by the server (the UI renders via +// textContent) and gets no special handling. +func (s *Service) validateContent(subject, body string) error { + if body == "" { + return fmt.Errorf("%w: body must not be empty", ErrInvalidInput) + } + if !utf8.ValidString(body) { + return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput) + } + if len(body) > s.deps.Config.MaxBodyBytes { + return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes) + } + if subject != "" { + if !utf8.ValidString(subject) { + return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput) + } + if len(subject) > s.deps.Config.MaxSubjectBytes { + return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes) + } + } + return nil +} + +// publishMessageReceived emits the per-recipient push notification. +// Failures are logged at debug level: notifications are best-effort +// over the gRPC stream, and clients always have the unread-counts +// endpoint as the durable fallback. +func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) { + unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID) + if err != nil { + s.deps.Logger.Warn("compute unread count for push payload failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("recipient", recipient.UserID.String()), + zap.Error(err)) + unreadGame = 0 + } + unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID) + if err != nil { + s.deps.Logger.Warn("compute unread totals for push payload failed", + zap.String("recipient", recipient.UserID.String()), + zap.Error(err)) + unreadTotals = nil + } + unreadTotal := 0 + for _, u := range unreadTotals { + unreadTotal += u.Unread + } + + payload := map[string]any{ + "message_id": msg.MessageID.String(), + "game_id": msg.GameID.String(), + "kind": msg.Kind, + "sender_kind": msg.SenderKind, + "subject": msg.Subject, + "preview": preview(msg.Body, previewMaxRunes), + "preview_lang": msg.BodyLang, + "unread_total": unreadTotal, + "unread_game": unreadGame, + } + ev := DiplomailNotification{ + Kind: "diplomail.message.received", + IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(), + Recipient: recipient.UserID, + Payload: payload, + } + if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil { + s.deps.Logger.Warn("publish diplomail event failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("recipient", recipient.UserID.String()), + zap.Error(err)) + } +} + +// preview truncates s to at most max runes and appends a horizontal +// ellipsis when truncation actually happened. The function operates +// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic, +// emoji) survive without corruption. +func preview(s string, max int) string { + if max <= 0 || utf8.RuneCountInString(s) <= max { + return s + } + count := 0 + for i := range s { + if count == max { + return s[:i] + "…" + } + count++ + } + return s +} diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go new file mode 100644 index 0000000..2d15a05 --- /dev/null +++ b/backend/internal/diplomail/store.go @@ -0,0 +1,473 @@ +package diplomail + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "galaxy/backend/internal/postgres/jet/backend/model" + "galaxy/backend/internal/postgres/jet/backend/table" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" +) + +// Store is the Postgres-backed query surface for the diplomail +// package. All queries are built through go-jet against the generated +// table bindings under `backend/internal/postgres/jet/backend/table`. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// messageColumns is the canonical projection for diplomail_messages +// reads. +func messageColumns() postgres.ColumnList { + m := table.DiplomailMessages + return postgres.ColumnList{ + m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, + m.SenderUserID, m.SenderUsername, m.SenderIP, + m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt, + } +} + +// recipientColumns is the canonical projection for +// diplomail_recipients reads. +func recipientColumns() postgres.ColumnList { + r := table.DiplomailRecipients + return postgres.ColumnList{ + r.RecipientID, r.MessageID, r.GameID, r.UserID, + r.RecipientUserName, r.RecipientRaceName, + r.DeliveredAt, r.ReadAt, r.DeletedAt, r.NotifiedAt, + } +} + +// MessageInsert carries the immutable per-message fields. The store +// fills MessageID, sets CreatedAt to `now()` via the column default, +// and leaves recipient-side state to InsertRecipient. +type MessageInsert struct { + MessageID uuid.UUID + GameID uuid.UUID + GameName string + Kind string + SenderKind string + SenderUserID *uuid.UUID + SenderUsername *string + SenderIP string + Subject string + Body string + BodyLang string + BroadcastScope string +} + +// RecipientInsert carries the per-recipient snapshot. +type RecipientInsert struct { + RecipientID uuid.UUID + MessageID uuid.UUID + GameID uuid.UUID + UserID uuid.UUID + RecipientUserName string + RecipientRaceName *string +} + +// InsertMessageWithRecipients persists a Message together with one or +// more Recipient rows inside a single transaction. The function is +// the canonical write path for every send variant: Stage A passes a +// single-element slice; later stages reuse the same path for +// broadcasts. +func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInsert, recipients []RecipientInsert) (Message, []Recipient, error) { + if len(recipients) == 0 { + return Message{}, nil, errors.New("diplomail store: at least one recipient required") + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + m := table.DiplomailMessages + msgStmt := m.INSERT( + m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, + m.SenderUserID, m.SenderUsername, m.SenderIP, + m.Subject, m.Body, m.BodyLang, m.BroadcastScope, + ).VALUES( + msg.MessageID, + msg.GameID, + msg.GameName, + msg.Kind, + msg.SenderKind, + uuidPtrArg(msg.SenderUserID), + stringPtrArg(msg.SenderUsername), + msg.SenderIP, + msg.Subject, + msg.Body, + msg.BodyLang, + msg.BroadcastScope, + ).RETURNING(messageColumns()) + + var msgRow model.DiplomailMessages + if err := msgStmt.QueryContext(ctx, tx, &msgRow); err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: insert message: %w", err) + } + + r := table.DiplomailRecipients + rcptStmt := r.INSERT( + r.RecipientID, r.MessageID, r.GameID, r.UserID, + r.RecipientUserName, r.RecipientRaceName, + ) + for _, in := range recipients { + rcptStmt = rcptStmt.VALUES( + in.RecipientID, + in.MessageID, + in.GameID, + in.UserID, + in.RecipientUserName, + stringPtrArg(in.RecipientRaceName), + ) + } + rcptStmt = rcptStmt.RETURNING(recipientColumns()) + + var rcptRows []model.DiplomailRecipients + if err := rcptStmt.QueryContext(ctx, tx, &rcptRows); err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: insert recipients: %w", err) + } + + if err := tx.Commit(); err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: commit: %w", err) + } + + return messageFromModel(msgRow), recipientsFromModel(rcptRows), nil +} + +// LoadMessage returns the Message row identified by messageID. The +// function is used by readers that already verified recipient +// authorisation; callers that need both the message and the +// recipient's per-user state should use LoadInboxEntry. +func (s *Store) LoadMessage(ctx context.Context, messageID uuid.UUID) (Message, error) { + m := table.DiplomailMessages + stmt := postgres.SELECT(messageColumns()). + FROM(m). + WHERE(m.MessageID.EQ(postgres.UUID(messageID))). + LIMIT(1) + var row model.DiplomailMessages + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Message{}, ErrNotFound + } + return Message{}, fmt.Errorf("diplomail store: load message %s: %w", messageID, err) + } + return messageFromModel(row), nil +} + +// LoadInboxEntry returns a Message together with the caller's +// Recipient row, both for messageID. Returns ErrNotFound when the +// caller is not a recipient of the message — this is also how the +// service layer enforces "only recipients may read". +func (s *Store) LoadInboxEntry(ctx context.Context, messageID, userID uuid.UUID) (InboxEntry, error) { + m := table.DiplomailMessages + r := table.DiplomailRecipients + cols := append(messageColumns(), recipientColumns()...) + stmt := postgres.SELECT(cols). + FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))), + ). + LIMIT(1) + var dest struct { + model.DiplomailMessages + Recipient model.DiplomailRecipients `alias:"diplomail_recipients"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return InboxEntry{}, ErrNotFound + } + return InboxEntry{}, fmt.Errorf("diplomail store: load inbox entry %s/%s: %w", messageID, userID, err) + } + return InboxEntry{ + Message: messageFromModel(dest.DiplomailMessages), + Recipient: recipientFromModel(dest.Recipient), + }, nil +} + +// ListInbox returns the recipient view of messages addressed to +// userID in gameID, newest first. Soft-deleted rows +// (`deleted_at IS NOT NULL`) are excluded. +func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) { + m := table.DiplomailMessages + r := table.DiplomailRecipients + cols := append(messageColumns(), recipientColumns()...) + stmt := postgres.SELECT(cols). + FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))). + WHERE( + r.UserID.EQ(postgres.UUID(userID)). + AND(r.GameID.EQ(postgres.UUID(gameID))). + AND(r.DeletedAt.IS_NULL()), + ). + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) + var dest []struct { + model.DiplomailMessages + Recipient model.DiplomailRecipients `alias:"diplomail_recipients"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return nil, fmt.Errorf("diplomail store: list inbox %s/%s: %w", gameID, userID, err) + } + out := make([]InboxEntry, 0, len(dest)) + for _, row := range dest { + out = append(out, InboxEntry{ + Message: messageFromModel(row.DiplomailMessages), + Recipient: recipientFromModel(row.Recipient), + }) + } + return out, nil +} + +// ListSent returns messages authored by senderUserID in gameID, +// newest first. Personal messages only — admin/system rows have +// `sender_user_id IS NULL` and are filtered out by the WHERE clause. +func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { + m := table.DiplomailMessages + stmt := postgres.SELECT(messageColumns()). + FROM(m). + WHERE( + m.GameID.EQ(postgres.UUID(gameID)). + AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))), + ). + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) + var rows []model.DiplomailMessages + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err) + } + out := make([]Message, 0, len(rows)) + for _, row := range rows { + out = append(out, messageFromModel(row)) + } + return out, nil +} + +// MarkRead sets `read_at = at` on the recipient row identified by +// (messageID, userID). Idempotent: a row that is already marked read +// is left untouched but the existing Recipient is returned. +// Returns ErrNotFound when the user is not a recipient of the message. +func (s *Store) MarkRead(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) { + r := table.DiplomailRecipients + stmt := r.UPDATE(r.ReadAt). + SET(postgres.TimestampzT(at.UTC())). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))). + AND(r.ReadAt.IS_NULL()), + ). + RETURNING(recipientColumns()) + var row model.DiplomailRecipients + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if !errors.Is(err, qrm.ErrNoRows) { + return Recipient{}, fmt.Errorf("diplomail store: mark read %s/%s: %w", messageID, userID, err) + } + // The row exists but read_at was already set, or the row + // does not exist at all. Fetch to disambiguate. + existing, loadErr := s.LoadRecipient(ctx, messageID, userID) + if loadErr != nil { + return Recipient{}, loadErr + } + return existing, nil + } + return recipientFromModel(row), nil +} + +// SoftDelete sets `deleted_at = at` on the recipient row identified by +// (messageID, userID). The row must already have `read_at` set; +// otherwise the call returns ErrConflict so a hostile client cannot +// erase a message before opening it (item 10 of the spec). +// Returns ErrNotFound when the user is not a recipient. +func (s *Store) SoftDelete(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) { + r := table.DiplomailRecipients + stmt := r.UPDATE(r.DeletedAt). + SET(postgres.TimestampzT(at.UTC())). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))). + AND(r.ReadAt.IS_NOT_NULL()). + AND(r.DeletedAt.IS_NULL()), + ). + RETURNING(recipientColumns()) + var row model.DiplomailRecipients + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if !errors.Is(err, qrm.ErrNoRows) { + return Recipient{}, fmt.Errorf("diplomail store: soft delete %s/%s: %w", messageID, userID, err) + } + existing, loadErr := s.LoadRecipient(ctx, messageID, userID) + if loadErr != nil { + return Recipient{}, loadErr + } + if existing.ReadAt == nil { + return Recipient{}, fmt.Errorf("%w: message must be read before delete", ErrConflict) + } + // Already deleted: return the existing row idempotently. + return existing, nil + } + return recipientFromModel(row), nil +} + +// LoadRecipient fetches the Recipient row keyed on (messageID, userID). +// Returns ErrNotFound when no such recipient exists. +func (s *Store) LoadRecipient(ctx context.Context, messageID, userID uuid.UUID) (Recipient, error) { + r := table.DiplomailRecipients + stmt := postgres.SELECT(recipientColumns()). + FROM(r). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))), + ). + LIMIT(1) + var row model.DiplomailRecipients + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Recipient{}, ErrNotFound + } + return Recipient{}, fmt.Errorf("diplomail store: load recipient %s/%s: %w", messageID, userID, err) + } + return recipientFromModel(row), nil +} + +// UnreadCountForUserGame returns the count of unread, non-deleted +// messages addressed to userID in gameID. Backs the push payload +// `unread_game` field. +func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid.UUID) (int, error) { + r := table.DiplomailRecipients + stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")). + FROM(r). + WHERE( + r.UserID.EQ(postgres.UUID(userID)). + AND(r.GameID.EQ(postgres.UUID(gameID))). + AND(r.ReadAt.IS_NULL()). + AND(r.DeletedAt.IS_NULL()), + ) + var dest struct { + Count int64 `alias:"count"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("diplomail store: unread count %s/%s: %w", gameID, userID, err) + } + return int(dest.Count), nil +} + +// UnreadCountsForUser returns a per-game breakdown of unread messages +// addressed to userID, plus the matching game names so the lobby +// badge UI can render entries even after the recipient's membership +// has been revoked. The slice is ordered by game name. +func (s *Store) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) { + r := table.DiplomailRecipients + m := table.DiplomailMessages + stmt := postgres.SELECT( + r.GameID.AS("game_id"), + postgres.MAX(m.GameName).AS("game_name"), + postgres.COUNT(postgres.STAR).AS("count"), + ). + FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))). + WHERE( + r.UserID.EQ(postgres.UUID(userID)). + AND(r.ReadAt.IS_NULL()). + AND(r.DeletedAt.IS_NULL()), + ). + GROUP_BY(r.GameID). + ORDER_BY(postgres.MAX(m.GameName).ASC()) + var dest []struct { + GameID uuid.UUID `alias:"game_id"` + GameName string `alias:"game_name"` + Count int64 `alias:"count"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return nil, fmt.Errorf("diplomail store: unread counts %s: %w", userID, err) + } + out := make([]UnreadCount, 0, len(dest)) + for _, row := range dest { + out = append(out, UnreadCount{ + GameID: row.GameID, + GameName: row.GameName, + Unread: int(row.Count), + }) + } + return out, nil +} + +// messageFromModel converts a jet-generated row to the domain type. +func messageFromModel(row model.DiplomailMessages) Message { + out := Message{ + MessageID: row.MessageID, + GameID: row.GameID, + GameName: row.GameName, + Kind: row.Kind, + SenderKind: row.SenderKind, + SenderIP: row.SenderIP, + Subject: row.Subject, + Body: row.Body, + BodyLang: row.BodyLang, + BroadcastScope: row.BroadcastScope, + CreatedAt: row.CreatedAt, + } + if row.SenderUserID != nil { + id := *row.SenderUserID + out.SenderUserID = &id + } + if row.SenderUsername != nil { + name := *row.SenderUsername + out.SenderUsername = &name + } + return out +} + +// recipientFromModel converts a jet-generated row to the domain type. +func recipientFromModel(row model.DiplomailRecipients) Recipient { + out := Recipient{ + RecipientID: row.RecipientID, + MessageID: row.MessageID, + GameID: row.GameID, + UserID: row.UserID, + RecipientUserName: row.RecipientUserName, + DeliveredAt: row.DeliveredAt, + ReadAt: row.ReadAt, + DeletedAt: row.DeletedAt, + NotifiedAt: row.NotifiedAt, + } + if row.RecipientRaceName != nil { + name := *row.RecipientRaceName + out.RecipientRaceName = &name + } + return out +} + +// recipientsFromModel converts a slice in place. Used by +// InsertMessageWithRecipients. +func recipientsFromModel(rows []model.DiplomailRecipients) []Recipient { + out := make([]Recipient, 0, len(rows)) + for _, row := range rows { + out = append(out, recipientFromModel(row)) + } + return out +} + +// uuidPtrArg returns the jet argument expression for a nullable UUID. +// Pre-NULL handling here avoids a custom NULL literal at every call +// site. +func uuidPtrArg(v *uuid.UUID) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.UUID(*v) +} + +// stringPtrArg returns the jet argument expression for a nullable +// text column. +func stringPtrArg(v *string) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.String(*v) +} diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go new file mode 100644 index 0000000..849f54b --- /dev/null +++ b/backend/internal/diplomail/types.go @@ -0,0 +1,80 @@ +package diplomail + +import ( + "time" + + "github.com/google/uuid" +) + +// Message mirrors a row in `backend.diplomail_messages` enriched with +// the per-message metadata captured at insert time. +// +// SenderUserID and SenderUsername are nullable in the DB so that the +// CHECK constraint can cover the three legal sender shapes: +// +// - player: SenderUserID set, SenderUsername set +// - admin: SenderUserID nil, SenderUsername set +// - system: SenderUserID nil, SenderUsername nil +type Message struct { + MessageID uuid.UUID + GameID uuid.UUID + GameName string + Kind string + SenderKind string + SenderUserID *uuid.UUID + SenderUsername *string + SenderIP string + Subject string + Body string + BodyLang string + BroadcastScope string + CreatedAt time.Time +} + +// Recipient mirrors a row in `backend.diplomail_recipients`. The +// per-recipient state (read/deleted/delivered/notified) lives here. +// RecipientUserName and RecipientRaceName are snapshots taken at +// insert time so the inbox listing and admin search render correctly +// even after the source rows are renamed or revoked. +type Recipient struct { + RecipientID uuid.UUID + MessageID uuid.UUID + GameID uuid.UUID + UserID uuid.UUID + RecipientUserName string + RecipientRaceName *string + DeliveredAt *time.Time + ReadAt *time.Time + DeletedAt *time.Time + NotifiedAt *time.Time +} + +// InboxEntry is the read-side projection composed of a Message and the +// caller's own Recipient row. The HTTP layer renders one of these per +// item in the inbox listing. +type InboxEntry struct { + Message + Recipient Recipient +} + +// SendPersonalInput is the request payload for SendPersonal: the +// caller sending a single-recipient personal message. Validation +// (active membership, body length, etc.) is performed inside the +// service. +type SendPersonalInput struct { + GameID uuid.UUID + SenderUserID uuid.UUID + RecipientUserID uuid.UUID + Subject string + Body string + SenderIP string +} + +// UnreadCount carries a per-game unread-count row returned by +// UnreadCountsForUser. The lobby badge UI consumes the slice plus the +// derived total. +type UnreadCount struct { + GameID uuid.UUID + GameName string + Unread int +} diff --git a/backend/internal/notification/catalog.go b/backend/internal/notification/catalog.go index 84666cf..9e4e9c3 100644 --- a/backend/internal/notification/catalog.go +++ b/backend/internal/notification/catalog.go @@ -19,6 +19,7 @@ const ( KindRuntimeStartConfigInvalid = "runtime.start_config_invalid" KindGameTurnReady = "game.turn.ready" KindGamePaused = "game.paused" + KindDiplomailReceived = "diplomail.message.received" ) // CatalogEntry describes the per-kind delivery policy: which channels @@ -103,6 +104,9 @@ var catalog = map[string]CatalogEntry{ KindGamePaused: { Channels: []string{ChannelPush}, }, + KindDiplomailReceived: { + Channels: []string{ChannelPush}, + }, } // LookupCatalog returns the per-kind policy and a boolean reporting @@ -133,5 +137,6 @@ func SupportedKinds() []string { KindRuntimeStartConfigInvalid, KindGameTurnReady, KindGamePaused, + KindDiplomailReceived, } } diff --git a/backend/internal/notification/catalog_test.go b/backend/internal/notification/catalog_test.go index 7dbae5c..3137938 100644 --- a/backend/internal/notification/catalog_test.go +++ b/backend/internal/notification/catalog_test.go @@ -41,6 +41,7 @@ func TestCatalogChannels(t *testing.T) { KindRuntimeStartConfigInvalid: {ChannelEmail}, KindGameTurnReady: {ChannelPush}, KindGamePaused: {ChannelPush}, + KindDiplomailReceived: {ChannelPush}, } for kind, want := range expect { entry, ok := LookupCatalog(kind) diff --git a/backend/internal/notification/events_test.go b/backend/internal/notification/events_test.go index a69f6af..93a1c2d 100644 --- a/backend/internal/notification/events_test.go +++ b/backend/internal/notification/events_test.go @@ -25,9 +25,15 @@ import ( // payload is `{game_id, turn, reason}` consumed by the same in-game // shell layout, so there is no value in dragging a FB schema in for // one consumer. +// +// `diplomail.message.received` (Stage A) carries the message metadata +// plus an unread-count snapshot. Stage A intentionally ships the +// payload as JSON so the diplomail UI can iterate on the contract +// without a FB schema dance; a later stage can promote it. var jsonFriendlyKinds = map[string]bool{ - KindGameTurnReady: true, - KindGamePaused: true, + KindGameTurnReady: true, + KindGamePaused: true, + KindDiplomailReceived: true, } // TestBuildClientPushEventCoversCatalog asserts that every catalog kind @@ -88,6 +94,17 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) { "turn": int32(7), "reason": "generation_failed", }}, + {"diplomail message received", KindDiplomailReceived, map[string]any{ + "message_id": gameID.String(), + "game_id": gameID.String(), + "kind": "personal", + "sender_kind": "player", + "subject": "Trade deal", + "preview": "Care to talk gas mining?", + "preview_lang": "en", + "unread_total": 3, + "unread_game": 1, + }}, } seenKinds := map[string]bool{} diff --git a/backend/internal/postgres/jet/backend/model/diplomail_messages.go b/backend/internal/postgres/jet/backend/model/diplomail_messages.go new file mode 100644 index 0000000..c2939cc --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/diplomail_messages.go @@ -0,0 +1,29 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type DiplomailMessages struct { + MessageID uuid.UUID `sql:"primary_key"` + GameID uuid.UUID + GameName string + Kind string + SenderKind string + SenderUserID *uuid.UUID + SenderUsername *string + SenderIP string + Subject string + Body string + BodyLang string + BroadcastScope string + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/diplomail_recipients.go b/backend/internal/postgres/jet/backend/model/diplomail_recipients.go new file mode 100644 index 0000000..af27d73 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/diplomail_recipients.go @@ -0,0 +1,26 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type DiplomailRecipients struct { + RecipientID uuid.UUID `sql:"primary_key"` + MessageID uuid.UUID + GameID uuid.UUID + UserID uuid.UUID + RecipientUserName string + RecipientRaceName *string + DeliveredAt *time.Time + ReadAt *time.Time + DeletedAt *time.Time + NotifiedAt *time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/diplomail_translations.go b/backend/internal/postgres/jet/backend/model/diplomail_translations.go new file mode 100644 index 0000000..08193c1 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/diplomail_translations.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type DiplomailTranslations struct { + TranslationID uuid.UUID `sql:"primary_key"` + MessageID uuid.UUID + TargetLang string + TranslatedSubject string + TranslatedBody string + Translator string + TranslatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/diplomail_messages.go b/backend/internal/postgres/jet/backend/table/diplomail_messages.go new file mode 100644 index 0000000..902e05c --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/diplomail_messages.go @@ -0,0 +1,114 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DiplomailMessages = newDiplomailMessagesTable("backend", "diplomail_messages", "") + +type diplomailMessagesTable struct { + postgres.Table + + // Columns + MessageID postgres.ColumnString + GameID postgres.ColumnString + GameName postgres.ColumnString + Kind postgres.ColumnString + SenderKind postgres.ColumnString + SenderUserID postgres.ColumnString + SenderUsername postgres.ColumnString + SenderIP postgres.ColumnString + Subject postgres.ColumnString + Body postgres.ColumnString + BodyLang postgres.ColumnString + BroadcastScope postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DiplomailMessagesTable struct { + diplomailMessagesTable + + EXCLUDED diplomailMessagesTable +} + +// AS creates new DiplomailMessagesTable with assigned alias +func (a DiplomailMessagesTable) AS(alias string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DiplomailMessagesTable with assigned schema name +func (a DiplomailMessagesTable) FromSchema(schemaName string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DiplomailMessagesTable with assigned table prefix +func (a DiplomailMessagesTable) WithPrefix(prefix string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DiplomailMessagesTable with assigned table suffix +func (a DiplomailMessagesTable) WithSuffix(suffix string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDiplomailMessagesTable(schemaName, tableName, alias string) *DiplomailMessagesTable { + return &DiplomailMessagesTable{ + diplomailMessagesTable: newDiplomailMessagesTableImpl(schemaName, tableName, alias), + EXCLUDED: newDiplomailMessagesTableImpl("", "excluded", ""), + } +} + +func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomailMessagesTable { + var ( + MessageIDColumn = postgres.StringColumn("message_id") + GameIDColumn = postgres.StringColumn("game_id") + GameNameColumn = postgres.StringColumn("game_name") + KindColumn = postgres.StringColumn("kind") + SenderKindColumn = postgres.StringColumn("sender_kind") + SenderUserIDColumn = postgres.StringColumn("sender_user_id") + SenderUsernameColumn = postgres.StringColumn("sender_username") + SenderIPColumn = postgres.StringColumn("sender_ip") + SubjectColumn = postgres.StringColumn("subject") + BodyColumn = postgres.StringColumn("body") + BodyLangColumn = postgres.StringColumn("body_lang") + BroadcastScopeColumn = postgres.StringColumn("broadcast_scope") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + ) + + return diplomailMessagesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + MessageID: MessageIDColumn, + GameID: GameIDColumn, + GameName: GameNameColumn, + Kind: KindColumn, + SenderKind: SenderKindColumn, + SenderUserID: SenderUserIDColumn, + SenderUsername: SenderUsernameColumn, + SenderIP: SenderIPColumn, + Subject: SubjectColumn, + Body: BodyColumn, + BodyLang: BodyLangColumn, + BroadcastScope: BroadcastScopeColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/diplomail_recipients.go b/backend/internal/postgres/jet/backend/table/diplomail_recipients.go new file mode 100644 index 0000000..43afaac --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/diplomail_recipients.go @@ -0,0 +1,105 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DiplomailRecipients = newDiplomailRecipientsTable("backend", "diplomail_recipients", "") + +type diplomailRecipientsTable struct { + postgres.Table + + // Columns + RecipientID postgres.ColumnString + MessageID postgres.ColumnString + GameID postgres.ColumnString + UserID postgres.ColumnString + RecipientUserName postgres.ColumnString + RecipientRaceName postgres.ColumnString + DeliveredAt postgres.ColumnTimestampz + ReadAt postgres.ColumnTimestampz + DeletedAt postgres.ColumnTimestampz + NotifiedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DiplomailRecipientsTable struct { + diplomailRecipientsTable + + EXCLUDED diplomailRecipientsTable +} + +// AS creates new DiplomailRecipientsTable with assigned alias +func (a DiplomailRecipientsTable) AS(alias string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DiplomailRecipientsTable with assigned schema name +func (a DiplomailRecipientsTable) FromSchema(schemaName string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DiplomailRecipientsTable with assigned table prefix +func (a DiplomailRecipientsTable) WithPrefix(prefix string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DiplomailRecipientsTable with assigned table suffix +func (a DiplomailRecipientsTable) WithSuffix(suffix string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDiplomailRecipientsTable(schemaName, tableName, alias string) *DiplomailRecipientsTable { + return &DiplomailRecipientsTable{ + diplomailRecipientsTable: newDiplomailRecipientsTableImpl(schemaName, tableName, alias), + EXCLUDED: newDiplomailRecipientsTableImpl("", "excluded", ""), + } +} + +func newDiplomailRecipientsTableImpl(schemaName, tableName, alias string) diplomailRecipientsTable { + var ( + RecipientIDColumn = postgres.StringColumn("recipient_id") + MessageIDColumn = postgres.StringColumn("message_id") + GameIDColumn = postgres.StringColumn("game_id") + UserIDColumn = postgres.StringColumn("user_id") + RecipientUserNameColumn = postgres.StringColumn("recipient_user_name") + RecipientRaceNameColumn = postgres.StringColumn("recipient_race_name") + DeliveredAtColumn = postgres.TimestampzColumn("delivered_at") + ReadAtColumn = postgres.TimestampzColumn("read_at") + DeletedAtColumn = postgres.TimestampzColumn("deleted_at") + NotifiedAtColumn = postgres.TimestampzColumn("notified_at") + allColumns = postgres.ColumnList{RecipientIDColumn, MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn} + mutableColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn} + defaultColumns = postgres.ColumnList{} + ) + + return diplomailRecipientsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + RecipientID: RecipientIDColumn, + MessageID: MessageIDColumn, + GameID: GameIDColumn, + UserID: UserIDColumn, + RecipientUserName: RecipientUserNameColumn, + RecipientRaceName: RecipientRaceNameColumn, + DeliveredAt: DeliveredAtColumn, + ReadAt: ReadAtColumn, + DeletedAt: DeletedAtColumn, + NotifiedAt: NotifiedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/diplomail_translations.go b/backend/internal/postgres/jet/backend/table/diplomail_translations.go new file mode 100644 index 0000000..a9beea4 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/diplomail_translations.go @@ -0,0 +1,96 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DiplomailTranslations = newDiplomailTranslationsTable("backend", "diplomail_translations", "") + +type diplomailTranslationsTable struct { + postgres.Table + + // Columns + TranslationID postgres.ColumnString + MessageID postgres.ColumnString + TargetLang postgres.ColumnString + TranslatedSubject postgres.ColumnString + TranslatedBody postgres.ColumnString + Translator postgres.ColumnString + TranslatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DiplomailTranslationsTable struct { + diplomailTranslationsTable + + EXCLUDED diplomailTranslationsTable +} + +// AS creates new DiplomailTranslationsTable with assigned alias +func (a DiplomailTranslationsTable) AS(alias string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DiplomailTranslationsTable with assigned schema name +func (a DiplomailTranslationsTable) FromSchema(schemaName string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DiplomailTranslationsTable with assigned table prefix +func (a DiplomailTranslationsTable) WithPrefix(prefix string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DiplomailTranslationsTable with assigned table suffix +func (a DiplomailTranslationsTable) WithSuffix(suffix string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDiplomailTranslationsTable(schemaName, tableName, alias string) *DiplomailTranslationsTable { + return &DiplomailTranslationsTable{ + diplomailTranslationsTable: newDiplomailTranslationsTableImpl(schemaName, tableName, alias), + EXCLUDED: newDiplomailTranslationsTableImpl("", "excluded", ""), + } +} + +func newDiplomailTranslationsTableImpl(schemaName, tableName, alias string) diplomailTranslationsTable { + var ( + TranslationIDColumn = postgres.StringColumn("translation_id") + MessageIDColumn = postgres.StringColumn("message_id") + TargetLangColumn = postgres.StringColumn("target_lang") + TranslatedSubjectColumn = postgres.StringColumn("translated_subject") + TranslatedBodyColumn = postgres.StringColumn("translated_body") + TranslatorColumn = postgres.StringColumn("translator") + TranslatedAtColumn = postgres.TimestampzColumn("translated_at") + allColumns = postgres.ColumnList{TranslationIDColumn, MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn} + mutableColumns = postgres.ColumnList{MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn} + defaultColumns = postgres.ColumnList{TranslatedSubjectColumn, TranslatedAtColumn} + ) + + return diplomailTranslationsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + TranslationID: TranslationIDColumn, + MessageID: MessageIDColumn, + TargetLang: TargetLangColumn, + TranslatedSubject: TranslatedSubjectColumn, + TranslatedBody: TranslatedBodyColumn, + Translator: TranslatorColumn, + TranslatedAt: TranslatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index cace8c3..ca6723a 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -16,6 +16,9 @@ func UseSchema(schema string) { AuthChallenges = AuthChallenges.FromSchema(schema) BlockedEmails = BlockedEmails.FromSchema(schema) DeviceSessions = DeviceSessions.FromSchema(schema) + DiplomailMessages = DiplomailMessages.FromSchema(schema) + DiplomailRecipients = DiplomailRecipients.FromSchema(schema) + DiplomailTranslations = DiplomailTranslations.FromSchema(schema) EngineVersions = EngineVersions.FromSchema(schema) EntitlementRecords = EntitlementRecords.FromSchema(schema) EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index add16b9..7368e0f 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -606,7 +606,8 @@ CREATE TABLE notifications ( 'lobby.race_name.expired', 'runtime.image_pull_failed', 'runtime.container_start_failed', 'runtime.start_config_invalid', - 'game.turn.ready', 'game.paused' + 'game.turn.ready', 'game.paused', + 'diplomail.message.received' )) ); @@ -662,6 +663,100 @@ CREATE TABLE notification_malformed_intents ( CREATE INDEX notification_malformed_intents_listing_idx ON notification_malformed_intents (received_at DESC); +-- ===================================================================== +-- Diplomail domain +-- ===================================================================== + +-- diplomail_messages is the canonical record of every diplomatic-mail +-- send: one row per personal message, owner/admin send, broadcast, or +-- system notification. game_name is captured at insert time so the +-- bulk-purge / rename paths still render correctly. sender_username +-- carries either accounts.user_name (sender_kind='player') or +-- admin_accounts.username (sender_kind='admin'); system senders leave +-- it NULL. body and subject are plain UTF-8; length limits are enforced +-- in the service layer and may be tuned without a migration. +CREATE TABLE diplomail_messages ( + message_id uuid PRIMARY KEY, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + game_name text NOT NULL, + kind text NOT NULL, + sender_kind text NOT NULL, + sender_user_id uuid, + sender_username text, + sender_ip text NOT NULL DEFAULT '', + subject text NOT NULL DEFAULT '', + body text NOT NULL, + body_lang text NOT NULL DEFAULT 'und', + broadcast_scope text NOT NULL DEFAULT 'single', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT diplomail_messages_kind_chk + CHECK (kind IN ('personal', 'admin')), + CONSTRAINT diplomail_messages_sender_kind_chk + CHECK (sender_kind IN ('player', 'admin', 'system')), + CONSTRAINT diplomail_messages_sender_identity_chk CHECK ( + (sender_kind = 'player' AND sender_user_id IS NOT NULL AND sender_username IS NOT NULL) OR + (sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR + (sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL) + ), + CONSTRAINT diplomail_messages_kind_sender_chk CHECK ( + (kind = 'personal' AND sender_kind = 'player') OR + (kind = 'admin' AND sender_kind IN ('admin', 'system')) + ), + CONSTRAINT diplomail_messages_broadcast_scope_chk + CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast')) +); + +CREATE INDEX diplomail_messages_game_idx + ON diplomail_messages (game_id, created_at DESC); + +CREATE INDEX diplomail_messages_sender_user_idx + ON diplomail_messages (sender_user_id, created_at DESC) + WHERE sender_user_id IS NOT NULL; + +-- diplomail_recipients carries one row per (message, recipient). The +-- per-user read/delete/deliver/notified state lives here. recipient +-- snapshots (user_name, race_name) are captured at insert time so the +-- inbox listing and admin search render without joining accounts / +-- memberships and survive race-name renames, membership revocation, +-- and account soft-delete. recipient_race_name is nullable for the +-- rare admin notifications addressed to a player who no longer has an +-- active membership in the game. +CREATE TABLE diplomail_recipients ( + recipient_id uuid PRIMARY KEY, + message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE, + game_id uuid NOT NULL, + user_id uuid NOT NULL, + recipient_user_name text NOT NULL, + recipient_race_name text, + delivered_at timestamptz, + read_at timestamptz, + deleted_at timestamptz, + notified_at timestamptz, + CONSTRAINT diplomail_recipients_unique UNIQUE (message_id, user_id) +); + +CREATE INDEX diplomail_recipients_inbox_idx + ON diplomail_recipients (user_id, game_id, deleted_at, read_at); + +CREATE INDEX diplomail_recipients_unread_idx + ON diplomail_recipients (user_id, game_id) + WHERE read_at IS NULL AND deleted_at IS NULL; + +-- diplomail_translations caches one rendered translation per +-- (message, target_lang) so a broadcast addressed to many recipients +-- with the same preferred_language is translated once. translator +-- identifies the backend that produced the row. +CREATE TABLE diplomail_translations ( + translation_id uuid PRIMARY KEY, + message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE, + target_lang text NOT NULL, + translated_subject text NOT NULL DEFAULT '', + translated_body text NOT NULL, + translator text NOT NULL, + translated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT diplomail_translations_unique UNIQUE (message_id, target_lang) +); + -- ===================================================================== -- Geo domain -- ===================================================================== diff --git a/backend/internal/postgres/migrations_test.go b/backend/internal/postgres/migrations_test.go index 16dc549..a3940f8 100644 --- a/backend/internal/postgres/migrations_test.go +++ b/backend/internal/postgres/migrations_test.go @@ -68,6 +68,10 @@ var expectedBackendTables = []string{ "notification_malformed_intents", "notification_routes", "notifications", + // Diplomail domain. + "diplomail_messages", + "diplomail_recipients", + "diplomail_translations", // Geo domain. "user_country_counters", } diff --git a/backend/internal/server/contract_test.go b/backend/internal/server/contract_test.go index ba8e82b..3afe389 100644 --- a/backend/internal/server/contract_test.go +++ b/backend/internal/server/contract_test.go @@ -46,6 +46,7 @@ var pathParamStubs = map[string]string{ "user_id": "00000000-0000-0000-0000-000000000007", "device_session_id": "00000000-0000-0000-0000-000000000008", "battle_id": "00000000-0000-0000-0000-000000000009", + "message_id": "00000000-0000-0000-0000-00000000000a", "id": "1.2.3", "username": "alice", "turn": "42", @@ -149,6 +150,11 @@ var requestBodyStubs = map[string]map[string]any{ "user_id": pathParamStubs["user_id"], "reason": "ToS violation", }, + "userMailSendPersonal": { + "recipient_user_id": pathParamStubs["user_id"], + "subject": "Contract test subject", + "body": "Contract test body", + }, } // TestOpenAPIContract is the top-level OpenAPI contract test. It diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go new file mode 100644 index 0000000..53ad97c --- /dev/null +++ b/backend/internal/server/handlers_user_mail.go @@ -0,0 +1,424 @@ +package server + +import ( + "context" + "errors" + "net/http" + + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/server/clientip" + "galaxy/backend/internal/server/handlers" + "galaxy/backend/internal/server/httperr" + "galaxy/backend/internal/server/middleware/userid" + "galaxy/backend/internal/telemetry" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// UserMailHandlers groups the diplomatic-mail handlers under +// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side +// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires only the +// personal-message subset. +type UserMailHandlers struct { + svc *diplomail.Service + logger *zap.Logger +} + +// NewUserMailHandlers constructs the handler set. svc may be nil — in +// that case every handler returns 501 not_implemented. +func NewUserMailHandlers(svc *diplomail.Service, logger *zap.Logger) *UserMailHandlers { + if logger == nil { + logger = zap.NewNop() + } + return &UserMailHandlers{svc: svc, logger: logger.Named("http.user.mail")} +} + +// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages. +func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailSendPersonal") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + recipientID, err := uuid.Parse(req.RecipientUserID) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + ctx := c.Request.Context() + msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: userID, + RecipientUserID: recipientID, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + respondDiplomailError(c, h.logger, "user mail send personal", ctx, err) + return + } + c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true)) + } +} + +// Get handles GET /api/v1/user/games/{game_id}/mail/messages/{message_id}. +func (h *UserMailHandlers) Get() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailGet") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + if _, ok := parseGameIDParam(c); !ok { + return + } + messageID, ok := parseMessageIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + entry, err := h.svc.GetMessage(ctx, userID, messageID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail get", ctx, err) + return + } + c.JSON(http.StatusOK, mailMessageDetailToWire(entry, false)) + } +} + +// Inbox handles GET /api/v1/user/games/{game_id}/mail/inbox. +func (h *UserMailHandlers) Inbox() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailInbox") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + items, err := h.svc.ListInbox(ctx, gameID, userID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail inbox", ctx, err) + return + } + out := userMailInboxListWire{Items: make([]userMailMessageDetailWire, 0, len(items))} + for _, e := range items { + out.Items = append(out.Items, mailMessageDetailToWire(e, false)) + } + c.JSON(http.StatusOK, out) + } +} + +// Sent handles GET /api/v1/user/games/{game_id}/mail/sent. +func (h *UserMailHandlers) Sent() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailSent") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + items, err := h.svc.ListSent(ctx, gameID, userID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail sent", ctx, err) + return + } + out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))} + for _, m := range items { + out.Items = append(out.Items, mailMessageSummaryToWire(m)) + } + c.JSON(http.StatusOK, out) + } +} + +// MarkRead handles POST /api/v1/user/games/{game_id}/mail/messages/{message_id}/read. +func (h *UserMailHandlers) MarkRead() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailMarkRead") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + if _, ok := parseGameIDParam(c); !ok { + return + } + messageID, ok := parseMessageIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + rcpt, err := h.svc.MarkRead(ctx, userID, messageID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail mark read", ctx, err) + return + } + c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt)) + } +} + +// Delete handles DELETE /api/v1/user/games/{game_id}/mail/messages/{message_id}. +func (h *UserMailHandlers) Delete() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailDelete") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + if _, ok := parseGameIDParam(c); !ok { + return + } + messageID, ok := parseMessageIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + rcpt, err := h.svc.DeleteMessage(ctx, userID, messageID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail delete", ctx, err) + return + } + c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt)) + } +} + +// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts. +func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailUnreadCounts") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + ctx := c.Request.Context() + items, err := h.svc.UnreadCountsForUser(ctx, userID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail unread counts", ctx, err) + return + } + out := userMailUnreadCountsResponseWire{Items: make([]userMailUnreadCountWire, 0, len(items))} + total := 0 + for _, u := range items { + out.Items = append(out.Items, userMailUnreadCountWire{ + GameID: u.GameID.String(), + GameName: u.GameName, + Unread: u.Unread, + }) + total += u.Unread + } + out.Total = total + c.JSON(http.StatusOK, out) + } +} + +// respondDiplomailError maps diplomail-package sentinels to the +// standard JSON error envelope. Unknown errors land on a 500. +func respondDiplomailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) { + switch { + case errors.Is(err, diplomail.ErrInvalidInput): + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error()) + case errors.Is(err, diplomail.ErrNotFound): + httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found") + case errors.Is(err, diplomail.ErrForbidden): + httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error()) + case errors.Is(err, diplomail.ErrConflict): + httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error()) + default: + logger.Error(op+" failed", + append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., + ) + httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") + } +} + +// parseMessageIDParam reads `message_id` from the path. Writes a 400 +// envelope on invalid input and returns false in that case. +func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) { + parsed, err := uuid.Parse(c.Param("message_id")) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "message_id must be a valid UUID") + return uuid.Nil, false + } + return parsed, true +} + +// userMailSendRequestWire mirrors the request body for SendPersonal. +type userMailSendRequestWire struct { + RecipientUserID string `json:"recipient_user_id"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` +} + +// userMailMessageDetailWire mirrors the unified response shape for +// inbox listings and per-message reads. Sender identifiers are +// optional: system messages carry neither user id nor username. +type userMailMessageDetailWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + SenderUserID *string `json:"sender_user_id,omitempty"` + SenderUsername *string `json:"sender_username,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` + RecipientUserID string `json:"recipient_user_id"` + RecipientUserName string `json:"recipient_user_name,omitempty"` + RecipientRaceName *string `json:"recipient_race_name,omitempty"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +// userMailSentSummaryWire mirrors the response shape for the +// sender-side listing. Recipient state is intentionally omitted (one +// author may have N recipients per broadcast in later stages). +type userMailSentSummaryWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` +} + +type userMailInboxListWire struct { + Items []userMailMessageDetailWire `json:"items"` +} + +type userMailSentListWire struct { + Items []userMailSentSummaryWire `json:"items"` +} + +type userMailUnreadCountWire struct { + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Unread int `json:"unread"` +} + +type userMailUnreadCountsResponseWire struct { + Total int `json:"total"` + Items []userMailUnreadCountWire `json:"items"` +} + +func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userMailMessageDetailWire { + out := userMailMessageDetailWire{ + MessageID: entry.MessageID.String(), + GameID: entry.GameID.String(), + GameName: entry.GameName, + Kind: entry.Kind, + SenderKind: entry.SenderKind, + Subject: entry.Subject, + Body: entry.Body, + BodyLang: entry.BodyLang, + BroadcastScope: entry.BroadcastScope, + CreatedAt: entry.CreatedAt.UTC().Format(timestampLayout), + RecipientUserID: entry.Recipient.UserID.String(), + RecipientUserName: entry.Recipient.RecipientUserName, + } + if entry.SenderUserID != nil { + s := entry.SenderUserID.String() + out.SenderUserID = &s + } + if entry.SenderUsername != nil { + s := *entry.SenderUsername + out.SenderUsername = &s + } + if entry.Recipient.RecipientRaceName != nil { + s := *entry.Recipient.RecipientRaceName + out.RecipientRaceName = &s + } + if entry.Recipient.ReadAt != nil { + s := entry.Recipient.ReadAt.UTC().Format(timestampLayout) + out.ReadAt = &s + } + if entry.Recipient.DeletedAt != nil { + s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout) + out.DeletedAt = &s + } + _ = justCreated + return out +} + +func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire { + return userMailSentSummaryWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + Kind: m.Kind, + Subject: m.Subject, + Body: m.Body, + BodyLang: m.BodyLang, + BroadcastScope: m.BroadcastScope, + CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), + } +} + +// mailRecipientStateToWire renders the recipient row after a +// mark-read or soft-delete call. The caller only needs the per-user +// state, not the full message body again. +func mailRecipientStateToWire(r diplomail.Recipient) userMailRecipientStateWire { + out := userMailRecipientStateWire{ + MessageID: r.MessageID.String(), + } + if r.ReadAt != nil { + s := r.ReadAt.UTC().Format(timestampLayout) + out.ReadAt = &s + } + if r.DeletedAt != nil { + s := r.DeletedAt.UTC().Format(timestampLayout) + out.DeletedAt = &s + } + return out +} + +type userMailRecipientStateWire struct { + MessageID string `json:"message_id"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 16dd2d0..36ccc7e 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -68,6 +68,7 @@ type RouterDependencies struct { UserLobbyMy *UserLobbyMyHandlers UserLobbyRaceNames *UserLobbyRaceNamesHandlers UserGames *UserGamesHandlers + UserMail *UserMailHandlers UserSessions *UserSessionsHandlers AdminAdminAccounts *AdminAdminAccountsHandlers AdminUsers *AdminUsersHandlers @@ -163,6 +164,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies { if deps.UserGames == nil { deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger) } + if deps.UserMail == nil { + deps.UserMail = NewUserMailHandlers(nil, deps.Logger) + } if deps.UserSessions == nil { deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger) } @@ -255,6 +259,9 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de my.GET("/invites", deps.UserLobbyMy.Invites()) my.GET("/race-names", deps.UserLobbyMy.RaceNames()) + lobbyMail := lobbyGroup.Group("/mail") + lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts()) + raceNames := lobbyGroup.Group("/race-names") raceNames.POST("/register", deps.UserLobbyRaceNames.Register()) @@ -265,6 +272,14 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle()) + userMail := userGames.Group("/:game_id/mail") + userMail.POST("/messages", deps.UserMail.SendPersonal()) + userMail.GET("/messages/:message_id", deps.UserMail.Get()) + userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead()) + userMail.DELETE("/messages/:message_id", deps.UserMail.Delete()) + userMail.GET("/inbox", deps.UserMail.Inbox()) + userMail.GET("/sent", deps.UserMail.Sent()) + userSessions := group.Group("/sessions") userSessions.GET("", deps.UserSessions.List()) userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll()) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 6915d34..2fc7d0e 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1144,6 +1144,215 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/messages: + post: + tags: [User] + operationId: userMailSendPersonal + summary: Send a personal diplomatic mail message + description: | + Sends a replyable personal message from the authenticated user + to another active member of the same game. Both sender and + recipient must be active members. Body is plain UTF-8 text + (no HTML processing on the server); `subject` is optional. + Body length is capped at `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` + (default 4096) and subject length at + `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendRequest" + responses: + "201": + description: Personal message accepted and persisted. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailMessageDetail" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/messages/{message_id}: + get: + tags: [User] + operationId: userMailGet + summary: Read one diplomatic mail message + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: Message addressed to the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailMessageDetail" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + delete: + tags: [User] + operationId: userMailDelete + summary: Soft-delete a previously-read message + description: | + Marks the caller's recipient row for the message as deleted. + The underlying message stays persisted (admin / system mail is + retained for the lifetime of the game). The recipient row must + have `read_at` set first; otherwise the call returns 409. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: Message soft-deleted for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailRecipientState" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/messages/{message_id}/read: + post: + tags: [User] + operationId: userMailMarkRead + summary: Mark a diplomatic mail message as read + description: | + Idempotent. Sets `read_at` on the caller's recipient row when + it is still unread; a second call on an already-read row is a + no-op and the existing state is returned. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: Recipient state after the mark-read. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailRecipientState" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/inbox: + get: + tags: [User] + operationId: userMailInbox + summary: List the caller's inbox for a game + description: | + Returns every non-soft-deleted mail row addressed to the + caller in the given game, newest first. Includes the + per-recipient read state. Soft access: the caller may not be + an active member if every visible row carries + `kind="admin"`. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + responses: + "200": + description: Inbox entries for the caller in the given game. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailInboxList" + "400": + $ref: "#/components/responses/InvalidRequestError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/sent: + get: + tags: [User] + operationId: userMailSent + summary: List the caller's sent personal messages in a game + description: | + Returns personal messages authored by the caller in the given + game, newest first. Admin / system messages are not listed + (they have no `sender_user_id`). + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + responses: + "200": + description: Sent personal messages by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSentList" + "400": + $ref: "#/components/responses/InvalidRequestError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/lobby/mail/unread-counts: + get: + tags: [User] + operationId: userMailUnreadCounts + summary: Per-game and total unread mail counts for the caller + description: | + Drives the lobby badge: returns one entry per game the caller + has any unread mail in, plus the global total. The response + is empty (and `total == 0`) when there is nothing unread. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Per-game unread counts addressed to the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailUnreadCountsResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/user/sessions: get: tags: [User] @@ -2247,6 +2456,13 @@ components: schema: type: string format: uuid + MessageID: + name: message_id + in: path + required: true + schema: + type: string + format: uuid NotificationID: name: notification_id in: path @@ -3599,6 +3815,180 @@ components: type: array items: $ref: "#/components/schemas/DeviceSession" + UserMailSendRequest: + type: object + additionalProperties: false + required: [recipient_user_id, body] + properties: + recipient_user_id: + type: string + format: uuid + subject: + type: string + description: | + Optional subject. Empty string and missing field are + treated the same. + body: + type: string + description: Plain UTF-8 body. HTML is not parsed on the server. + UserMailMessageDetail: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - sender_kind + - body + - body_lang + - broadcast_scope + - created_at + - recipient_user_id + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + sender_kind: + type: string + enum: [player, admin, system] + sender_user_id: + type: string + format: uuid + nullable: true + sender_username: + type: string + nullable: true + subject: + type: string + body: + type: string + body_lang: + type: string + description: BCP 47 tag. `und` until Stage D adds detection. + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + recipient_user_id: + type: string + format: uuid + recipient_user_name: + type: string + recipient_race_name: + type: string + nullable: true + read_at: + type: string + format: date-time + nullable: true + deleted_at: + type: string + format: date-time + nullable: true + UserMailSentSummary: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - body + - body_lang + - broadcast_scope + - created_at + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + subject: + type: string + body: + type: string + body_lang: + type: string + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + UserMailInboxList: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UserMailMessageDetail" + UserMailSentList: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UserMailSentSummary" + UserMailUnreadCount: + type: object + additionalProperties: false + required: [game_id, unread] + properties: + game_id: + type: string + format: uuid + game_name: + type: string + unread: + type: integer + minimum: 0 + UserMailUnreadCountsResponse: + type: object + additionalProperties: false + required: [total, items] + properties: + total: + type: integer + minimum: 0 + items: + type: array + items: + $ref: "#/components/schemas/UserMailUnreadCount" + UserMailRecipientState: + type: object + additionalProperties: false + required: [message_id] + properties: + message_id: + type: string + format: uuid + read_at: + type: string + format: date-time + nullable: true + deleted_at: + type: string + format: date-time + nullable: true responses: NotImplementedError: description: Endpoint is documented but not implemented yet. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ed6df5..53665ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -192,10 +192,12 @@ because they cross domain boundaries: `race_name`) remain `text`. - Foreign keys are intra-domain only: `accounts → entitlement_*` / `sanction_*` / `limit_*`; `games → applications` / `invites` / - `memberships` (with `ON DELETE CASCADE`); `mail_payloads → - mail_deliveries → mail_recipients` / `mail_attempts` / - `mail_dead_letters`; `notifications → notification_routes` / - `notification_dead_letters`. Cross-domain references + `memberships` / `diplomail_messages` (each with + `ON DELETE CASCADE`); `mail_payloads → mail_deliveries → + mail_recipients` / `mail_attempts` / `mail_dead_letters`; + `notifications → notification_routes` / `notification_dead_letters`; + `diplomail_messages → diplomail_recipients` / + `diplomail_translations`. Cross-domain references (`memberships.user_id`, `games.owner_user_id`, etc.) are kept as opaque `uuid` columns because each domain runs its own cleanup through the in-process cascade described in [§7](#7-in-process-async-patterns). Adding a database @@ -456,12 +458,15 @@ committed; SMTP completion is asynchronous to the auth request. Notifications are an in-process pipeline. The closed catalog is defined in `backend/internal/notification/catalog.go` and currently -covers 13 kinds: 10 lobby kinds (invite received/revoked, application +covers 16 kinds: 10 lobby kinds (invite received/revoked, application submitted/approved/rejected, membership removed/blocked, race name -registered/pending/expired) and 3 admin-recipient runtime kinds -(image pull failed, container start failed, start config invalid). -Per-kind delivery channels (push, email, or both) and the admin-vs- -per-user recipient routing live in the same file. +registered/pending/expired), 3 admin-recipient runtime kinds (image +pull failed, container start failed, start config invalid), 2 game +lifecycle kinds (turn ready, game paused), and the +`diplomail.message.received` kind that fans diplomatic-mail send +events out to the recipient's push stream. Per-kind delivery channels +(push, email, or both) and the admin-vs-per-user recipient routing +live in the same file. For every intent, `notification.Submit` performs: @@ -490,6 +495,34 @@ Notification persistence is the auditable record of "we tried to tell this user about this thing"; clients still derive their actual game state through normal user-facing reads. +### 12.1 Diplomatic mail subsystem + +`backend/internal/diplomail` owns the player-to-player message channel +that the in-game mail view consumes. The data lives in three tables: + +- `diplomail_messages` — one canonical row per send. Captures the + game name and the sender IP at insert time so audit rendering + survives game renames and bulk purges. `kind` is `personal` (a + replyable player→player message) or `admin` (a non-replyable + notification produced by an administrator or the system). + `sender_kind` distinguishes `player`, `admin`, and `system` senders. + `broadcast_scope` carries `single`, `game_broadcast`, or + `multi_game_broadcast`. +- `diplomail_recipients` — one row per (message, recipient). Holds + the per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at` + state plus snapshot fields (`recipient_user_name`, + `recipient_race_name`) so admin search and the inbox listing render + correctly even after the source rows are renamed or revoked. +- `diplomail_translations` — cached per-language rendering shared + across every recipient with the same `accounts.preferred_language`. + +Stage A wires the personal subset (single recipient, no language +detection). Lifecycle hooks (paused / cancelled / kicked), paid-tier +player broadcasts, multi-game admin broadcasts, bulk purge, and the +detection / translation cache land in later stages. The package is +the only place that constructs `diplomail.message.received` push +intents; the notification pipeline takes it from there. + ## 13. Container Lifecycle (in-process) `backend/internal/runtime` owns the lifecycle of game-engine containers