diplomail (Stage A): add in-game personal mail subsystem
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail channel; the existing `mail` package is a transactional email outbox and the `notification` catalog is one-way platform events. Stage A lands the schema (diplomail_messages / _recipients / _translations), a single-recipient personal send/read/delete service path, a `diplomail.message.received` push kind plumbed through the notification pipeline, and an unread-counts endpoint that drives the lobby badge. Admin / system mail, lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk purge and language detection / translation cache come in stages B–D. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user