diplomail (Stage A→D): backend in-game diplomatic mail #10
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
-- =====================================================================
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
+42
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user