Compare commits
111 Commits
main
...
1dd8df9f6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd8df9f6e | |||
| 11f51944df | |||
| 04c7f6e68a | |||
| c066a8958e | |||
| b729036778 | |||
| 9d3a652b6b | |||
| b07b8fb1c8 | |||
| 35e27c5aec | |||
| 8dcaf1c6c6 | |||
| 87d524fb89 | |||
| 1e62837c68 | |||
| c56050f5dd | |||
| 70f2973396 | |||
| e193f3ca88 | |||
| 642c5b7322 | |||
| dcc655c7c4 | |||
| 4ad96b0ef7 | |||
| 973480d812 | |||
| 44ed0a90eb | |||
| a89048f6c5 | |||
| 51865b8cf4 | |||
| 4d3cfd11a3 | |||
| b1b87c8521 | |||
| 3ea29cf8b5 | |||
| 9ae7b88b89 | |||
| 00159ddf7c | |||
| b24d53b82f | |||
| 6572f5b59d | |||
| a08f4f55b0 | |||
| 44c18c3ef4 | |||
| 51902b995f | |||
| 0da2f4b6fb | |||
| 53b892ae00 | |||
| 00e84579ca | |||
| 7ade838df8 | |||
| 37580b7699 | |||
| 2f4dc01d54 | |||
| 7c46aa4bec | |||
| 2528d63b51 | |||
| 2bd1b54936 | |||
| 65c0fbb87d | |||
| 3d06f49f3c | |||
| 08345606a5 | |||
| b85a9e1b9b | |||
| 8170abd5fa | |||
| 14b65389ef | |||
| 8f84075c4b | |||
| bde01b1ce2 | |||
| 82bdb6777a | |||
| f70258849f | |||
| d19aa3aac5 | |||
| a338ebf058 | |||
| f91cf6eb41 | |||
| daed2690c1 | |||
| a9087691a3 | |||
| 5eec7013ba | |||
| 49f614926a | |||
| cadb72b412 | |||
| 5177fef2ef | |||
| 823be9d980 | |||
| 2119f825d6 | |||
| 57e6c1d253 | |||
| 4b2a949f12 | |||
| 81917acc3e | |||
| 859b157a59 | |||
| 166baf4be0 | |||
| ebd156ece2 | |||
| 8bc75fd71b | |||
| 1556d36511 | |||
| 6d0272b078 | |||
| c48bc83890 | |||
| db81bd8e08 | |||
| f7300f25a3 | |||
| fdd5fd193d | |||
| 7378d4c8ed | |||
| 4cb03736de | |||
| 57d2286f5e | |||
| fed282f2d2 | |||
| 7b43ce5844 | |||
| 74c1e7ab24 | |||
| 2d36b54b8d | |||
| 9f7c9099bc | |||
| e22f4b7800 | |||
| 362f92e520 | |||
| b3f24cc440 | |||
| 535e27008f | |||
| 77cb7c78b6 | |||
| 1a0e3e992f | |||
| faf598b2cd | |||
| 6e6186a571 | |||
| e3bb30201d | |||
| 7ff81de2b6 | |||
| 9d65bf5157 | |||
| 1855e43699 | |||
| 7bce67462c | |||
| 2be7e5c110 | |||
| 2a95bf4a50 | |||
| fd071260ec | |||
| 8058f26397 | |||
| 660044559c | |||
| 9135991887 | |||
| bb74e3336e | |||
| 4a88b24f4b | |||
| fe8ad6a02a | |||
| 9ebb2e7f0f | |||
| 0da360a644 | |||
| 6686059535 | |||
| c6c5f3c8dd | |||
| f00c8efd18 | |||
| f316952c12 | |||
| 00c79064fc |
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build core.wasm
|
||||||
|
description: >-
|
||||||
|
Install TinyGo (cached) and build ui/core to frontend/static/core.wasm
|
||||||
|
and wasm_exec.js via `make -C ui wasm`. The binaries are no longer
|
||||||
|
committed, so every workflow that builds or serves the frontend bundle
|
||||||
|
(ui-test, dev-deploy, prod-build) runs this first. Requires Go to be
|
||||||
|
set up by the caller — TinyGo shells out to the Go toolchain.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Restore TinyGo cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/galaxy-tinygo
|
||||||
|
key: tinygo-0.41.1-linux-amd64
|
||||||
|
|
||||||
|
- name: Install TinyGo
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="0.41.1"
|
||||||
|
root="$HOME/.cache/galaxy-tinygo/tinygo"
|
||||||
|
if [ ! -x "$root/bin/tinygo" ]; then
|
||||||
|
mkdir -p "$HOME/.cache/galaxy-tinygo"
|
||||||
|
curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${version}/tinygo${version}.linux-amd64.tar.gz" \
|
||||||
|
| tar -xz -C "$HOME/.cache/galaxy-tinygo"
|
||||||
|
fi
|
||||||
|
echo "$root/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Build core.wasm
|
||||||
|
shell: bash
|
||||||
|
run: make -C ui wasm
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Deploy · Prod
|
||||||
|
|
||||||
|
# Placeholder for the production rollout workflow. Today it only proves
|
||||||
|
# the manual entry point works; the actual `docker save | ssh prod
|
||||||
|
# docker load` + remote `docker compose up -d` pipeline is wired in
|
||||||
|
# once the production host, SSH credentials, and DNS are decided.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Image tag to deploy (commit-<sha12>, produced by prod-build.yaml)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Announce target
|
||||||
|
run: |
|
||||||
|
echo "Would deploy image tag: ${{ inputs.image_tag }}"
|
||||||
|
echo "TODO:"
|
||||||
|
echo " 1. Download galaxy-images-${{ inputs.image_tag }} from prod-build artifacts."
|
||||||
|
echo " 2. scp the .tar.gz bundles to the production host."
|
||||||
|
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
|
||||||
|
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
|
||||||
|
echo " 5. Probe https://api.galaxy.com/healthz and roll back on failure."
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: Deploy · Dev
|
||||||
|
|
||||||
|
# Builds the Galaxy stack and (re)deploys it into the long-lived dev
|
||||||
|
# environment on the host running this Gitea Actions runner. Triggered
|
||||||
|
# on every merge into `development`. Branch protections on `development`
|
||||||
|
# guarantee the commit already passed `go-unit`, `ui-test`, and
|
||||||
|
# `integration` as part of the PR that produced this push, so this
|
||||||
|
# workflow does not re-run those tests — it focuses on packaging and
|
||||||
|
# rollout.
|
||||||
|
#
|
||||||
|
# `workflow_dispatch` is also accepted so a developer can deploy any
|
||||||
|
# branch (typically a feature branch under active review) into the
|
||||||
|
# shared dev environment from the Gitea Actions UI without waiting for
|
||||||
|
# the PR to merge first. The deploy job picks up whatever the chosen
|
||||||
|
# ref is — same packaging + healthcheck steps as the merge path.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- development
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'gateway/**'
|
||||||
|
- 'game/**'
|
||||||
|
- 'pkg/**'
|
||||||
|
- 'ui/**'
|
||||||
|
- 'go.work'
|
||||||
|
- 'go.work.sum'
|
||||||
|
- 'tools/dev-deploy/**'
|
||||||
|
- '.gitea/workflows/dev-deploy.yaml'
|
||||||
|
- '!**/*.md'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.0.7
|
||||||
|
# Install pnpm into a per-job directory so concurrent jobs on
|
||||||
|
# the shared host runner do not race on the default
|
||||||
|
# `~/setup-pnpm` (the self-installer otherwise fails with
|
||||||
|
# `ENOTEMPTY` while cleaning a sibling job's install).
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install UI dependencies
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build core.wasm
|
||||||
|
uses: ./.gitea/actions/build-wasm
|
||||||
|
|
||||||
|
- name: Build UI frontend
|
||||||
|
working-directory: ui/frontend
|
||||||
|
env:
|
||||||
|
VITE_GATEWAY_BASE_URL: https://api.galaxy.lan
|
||||||
|
# Surface the synthetic-report loader and similar dev-only
|
||||||
|
# affordances in the long-lived dev bundle. The prod build
|
||||||
|
# path (`prod-build.yaml`) leaves this flag unset so the
|
||||||
|
# production bundle keeps the same affordances stripped.
|
||||||
|
VITE_GALAXY_DEV_AFFORDANCES: "true"
|
||||||
|
run: |
|
||||||
|
# The response-signing public key is committed in
|
||||||
|
# `.env.development` alongside its private counterpart in
|
||||||
|
# `tools/local-dev/keys/`. Pull it from there at build time so
|
||||||
|
# the production-mode bundle ships the same key the dev
|
||||||
|
# gateway uses to sign.
|
||||||
|
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
- name: Build galaxy-engine image
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t galaxy-engine:dev \
|
||||||
|
-f game/Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Build backend + gateway images
|
||||||
|
working-directory: tools/dev-deploy
|
||||||
|
run: |
|
||||||
|
docker compose build galaxy-backend galaxy-api
|
||||||
|
|
||||||
|
- name: Seed UI volume
|
||||||
|
run: |
|
||||||
|
docker volume create galaxy-dev-ui-dist >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v galaxy-dev-ui-dist:/dst \
|
||||||
|
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
||||||
|
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||||
|
|
||||||
|
- name: Seed geoip volume
|
||||||
|
run: |
|
||||||
|
# Copy the GeoIP test fixture into a named volume so the
|
||||||
|
# backend can mount it as /var/lib/galaxy. A bind-mount with
|
||||||
|
# a relative path would resolve against this runner's
|
||||||
|
# ephemeral workspace under /home/runner/.cache/act/<hash>/,
|
||||||
|
# which the runner deletes once the workflow ends — the next
|
||||||
|
# `docker restart galaxy-dev-backend` would then fail with
|
||||||
|
# "not a directory" because the mount source vanished.
|
||||||
|
docker volume create galaxy-dev-geoip-data >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v galaxy-dev-geoip-data:/dst \
|
||||||
|
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
||||||
|
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
||||||
|
|
||||||
|
- name: Reap stray dev-deploy containers
|
||||||
|
run: |
|
||||||
|
# Remove any non-running compose-managed containers from
|
||||||
|
# earlier deploys before `compose up`. Filter by the stack
|
||||||
|
# label so we never touch unrelated workloads on the same
|
||||||
|
# daemon. Running containers (incl. engine instances backend
|
||||||
|
# spawned itself with the same label) are left intact —
|
||||||
|
# those are reattached by the backend reconciler on boot.
|
||||||
|
ids=$(docker ps -aq \
|
||||||
|
--filter "label=galaxy.stack=dev-deploy" \
|
||||||
|
--filter "status=exited" \
|
||||||
|
--filter "status=created" \
|
||||||
|
--filter "status=dead")
|
||||||
|
if [ -n "$ids" ]; then
|
||||||
|
echo "reaping: $ids"
|
||||||
|
docker rm -f $ids
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bring up the stack
|
||||||
|
working-directory: tools/dev-deploy
|
||||||
|
run: |
|
||||||
|
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||||
|
# is empty at the workflow-evaluation stage.
|
||||||
|
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||||
|
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||||
|
docker compose up -d --wait --remove-orphans
|
||||||
|
|
||||||
|
- name: Probe the stack
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Use --resolve so the probe goes through the same routing as
|
||||||
|
# a browser on the host: the host Caddy on :443 (which has
|
||||||
|
# `tls internal`) terminates and forwards into the edge
|
||||||
|
# network. We accept the host's internal CA via -k because
|
||||||
|
# the runner image has no reason to trust it.
|
||||||
|
curl -sk --max-time 10 https://api.galaxy.lan/healthz \
|
||||||
|
| tee /tmp/healthz
|
||||||
|
test -s /tmp/healthz
|
||||||
|
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||||
|
https://www.galaxy.lan/ | tee /tmp/www_status
|
||||||
|
grep -qE '^(200|304)$' /tmp/www_status
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: Tests · Go
|
||||||
|
|
||||||
|
# Fast unit tests for the Go side of the monorepo. Runs on every push
|
||||||
|
# and pull request whose path filter matches a Go source directory.
|
||||||
|
# The integration suite (testcontainers-driven, slow) lives in
|
||||||
|
# `integration.yaml` and only fires for PRs into `development`/`main`
|
||||||
|
# and pushes to `development`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'gateway/**'
|
||||||
|
- 'game/**'
|
||||||
|
- 'pkg/**'
|
||||||
|
- 'ui/core/**'
|
||||||
|
- 'go.work'
|
||||||
|
- 'go.work.sum'
|
||||||
|
- '.gitea/workflows/go-unit.yaml'
|
||||||
|
- '!**/*.md'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'gateway/**'
|
||||||
|
- 'game/**'
|
||||||
|
- 'pkg/**'
|
||||||
|
- 'ui/core/**'
|
||||||
|
- 'go.work'
|
||||||
|
- 'go.work.sum'
|
||||||
|
- '.gitea/workflows/go-unit.yaml'
|
||||||
|
- '!**/*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
# client/ is the deprecated Fyne client; excluded from CI per
|
||||||
|
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||||
|
# run never depends on a previous runner's cached state. The
|
||||||
|
# backend suite is run with -p 1 because most backend packages
|
||||||
|
# spawn their own Postgres testcontainer, and parallel
|
||||||
|
# Postgres bootstraps starve each other on a constrained
|
||||||
|
# runner. pkg modules are listed one by one because ./pkg/...
|
||||||
|
# does not recurse across the independent go.work modules
|
||||||
|
# under pkg/.
|
||||||
|
run: |
|
||||||
|
go test -count=1 -p 1 ./backend/...
|
||||||
|
go test -count=1 \
|
||||||
|
./gateway/... \
|
||||||
|
./game/... \
|
||||||
|
./ui/core/... \
|
||||||
|
./pkg/calc/... \
|
||||||
|
./pkg/connector/... \
|
||||||
|
./pkg/cronutil/... \
|
||||||
|
./pkg/error/... \
|
||||||
|
./pkg/geoip/... \
|
||||||
|
./pkg/model/... \
|
||||||
|
./pkg/postgres/... \
|
||||||
|
./pkg/redisconn/... \
|
||||||
|
./pkg/schema/... \
|
||||||
|
./pkg/storage/... \
|
||||||
|
./pkg/transcoder/... \
|
||||||
|
./pkg/util/...
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Tests · Integration
|
||||||
|
|
||||||
|
# Full integration suite (testcontainers-driven, ~5–10 minutes). Heavy
|
||||||
|
# enough that we do not run it on every push to a feature branch — only
|
||||||
|
# when there is an open PR aimed at `development`/`main`, or after a
|
||||||
|
# merge into `development`. The unit jobs (`go-unit.yaml`,
|
||||||
|
# `ui-test.yaml`) keep guarding fast feedback on every push.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- development
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'gateway/**'
|
||||||
|
- 'game/**'
|
||||||
|
- 'pkg/**'
|
||||||
|
- 'ui/core/**'
|
||||||
|
- 'integration/**'
|
||||||
|
- 'go.work'
|
||||||
|
- 'go.work.sum'
|
||||||
|
- '.gitea/workflows/integration.yaml'
|
||||||
|
- '!**/*.md'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- development
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'gateway/**'
|
||||||
|
- 'game/**'
|
||||||
|
- 'pkg/**'
|
||||||
|
- 'ui/core/**'
|
||||||
|
- 'integration/**'
|
||||||
|
- 'go.work'
|
||||||
|
- 'go.work.sum'
|
||||||
|
- '.gitea/workflows/integration.yaml'
|
||||||
|
- '!**/*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run integration suite
|
||||||
|
# `make integration` precleans leftover docker-compose state and
|
||||||
|
# then runs every test under integration/ serially (-p=1
|
||||||
|
# -parallel=1, 15-minute per-test timeout). Testcontainers
|
||||||
|
# reaches the host's docker daemon via the socket Gitea exposes
|
||||||
|
# to the runner; the workflow inherits the same access the
|
||||||
|
# runner has.
|
||||||
|
run: make -C integration integration
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
name: Build · Prod
|
||||||
|
|
||||||
|
# Builds the production-grade Docker images and the UI bundle on every
|
||||||
|
# merge into `main`, then saves the artifacts so a future
|
||||||
|
# `deploy-prod.yaml` run can ship them to the production host. This
|
||||||
|
# workflow does not deploy anything by itself — production rollout is
|
||||||
|
# strictly manual (workflow_dispatch on `deploy-prod.yaml`).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'gateway/**'
|
||||||
|
- 'game/**'
|
||||||
|
- 'pkg/**'
|
||||||
|
- 'ui/**'
|
||||||
|
- 'go.work'
|
||||||
|
- 'go.work.sum'
|
||||||
|
- '.gitea/workflows/prod-build.yaml'
|
||||||
|
- '!**/*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.0.7
|
||||||
|
# Install pnpm into a per-job directory so concurrent jobs on
|
||||||
|
# the shared host runner do not race on the default
|
||||||
|
# `~/setup-pnpm` (the self-installer otherwise fails with
|
||||||
|
# `ENOTEMPTY` while cleaning a sibling job's install).
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Resolve image tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
short_sha=$(git rev-parse --short=12 HEAD)
|
||||||
|
echo "tag=commit-${short_sha}" >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build backend image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t "galaxy/backend:${{ steps.tag.outputs.tag }}" \
|
||||||
|
-f backend/Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Build gateway image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
|
||||||
|
-f gateway/Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Build engine image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
|
||||||
|
-f game/Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Install UI dependencies
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build core.wasm
|
||||||
|
uses: ./.gitea/actions/build-wasm
|
||||||
|
|
||||||
|
- name: Build UI bundle
|
||||||
|
working-directory: ui/frontend
|
||||||
|
env:
|
||||||
|
VITE_GATEWAY_BASE_URL: https://api.galaxy.com
|
||||||
|
run: |
|
||||||
|
# Production response-signing public key is not in the repo
|
||||||
|
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
||||||
|
# only). When real prod keys exist, source them from a Gitea
|
||||||
|
# Actions secret and set VITE_GATEWAY_RESPONSE_PUBLIC_KEY
|
||||||
|
# here. Until then the prod bundle compiles with the dev
|
||||||
|
# key as a placeholder so the artifact exists.
|
||||||
|
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
- name: Save images as artifact bundles
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
docker save "galaxy/backend:${{ steps.tag.outputs.tag }}" \
|
||||||
|
| gzip >"artifacts/backend-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||||
|
docker save "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
|
||||||
|
| gzip >"artifacts/gateway-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||||
|
docker save "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
|
||||||
|
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||||
|
tar -C ui/frontend -czf \
|
||||||
|
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
||||||
|
|
||||||
|
- name: Upload images
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: galaxy-images-${{ steps.tag.outputs.tag }}
|
||||||
|
path: artifacts/*.tar.gz
|
||||||
|
retention-days: 30
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
name: ui-release
|
|
||||||
|
|
||||||
# Tier 2 (release) workflow. Runs on tag push.
|
|
||||||
#
|
|
||||||
# Currently mirrors the Tier 1 step set. Visual regression baseline
|
|
||||||
# checks and the macOS-runner iOS smoke job are landed in later phases
|
|
||||||
# of ui/PLAN.md and live as commented sections at the end of this file
|
|
||||||
# until those phases ship.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run Go tests
|
|
||||||
# client/ is the deprecated Fyne client; excluded from CI per
|
|
||||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
|
||||||
# run never depends on a previous runner's cached state. The
|
|
||||||
# backend suite is run with -p 1 because most backend packages
|
|
||||||
# spawn their own Postgres testcontainer, and parallel
|
|
||||||
# Postgres bootstraps starve each other on a constrained
|
|
||||||
# runner. pkg modules are listed one by one because ./pkg/...
|
|
||||||
# does not recurse across the independent go.work modules
|
|
||||||
# under pkg/.
|
|
||||||
run: |
|
|
||||||
go test -count=1 -p 1 ./backend/...
|
|
||||||
go test -count=1 \
|
|
||||||
./gateway/... \
|
|
||||||
./game/... \
|
|
||||||
./ui/core/... \
|
|
||||||
./pkg/calc/... \
|
|
||||||
./pkg/connector/... \
|
|
||||||
./pkg/cronutil/... \
|
|
||||||
./pkg/error/... \
|
|
||||||
./pkg/geoip/... \
|
|
||||||
./pkg/model/... \
|
|
||||||
./pkg/postgres/... \
|
|
||||||
./pkg/redisconn/... \
|
|
||||||
./pkg/schema/... \
|
|
||||||
./pkg/storage/... \
|
|
||||||
./pkg/transcoder/... \
|
|
||||||
./pkg/util/...
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 11.0.7
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: ui/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Install npm dependencies
|
|
||||||
working-directory: ui
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
working-directory: ui/frontend
|
|
||||||
run: pnpm exec playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run Vitest
|
|
||||||
working-directory: ui/frontend
|
|
||||||
run: pnpm test
|
|
||||||
|
|
||||||
- name: Run Playwright
|
|
||||||
working-directory: ui/frontend
|
|
||||||
run: pnpm exec playwright test
|
|
||||||
|
|
||||||
- name: Upload Playwright report on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: ui/frontend/playwright-report/
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
- name: Upload Playwright traces on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: playwright-traces
|
|
||||||
path: ui/frontend/test-results/
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
# visual-regression: enabled in Phase 33 of ui/PLAN.md, once the PWA
|
|
||||||
# shell and service worker land and a snapshot baseline is committed
|
|
||||||
# under ui/frontend/tests/__snapshots__/.
|
|
||||||
#
|
|
||||||
# visual-regression:
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# needs: test
|
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v4
|
|
||||||
# - uses: pnpm/action-setup@v4
|
|
||||||
# with: { version: 11.0.7 }
|
|
||||||
# - uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: 22
|
|
||||||
# cache: pnpm
|
|
||||||
# cache-dependency-path: ui/pnpm-lock.yaml
|
|
||||||
# - working-directory: ui
|
|
||||||
# run: pnpm install --frozen-lockfile
|
|
||||||
# - working-directory: ui/frontend
|
|
||||||
# run: pnpm exec playwright install --with-deps
|
|
||||||
# - working-directory: ui/frontend
|
|
||||||
# run: pnpm exec playwright test --grep @visual
|
|
||||||
|
|
||||||
# ios-smoke: enabled in Phase 32 of ui/PLAN.md, once the Capacitor
|
|
||||||
# wrapper lands. Runs a Capacitor + Appium smoke against an iOS
|
|
||||||
# simulator on a macOS runner.
|
|
||||||
#
|
|
||||||
# ios-smoke:
|
|
||||||
# runs-on: macos-13
|
|
||||||
# needs: test
|
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v4
|
|
||||||
# - uses: pnpm/action-setup@v4
|
|
||||||
# with: { version: 11.0.7 }
|
|
||||||
# - uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: 22
|
|
||||||
# cache: pnpm
|
|
||||||
# cache-dependency-path: ui/pnpm-lock.yaml
|
|
||||||
# - working-directory: ui
|
|
||||||
# run: pnpm install --frozen-lockfile
|
|
||||||
# - working-directory: ui/mobile
|
|
||||||
# run: pnpm exec cap sync ios && pnpm exec appium-smoke ios
|
|
||||||
@@ -1,41 +1,33 @@
|
|||||||
name: ui-test
|
name: Tests · UI
|
||||||
|
|
||||||
# Tier 1 (per-PR) workflow. Runs Vitest + Playwright for the UI client and
|
# UI-side unit and end-to-end tests (Vitest + Playwright). The Go side
|
||||||
# the monorepo Go service tests (everything except the integration suite,
|
# of the workspace is tested in `go-unit.yaml`. Both workflows can run
|
||||||
# which lives behind `make -C integration integration` and needs a Docker
|
# in parallel for a push that touches Go and UI together.
|
||||||
# daemon set up for testcontainers).
|
|
||||||
#
|
|
||||||
# The path filter is intentionally broad until a dedicated go-test
|
|
||||||
# workflow is introduced; this is the only CI gate today.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- 'ui/**'
|
- 'ui/**'
|
||||||
- 'backend/**'
|
|
||||||
- 'gateway/**'
|
|
||||||
- 'game/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/ui-test.yaml'
|
- '.gitea/workflows/ui-test.yaml'
|
||||||
# Skip docs-only commits. Negation removes pure markdown changes;
|
|
||||||
# mixed commits (code + .md) still match a positive pattern above
|
|
||||||
# and trigger the workflow. Image and other binary asset paths
|
|
||||||
# are already outside the positive list.
|
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'ui/**'
|
- 'ui/**'
|
||||||
- 'backend/**'
|
|
||||||
- 'gateway/**'
|
|
||||||
- 'game/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/ui-test.yaml'
|
- '.gitea/workflows/ui-test.yaml'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
|
|
||||||
|
# Playwright launches its own `pnpm dev` on :5173, and in host-mode
|
||||||
|
# the runner shares the host's port namespace with every other job,
|
||||||
|
# so two parallel ui-test runs collide on EADDRINUSE. Serialise via a
|
||||||
|
# singleton concurrency group with queueing — new runs wait their
|
||||||
|
# turn instead of cancelling the in-progress one. cancel-in-progress
|
||||||
|
# is explicitly false because Gitea has shown spurious self-cancel
|
||||||
|
# behaviour under cancel-in-progress: true even when no other run
|
||||||
|
# shares the group.
|
||||||
|
concurrency:
|
||||||
|
group: ui-test-singleton
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -48,45 +40,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run Go tests
|
|
||||||
# client/ is the deprecated Fyne client; excluded from CI per
|
|
||||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
|
||||||
# run never depends on a previous runner's cached state. The
|
|
||||||
# backend suite is run with -p 1 because most backend packages
|
|
||||||
# spawn their own Postgres testcontainer, and parallel
|
|
||||||
# Postgres bootstraps starve each other on a constrained
|
|
||||||
# runner. pkg modules are listed one by one because ./pkg/...
|
|
||||||
# does not recurse across the independent go.work modules
|
|
||||||
# under pkg/.
|
|
||||||
run: |
|
|
||||||
go test -count=1 -p 1 ./backend/...
|
|
||||||
go test -count=1 \
|
|
||||||
./gateway/... \
|
|
||||||
./game/... \
|
|
||||||
./ui/core/... \
|
|
||||||
./pkg/calc/... \
|
|
||||||
./pkg/connector/... \
|
|
||||||
./pkg/cronutil/... \
|
|
||||||
./pkg/error/... \
|
|
||||||
./pkg/geoip/... \
|
|
||||||
./pkg/model/... \
|
|
||||||
./pkg/postgres/... \
|
|
||||||
./pkg/redisconn/... \
|
|
||||||
./pkg/schema/... \
|
|
||||||
./pkg/storage/... \
|
|
||||||
./pkg/transcoder/... \
|
|
||||||
./pkg/util/...
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
- name: Set up pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 11.0.7
|
version: 11.0.7
|
||||||
|
# Install pnpm into a per-job directory so concurrent jobs on
|
||||||
|
# the shared host runner do not race on the default
|
||||||
|
# `~/setup-pnpm` (the self-installer otherwise fails with
|
||||||
|
# `ENOTEMPTY` while cleaning a sibling job's install).
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -99,18 +61,49 @@ jobs:
|
|||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build core.wasm
|
||||||
|
uses: ./.gitea/actions/build-wasm
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
|
# `--with-deps` would shell out to `sudo apt-get install` for
|
||||||
|
# the system .so libraries, which the host-mode runner cannot
|
||||||
|
# run non-interactively. The host has the deps installed once,
|
||||||
|
# globally; we only need to fetch the browser binaries here.
|
||||||
|
# If a future run fails with missing libraries, install them
|
||||||
|
# on the host via `pnpm exec playwright install-deps` (one
|
||||||
|
# shot, requires sudo).
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install
|
||||||
|
|
||||||
- name: Run Vitest
|
- name: Run Vitest
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Clear stale Vite from :5173
|
||||||
|
# Defence in depth in case a previous job's webServer survived
|
||||||
|
# the concurrency-cancel — `pkill` does not fail when there is
|
||||||
|
# nothing to kill, and `fuser -k` cleans up anything else
|
||||||
|
# holding the port.
|
||||||
|
run: |
|
||||||
|
pkill -f 'vite dev' || true
|
||||||
|
fuser -k 5173/tcp 2>/dev/null || true
|
||||||
|
|
||||||
- name: Run Playwright
|
- name: Run Playwright
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
run: pnpm exec playwright test
|
run: pnpm exec playwright test
|
||||||
|
|
||||||
|
- name: Run PWA tests
|
||||||
|
# Builds + previews the production bundle (the service worker only
|
||||||
|
# precaches a real build) and checks manifest / SW / offline.
|
||||||
|
working-directory: ui/frontend
|
||||||
|
run: pnpm test:pwa
|
||||||
|
|
||||||
- name: Upload Playwright report on failure
|
- name: Upload Playwright report on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -34,32 +34,57 @@ This repository hosts the Galaxy Game project.
|
|||||||
deeper than what fits in `README.md` (per-feature design notes,
|
deeper than what fits in `README.md` (per-feature design notes,
|
||||||
protocol specs, runbooks). Not stage-by-stage history.
|
protocol specs, runbooks). Not stage-by-stage history.
|
||||||
|
|
||||||
|
## Branching and CI flow
|
||||||
|
|
||||||
|
Branches:
|
||||||
|
|
||||||
|
- `main` — production-track. Direct pushes are disallowed; the only
|
||||||
|
way in is a PR merge from `development`. A merge fires
|
||||||
|
`prod-build.yaml` which packages the artifacts; production rollout
|
||||||
|
is manual through `deploy-prod.yaml`.
|
||||||
|
- `development` — long-lived dev integration branch. Every merge into
|
||||||
|
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
||||||
|
(reachable at `https://www.galaxy.lan` / `https://api.galaxy.lan`).
|
||||||
|
- `feature/*` — short-lived branches off `development`. Merged back
|
||||||
|
via PR; only then do they reach the dev environment automatically.
|
||||||
|
|
||||||
|
Workflows in `.gitea/workflows/`:
|
||||||
|
|
||||||
|
| File | Trigger | What it does |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||||
|
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||||
|
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
||||||
|
| `dev-deploy.yaml` | push to `development`; `workflow_dispatch` on any ref | Build images + (re)deploy to `tools/dev-deploy/`. |
|
||||||
|
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
|
||||||
|
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
|
||||||
|
|
||||||
|
### Deployment cadence
|
||||||
|
|
||||||
|
The long-lived dev environment (`tools/dev-deploy/`) is single-tenant:
|
||||||
|
one live deployment, redeployed on every merge into `development`.
|
||||||
|
While a PR is open the dev environment stays on whatever was last
|
||||||
|
merged — pushes to `feature/*` only fire the test workflows
|
||||||
|
(`go-unit`, `ui-test`, `integration`), not `dev-deploy.yaml`.
|
||||||
|
|
||||||
|
To preview an unmerged feature branch on the shared dev environment,
|
||||||
|
trigger `dev-deploy.yaml` manually from the Gitea UI
|
||||||
|
(**Actions → Deploy · Dev → Run workflow**) and pick the feature ref.
|
||||||
|
The deploy is idempotent: the next merge into `development` simply
|
||||||
|
overwrites whatever the manual dispatch left behind.
|
||||||
|
|
||||||
## Per-stage CI gate
|
## Per-stage CI gate
|
||||||
|
|
||||||
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
|
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
|
||||||
must be exercised on the local Gitea Actions runner before being
|
must be exercised on `gitea.lan` before being declared done. The
|
||||||
declared done. The runbook lives in `tools/local-ci/README.md`; the
|
short version:
|
||||||
short version is:
|
|
||||||
|
|
||||||
1. Commit the stage changes.
|
1. Commit the stage changes on the feature branch.
|
||||||
2. `make -C tools/local-ci push` — pushes `HEAD` to the local Gitea
|
2. `git push gitea …` to publish the branch.
|
||||||
instance and triggers every workflow that matches the changed
|
3. Poll the latest run in the Gitea UI (or the API) until it leaves
|
||||||
paths.
|
|
||||||
3. Poll the latest run via the API snippet in `ui/docs/testing.md`
|
|
||||||
(or the Gitea UI on `http://localhost:3000`) until it leaves
|
|
||||||
`running`. Inspect the log on failure.
|
`running`. Inspect the log on failure.
|
||||||
4. Only after the run is `success` may the stage be marked done in
|
4. Only after every workflow that fired is `success` may the stage be
|
||||||
the corresponding `PLAN.md`.
|
marked done in the corresponding `PLAN.md`.
|
||||||
|
|
||||||
This applies even when the local unit-test suite is green —
|
|
||||||
workflow-only failures (path filters, action-version mismatches,
|
|
||||||
missing secrets, runner-only environment differences) are cheap to
|
|
||||||
catch here and expensive to catch on a remote PR. The push step is
|
|
||||||
implicitly authorised: do not ask for confirmation on every stage.
|
|
||||||
|
|
||||||
If `tools/local-ci` is not running, bring it up first
|
|
||||||
(`make -C tools/local-ci up`); do not skip this gate. The single
|
|
||||||
exception is when the user explicitly waives it for a stage.
|
|
||||||
|
|
||||||
## Decisions during stage implementation
|
## Decisions during stage implementation
|
||||||
|
|
||||||
@@ -87,18 +112,22 @@ The existing codebase of `galaxy/<service>` may be modified or extended when a
|
|||||||
plan stage requires it. All such changes must be covered by new or updated tests
|
plan stage requires it. All such changes must be covered by new or updated tests
|
||||||
and reflected in documentation when they affect documented behavior.
|
and reflected in documentation when they affect documented behavior.
|
||||||
|
|
||||||
## Pre-production migration rule
|
## Migrations
|
||||||
|
|
||||||
The platform is not yet in production. Schema changes for `backend` go
|
Schema changes for `backend` go into a new `0000N_*.sql` file under
|
||||||
into the existing `backend/internal/postgres/migrations/00001_init.sql`
|
`backend/internal/postgres/migrations/` with a monotonically increasing
|
||||||
file rather than into new `00002_*`-prefixed files. Local databases and
|
prefix. `00001_init.sql` is the historical baseline and stays
|
||||||
integration test harnesses are recreated from scratch on every pull.
|
immutable; every subsequent change is its own additive migration with
|
||||||
|
matching Up/Down sides. `pressly/goose/v3` (embedded into the backend
|
||||||
|
binary) applies pending migrations on startup, so the long-lived dev
|
||||||
|
environment picks up schema deltas without a manual reset.
|
||||||
|
|
||||||
**This rule is removed before the first production deployment.** From
|
Before the first production deployment the migration chain may be
|
||||||
that point on every schema change becomes a new migration file with a
|
squashed back into a single fresh `00001_init.sql` for a clean slate;
|
||||||
monotonically increasing prefix, and `00001_init.sql` becomes immutable
|
plan that work as an explicit task when it lands. See
|
||||||
history. See `backend/internal/postgres/migrations/README.md` for
|
`backend/internal/postgres/migrations/README.md` for the local
|
||||||
details.
|
authoring conventions (file naming, transactional vs. non-transactional
|
||||||
|
sections, backward-compatible deletes, rollback expectations).
|
||||||
|
|
||||||
## Documentation discipline
|
## Documentation discipline
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -45,6 +45,7 @@ backend/
|
|||||||
│ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations
|
│ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations
|
||||||
│ ├── auth/ # email-code challenges, device sessions, Ed25519 keys
|
│ ├── auth/ # email-code challenges, device sessions, Ed25519 keys
|
||||||
│ ├── config/ # env-var loader, Validate
|
│ ├── config/ # env-var loader, Validate
|
||||||
|
│ ├── diplomail/ # diplomatic-mail messages, recipients, translations
|
||||||
│ ├── dockerclient/ # docker/docker wrapper for container ops
|
│ ├── dockerclient/ # docker/docker wrapper for container ops
|
||||||
│ ├── engineclient/ # net/http client to galaxy-game containers
|
│ ├── engineclient/ # net/http client to galaxy-game containers
|
||||||
│ ├── geo/ # geoip lookup, declared_country, per-user counters
|
│ ├── geo/ # geoip lookup, declared_country, per-user counters
|
||||||
@@ -128,9 +129,16 @@ fast.
|
|||||||
| `BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT` | no | `256` | Engine container `--pids-limit`. |
|
| `BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT` | no | `256` | Engine container `--pids-limit`. |
|
||||||
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
|
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
|
||||||
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
|
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
|
||||||
|
| `BACKEND_STACK_LABEL` | no | — | Optional value stamped as `galaxy.stack=<value>` on every engine container backend spawns. Lets host-side tooling (Makefile / CI) scope cleanup to one dev stack. Empty → label is not applied. |
|
||||||
| `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_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_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. |
|
||||||
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
|
| `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. |
|
||||||
|
| `BACKEND_DIPLOMAIL_TRANSLATOR_URL` | no | — | Base URL of a LibreTranslate-compatible instance (`http://libretranslate:5000`). Empty → translator falls through to no-op (recipients are delivered with the original body). |
|
||||||
|
| `BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT` | no | `10s` | Per-request HTTP timeout for the translation worker. |
|
||||||
|
| `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` | no | `5` | Number of failed HTTP attempts before the worker delivers the message with the original body (fallback). |
|
||||||
|
| `BACKEND_DIPLOMAIL_WORKER_INTERVAL` | no | `2s` | How often the async translation worker scans for pending pairs. The worker processes one pair per tick. |
|
||||||
|
|
||||||
If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without
|
If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without
|
||||||
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
|
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
|
||||||
@@ -146,10 +154,10 @@ seeded `admin_accounts` ahead of time.
|
|||||||
before the HTTP listener opens. The startup path also issues a
|
before the HTTP listener opens. The startup path also issues a
|
||||||
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
|
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
|
||||||
trip goose's bookkeeping table on the first migration.
|
trip goose's bookkeeping table on the first migration.
|
||||||
- Pre-production uses one migration file (`00001_init.sql`) covering
|
- Migrations are sequence-numbered (`0000N_*.sql`) and applied
|
||||||
every backend domain (auth, user, admin, lobby, runtime, mail,
|
additively. `00001_init.sql` is the historical baseline; every
|
||||||
notification, geo). Future migrations are sequence-numbered and
|
schema change after it is a new file with a higher prefix. See
|
||||||
additive.
|
`internal/postgres/migrations/README.md` for the authoring rules.
|
||||||
- Queries are written through `go-jet/jet/v2`. The generated code is in
|
- Queries are written through `go-jet/jet/v2`. The generated code is in
|
||||||
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
|
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
|
||||||
carries package metadata that survives regeneration.
|
carries package metadata that survives regeneration.
|
||||||
|
|||||||
+315
-1
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
// time/tzdata embeds the IANA timezone database so time.LoadLocation
|
// time/tzdata embeds the IANA timezone database so time.LoadLocation
|
||||||
// works in container images without /usr/share/zoneinfo (distroless
|
// works in container images without /usr/share/zoneinfo (distroless
|
||||||
@@ -25,6 +26,9 @@ import (
|
|||||||
"galaxy/backend/internal/auth"
|
"galaxy/backend/internal/auth"
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
"galaxy/backend/internal/devsandbox"
|
"galaxy/backend/internal/devsandbox"
|
||||||
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/diplomail/detector"
|
||||||
|
"galaxy/backend/internal/diplomail/translator"
|
||||||
"galaxy/backend/internal/dockerclient"
|
"galaxy/backend/internal/dockerclient"
|
||||||
"galaxy/backend/internal/engineclient"
|
"galaxy/backend/internal/engineclient"
|
||||||
"galaxy/backend/internal/geo"
|
"galaxy/backend/internal/geo"
|
||||||
@@ -131,6 +135,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
lobbyCascade := &lobbyCascadeAdapter{}
|
lobbyCascade := &lobbyCascadeAdapter{}
|
||||||
userNotifyCascade := &userNotificationCascadeAdapter{}
|
userNotifyCascade := &userNotificationCascadeAdapter{}
|
||||||
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
|
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
|
||||||
|
lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{}
|
||||||
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
|
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
|
||||||
|
|
||||||
userSvc := user.NewService(user.Deps{
|
userSvc := user.NewService(user.Deps{
|
||||||
@@ -197,6 +202,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
Cache: lobbyCache,
|
Cache: lobbyCache,
|
||||||
Runtime: runtimeGateway,
|
Runtime: runtimeGateway,
|
||||||
Notification: lobbyNotifyPublisher,
|
Notification: lobbyNotifyPublisher,
|
||||||
|
Diplomail: lobbyDiplomailPublisher,
|
||||||
Entitlement: &userEntitlementAdapter{svc: userSvc},
|
Entitlement: &userEntitlementAdapter{svc: userSvc},
|
||||||
Config: cfg.Lobby,
|
Config: cfg.Lobby,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
@@ -301,6 +307,25 @@ func run(ctx context.Context) (err error) {
|
|||||||
userNotifyCascade.svc = notifSvc
|
userNotifyCascade.svc = notifSvc
|
||||||
lobbyNotifyPublisher.svc = notifSvc
|
lobbyNotifyPublisher.svc = notifSvc
|
||||||
runtimeNotifyPublisher.svc = notifSvc
|
runtimeNotifyPublisher.svc = notifSvc
|
||||||
|
|
||||||
|
diplomailStore := diplomail.NewStore(db)
|
||||||
|
diplomailTranslator, err := buildDiplomailTranslator(cfg.Diplomail, logger)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build diplomail translator: %w", err)
|
||||||
|
}
|
||||||
|
diplomailSvc := diplomail.NewService(diplomail.Deps{
|
||||||
|
Store: diplomailStore,
|
||||||
|
Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc},
|
||||||
|
Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc},
|
||||||
|
Entitlements: &diplomailEntitlementAdapter{users: userSvc},
|
||||||
|
Games: &diplomailGameAdapter{lobby: lobbySvc},
|
||||||
|
Detector: detector.New(),
|
||||||
|
Translator: diplomailTranslator,
|
||||||
|
Config: cfg.Diplomail,
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
lobbyDiplomailPublisher.svc = diplomailSvc
|
||||||
|
diplomailWorker := diplomail.NewWorker(diplomailSvc)
|
||||||
if email := cfg.Notification.AdminEmail; email == "" {
|
if email := cfg.Notification.AdminEmail; email == "" {
|
||||||
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
||||||
} else {
|
} else {
|
||||||
@@ -325,9 +350,11 @@ func run(ctx context.Context) (err error) {
|
|||||||
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
|
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
|
||||||
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
|
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
|
||||||
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
|
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
|
||||||
|
adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger)
|
||||||
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
|
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
|
||||||
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
|
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
|
||||||
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
|
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
|
||||||
|
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger)
|
||||||
|
|
||||||
ready := func() bool {
|
ready := func() bool {
|
||||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||||
@@ -356,9 +383,11 @@ func run(ctx context.Context) (err error) {
|
|||||||
AdminRuntimes: adminRuntimesHandlers,
|
AdminRuntimes: adminRuntimesHandlers,
|
||||||
AdminEngineVersions: adminEngineVersionsHandlers,
|
AdminEngineVersions: adminEngineVersionsHandlers,
|
||||||
AdminMail: adminMailHandlers,
|
AdminMail: adminMailHandlers,
|
||||||
|
AdminDiplomail: adminDiplomailHandlers,
|
||||||
AdminNotifications: adminNotificationsHandlers,
|
AdminNotifications: adminNotificationsHandlers,
|
||||||
AdminGeo: adminGeoHandlers,
|
AdminGeo: adminGeoHandlers,
|
||||||
UserGames: userGamesHandlers,
|
UserGames: userGamesHandlers,
|
||||||
|
UserMail: userMailHandlers,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build backend router: %w", err)
|
return fmt.Errorf("build backend router: %w", err)
|
||||||
@@ -374,7 +403,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
runtimeScheduler := runtimeSvc.SchedulerComponent()
|
runtimeScheduler := runtimeSvc.SchedulerComponent()
|
||||||
runtimeReconciler := runtimeSvc.Reconciler()
|
runtimeReconciler := runtimeSvc.Reconciler()
|
||||||
|
|
||||||
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
|
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, diplomailWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
|
||||||
if metricsServer.Enabled() {
|
if metricsServer.Enabled() {
|
||||||
components = append(components, metricsServer)
|
components = append(components, metricsServer)
|
||||||
}
|
}
|
||||||
@@ -579,3 +608,288 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co
|
|||||||
}
|
}
|
||||||
return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload)
|
return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diplomailMembershipAdapter implements `diplomail.MembershipLookup`
|
||||||
|
// by walking the lobby cache (for active rows) and the lobby service
|
||||||
|
// (for any-status rows) and stitching each membership row to the
|
||||||
|
// immutable `accounts.user_name` resolved 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,
|
||||||
|
PreferredLanguage: account.PreferredLanguage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil || a.users == nil {
|
||||||
|
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||||
|
if !ok {
|
||||||
|
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return diplomail.MemberSnapshot{}, err
|
||||||
|
}
|
||||||
|
var found *lobby.Membership
|
||||||
|
for _, m := range members {
|
||||||
|
if m.UserID == userID {
|
||||||
|
mm := m
|
||||||
|
found = &mm
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
account, err := a.users.GetAccount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return diplomail.MemberSnapshot{}, err
|
||||||
|
}
|
||||||
|
return diplomail.MemberSnapshot{
|
||||||
|
UserID: userID,
|
||||||
|
GameID: gameID,
|
||||||
|
GameName: game.GameName,
|
||||||
|
UserName: account.UserName,
|
||||||
|
RaceName: found.RaceName,
|
||||||
|
Status: found.Status,
|
||||||
|
PreferredLanguage: account.PreferredLanguage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil || a.users == nil {
|
||||||
|
return nil, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||||
|
if !ok {
|
||||||
|
return nil, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
matches := func(status string) bool {
|
||||||
|
switch scope {
|
||||||
|
case diplomail.RecipientScopeActive:
|
||||||
|
return status == lobby.MembershipStatusActive
|
||||||
|
case diplomail.RecipientScopeActiveAndRemoved:
|
||||||
|
return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved
|
||||||
|
case diplomail.RecipientScopeAllMembers:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return status == lobby.MembershipStatusActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]diplomail.MemberSnapshot, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
if !matches(m.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
account, err := a.users.GetAccount(ctx, m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err)
|
||||||
|
}
|
||||||
|
out = append(out, diplomail.MemberSnapshot{
|
||||||
|
UserID: m.UserID,
|
||||||
|
GameID: gameID,
|
||||||
|
GameName: game.GameName,
|
||||||
|
UserName: account.UserName,
|
||||||
|
RaceName: m.RaceName,
|
||||||
|
Status: m.Status,
|
||||||
|
PreferredLanguage: account.PreferredLanguage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher`
|
||||||
|
// by translating each lobby.LifecycleEvent into the diplomail
|
||||||
|
// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`.
|
||||||
|
// The svc pointer is patched once diplomailSvc exists — diplomail
|
||||||
|
// depends on lobby through MembershipLookup, so the lobby service
|
||||||
|
// is constructed first and patched up.
|
||||||
|
type lobbyDiplomailPublisherAdapter struct {
|
||||||
|
svc *diplomail.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error {
|
||||||
|
if a == nil || a.svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
|
||||||
|
GameID: ev.GameID,
|
||||||
|
Kind: ev.Kind,
|
||||||
|
Actor: ev.Actor,
|
||||||
|
Reason: ev.Reason,
|
||||||
|
TargetUser: ev.TargetUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiplomailTranslator selects the diplomail translator backend
|
||||||
|
// from configuration: a non-empty `TranslatorURL` constructs the
|
||||||
|
// LibreTranslate HTTP client; an empty URL falls through to the
|
||||||
|
// noop translator so deployments without a translation service still
|
||||||
|
// boot and deliver mail with the fallback path.
|
||||||
|
func buildDiplomailTranslator(cfg config.DiplomailConfig, logger *zap.Logger) (translator.Translator, error) {
|
||||||
|
if cfg.TranslatorURL == "" {
|
||||||
|
logger.Info("diplomail translator URL not configured, using noop translator")
|
||||||
|
return translator.NewNoop(), nil
|
||||||
|
}
|
||||||
|
return translator.NewLibreTranslate(translator.LibreTranslateConfig{
|
||||||
|
URL: cfg.TranslatorURL,
|
||||||
|
Timeout: cfg.TranslatorTimeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// diplomailEntitlementAdapter implements
|
||||||
|
// `diplomail.EntitlementReader` by reading the user-service
|
||||||
|
// entitlement snapshot. The IsPaid flag mirrors the per-tier policy
|
||||||
|
// defined in `internal/user`, so updates to the tier set (monthly,
|
||||||
|
// yearly, permanent, …) flow through without changes here.
|
||||||
|
type diplomailEntitlementAdapter struct {
|
||||||
|
users *user.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailEntitlementAdapter) IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||||
|
if a == nil || a.users == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
snap, err := a.users.GetEntitlementSnapshot(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return snap.IsPaid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diplomailGameAdapter implements `diplomail.GameLookup`. The
|
||||||
|
// running-games and finished-games queries walk the lobby cache so
|
||||||
|
// the admin multi-game broadcast and bulk-purge endpoints do not
|
||||||
|
// fan out a per-game DB query each time. GetGame falls back to the
|
||||||
|
// cache; an unknown id is surfaced as ErrNotFound (the diplomail
|
||||||
|
// sentinel).
|
||||||
|
type diplomailGameAdapter struct {
|
||||||
|
lobby *lobby.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailGameAdapter) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []diplomail.GameSnapshot
|
||||||
|
for _, game := range a.lobby.Cache().ListGames() {
|
||||||
|
if !isRunningStatus(game.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, gameSnapshot(game))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailGameAdapter) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
games, err := a.lobby.ListFinishedGamesBefore(ctx, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]diplomail.GameSnapshot, 0, len(games))
|
||||||
|
for _, g := range games {
|
||||||
|
out = append(out, gameSnapshot(g))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailGameAdapter) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
|
||||||
|
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||||
|
if !ok {
|
||||||
|
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
return gameSnapshot(game), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameSnapshot(g lobby.GameRecord) diplomail.GameSnapshot {
|
||||||
|
out := diplomail.GameSnapshot{
|
||||||
|
GameID: g.GameID,
|
||||||
|
GameName: g.GameName,
|
||||||
|
Status: g.Status,
|
||||||
|
}
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
f := *g.FinishedAt
|
||||||
|
out.FinishedAt = &f
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunningStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case lobby.GameStatusReadyToStart, lobby.GameStatusStarting, lobby.GameStatusRunning, lobby.GameStatusPaused:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# LibreTranslate setup for diplomatic mail
|
||||||
|
|
||||||
|
This document describes how to run the LibreTranslate backend that the
|
||||||
|
diplomatic-mail subsystem uses for body translation. The instructions
|
||||||
|
target three audiences: developers spinning up LibreTranslate
|
||||||
|
alongside `tools/local-dev`, operators preparing a real deployment,
|
||||||
|
and reviewers verifying the end-to-end translation flow by hand.
|
||||||
|
|
||||||
|
## When you need LibreTranslate
|
||||||
|
|
||||||
|
The diplomatic-mail worker runs unconditionally — `make up` and `make
|
||||||
|
test` both work without any translator. With
|
||||||
|
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator
|
||||||
|
short-circuits the pipeline: messages are delivered in the original
|
||||||
|
language, and the inbox handler returns the original body to every
|
||||||
|
reader.
|
||||||
|
|
||||||
|
You only need LibreTranslate when you want to exercise the cross-
|
||||||
|
language path: sender writes in language X, recipient's
|
||||||
|
`accounts.preferred_language` is Y, the worker is expected to fetch
|
||||||
|
a Y rendering. The pipeline is otherwise identical and unaware of
|
||||||
|
which engine is producing translations.
|
||||||
|
|
||||||
|
## Running a local instance
|
||||||
|
|
||||||
|
LibreTranslate ships a public Docker image at
|
||||||
|
`libretranslate/libretranslate`. The image is ~3 GB on first pull
|
||||||
|
because it bundles every supported language model; subsequent runs
|
||||||
|
reuse the layer cache.
|
||||||
|
|
||||||
|
The simplest setup is a one-shot container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -d --name libretranslate \
|
||||||
|
-p 5000:5000 \
|
||||||
|
-e LT_LOAD_ONLY=en,ru \
|
||||||
|
libretranslate/libretranslate:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The `LT_LOAD_ONLY` whitelist trims the loaded model set so the
|
||||||
|
container fits in ~600 MB of RAM. Drop the variable to load every
|
||||||
|
language pair LibreTranslate ships.
|
||||||
|
|
||||||
|
LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm
|
||||||
|
model cache). Wait until `curl -s http://localhost:5000/languages`
|
||||||
|
returns a JSON array before pointing backend at it.
|
||||||
|
|
||||||
|
## Wiring backend at it
|
||||||
|
|
||||||
|
Add three env vars to the backend process:
|
||||||
|
|
||||||
|
```
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
When backend lives inside the `tools/local-dev` Docker network and
|
||||||
|
LibreTranslate runs on the host, replace `localhost` with the host's
|
||||||
|
docker-bridge address (`http://host.docker.internal:5000` on
|
||||||
|
Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by
|
||||||
|
default).
|
||||||
|
|
||||||
|
For a stack-internal deployment, drop LibreTranslate into the same
|
||||||
|
Docker compose file alongside backend and reach it by its service
|
||||||
|
name:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
libretranslate:
|
||||||
|
image: libretranslate/libretranslate:latest
|
||||||
|
environment:
|
||||||
|
LT_LOAD_ONLY: "en,ru"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000"
|
||||||
|
depends_on:
|
||||||
|
libretranslate:
|
||||||
|
condition: service_healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual smoke test
|
||||||
|
|
||||||
|
Once both services are up:
|
||||||
|
|
||||||
|
1. Register two accounts via the public auth flow. Set the second
|
||||||
|
account's `preferred_language` to a value that differs from the
|
||||||
|
sender's writing language (e.g. sender writes in English, second
|
||||||
|
account is `ru`).
|
||||||
|
2. Create a private game with the first account, invite the second,
|
||||||
|
land both as active members.
|
||||||
|
3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages`
|
||||||
|
with the body in English.
|
||||||
|
4. Watch backend logs for the diplomail worker. After ~2 seconds you
|
||||||
|
should see `translator attempt succeeded` (or equivalent INFO
|
||||||
|
line) and the recipient flipped to `available_at`.
|
||||||
|
5. As the second account, fetch
|
||||||
|
`GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The
|
||||||
|
response should carry both `body` (English original) and
|
||||||
|
`translated_body` (Russian) along with the `translation_lang`
|
||||||
|
and `translator` fields.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks
|
||||||
|
around 800 MB resident; with all languages, ~3 GB. Plan accordingly.
|
||||||
|
- **CPU.** LibreTranslate is CPU-bound. One translation of a 200-
|
||||||
|
word body takes ~200 ms on a modern x86 core; the diplomail worker
|
||||||
|
is single-threaded by design, so steady-state throughput is
|
||||||
|
`1 / avg_latency` per backend instance.
|
||||||
|
- **Outage behaviour.** A LibreTranslate outage stalls delivery of
|
||||||
|
pending pairs by at most ~31 seconds per pair (the worker's
|
||||||
|
exponential backoff schedule), then falls back to the original
|
||||||
|
body. Inbox listings never depend on the translator's
|
||||||
|
availability.
|
||||||
|
- **API key.** Backend does not send an API key. Self-hosted
|
||||||
|
deployments without `LT_API_KEYS` configured accept anonymous
|
||||||
|
POSTs by default, which matches our deployment posture
|
||||||
|
(LibreTranslate sits on the internal docker network, not
|
||||||
|
reachable from outside).
|
||||||
|
- **Models.** Adding a new target language is an operator-side
|
||||||
|
task: install the corresponding Argos model into the
|
||||||
|
LibreTranslate container (`argospm install …`) and either restart
|
||||||
|
the container or send a SIGHUP. The diplomail pipeline notices
|
||||||
|
the new language pair automatically — there is no allow-list
|
||||||
|
inside backend.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`translator: do request: dial tcp ...: connect: connection refused`.**
|
||||||
|
LibreTranslate is not listening on the configured address. Verify
|
||||||
|
with `curl http://${URL}/languages`. On Docker setups, double-
|
||||||
|
check the bridge address discussion above.
|
||||||
|
- **`translator: libretranslate http 400`** in worker logs but the
|
||||||
|
language pair clearly exists.
|
||||||
|
Make sure the request used the two-letter codes (`en`, not
|
||||||
|
`en-US`). Backend normalises before sending; if you see a region
|
||||||
|
subtag in the log, file an issue against `internal/diplomail` —
|
||||||
|
the normalisation should be unconditional.
|
||||||
|
- **`translator: libretranslate http 503`.**
|
||||||
|
Container is still loading models. Wait for `/languages` to
|
||||||
|
respond `200`. The worker retries with backoff, so steady-state
|
||||||
|
recovers automatically.
|
||||||
|
- **Worker logs only "noop translator returned, delivering
|
||||||
|
fallback".**
|
||||||
|
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend
|
||||||
|
process. Confirm with `docker compose exec backend env | grep
|
||||||
|
DIPLOMAIL`.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
- Adding an OpenTelemetry counter and histogram for translator
|
||||||
|
outcomes is tracked in the diplomail package README; the metrics
|
||||||
|
will surface in Grafana once LibreTranslate is deployed.
|
||||||
|
- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures
|
||||||
|
in M minutes) is planned through a new
|
||||||
|
`diplomail.translator.unhealthy` notification kind. Not wired
|
||||||
|
yet — current monitoring lives in zap logs.
|
||||||
@@ -28,10 +28,11 @@ test stack. The list mirrors the steady-state behaviour documented in
|
|||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
`pressly/goose/v3` applies embedded migrations from
|
`pressly/goose/v3` applies embedded migrations from
|
||||||
`internal/postgres/migrations/`. The pre-production set ships as
|
`internal/postgres/migrations/`. Migrations are additive,
|
||||||
`00001_init.sql` plus additive numbered files. Backend always runs
|
sequence-numbered files (`00001_init.sql` is the baseline). Backend
|
||||||
`CREATE SCHEMA IF NOT EXISTS backend` before goose so a fresh database
|
always runs `CREATE SCHEMA IF NOT EXISTS backend` before goose so a
|
||||||
does not trip the bookkeeping table on the first migration.
|
fresh database does not trip the bookkeeping table on the first
|
||||||
|
migration.
|
||||||
|
|
||||||
`internal/postgres/migrations_test.go` asserts that the migration
|
`internal/postgres/migrations_test.go` asserts that the migration
|
||||||
produces the expected table set; adding a table without updating the
|
produces the expected table set; adding a table without updating the
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/abadojack/whatlanggo v1.0.1 // indirect
|
||||||
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
|
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
|
||||||
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
|
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
|
||||||
|
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||||
|
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
|
|||||||
@@ -513,6 +513,52 @@ func TestConfirmEmailCodeWrongCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling proves the
|
||||||
|
// dev-mode override is a true escape hatch: a developer who already
|
||||||
|
// burned past ChallengeMaxAttempts on a long-lived dev challenge
|
||||||
|
// (typically because the throttle merged repeated send-email-code
|
||||||
|
// calls onto one challenge_id) can still recover by submitting the
|
||||||
|
// fixed code without first waiting out the challenge TTL.
|
||||||
|
func TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
cfg := authConfig()
|
||||||
|
cfg.DevFixedCode = "999999"
|
||||||
|
svc := buildServiceWithConfig(t, db, cfg)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := svc.SendEmailCode(ctx, "dev-bypass-ceiling@example.test", "en", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burn through the attempts ceiling with deliberately wrong codes.
|
||||||
|
for i := range cfg.ChallengeMaxAttempts + 1 {
|
||||||
|
_, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
||||||
|
ChallengeID: id,
|
||||||
|
Code: "111111",
|
||||||
|
ClientPublicKey: randomKey(t),
|
||||||
|
TimeZone: "UTC",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("attempt %d unexpectedly succeeded", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dev-fixed code still goes through.
|
||||||
|
session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
||||||
|
ChallengeID: id,
|
||||||
|
Code: "999999",
|
||||||
|
ClientPublicKey: randomKey(t),
|
||||||
|
TimeZone: "UTC",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dev-fixed-code after attempts exhausted: %v", err)
|
||||||
|
}
|
||||||
|
if session.DeviceSessionID == uuid.Nil {
|
||||||
|
t.Fatalf("dev-fixed-code did not produce a session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
|
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
svc, mailer, _, _ := buildService(t, db)
|
svc, mailer, _, _ := buildService(t, db)
|
||||||
|
|||||||
@@ -163,15 +163,28 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
|||||||
return Session{}, err
|
return Session{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
// The dev-mode fixed-code override is checked first so it bypasses
|
||||||
s.deps.Logger.Info("auth challenge attempts exhausted",
|
// both the bcrypt verify and the per-challenge attempts ceiling.
|
||||||
|
// Without this, a developer who already burned through
|
||||||
|
// `ChallengeMaxAttempts` on an existing un-consumed challenge —
|
||||||
|
// for example after the throttle merged repeated send-email-code
|
||||||
|
// calls onto one challenge_id — could not recover with the fixed
|
||||||
|
// code either, defeating the purpose of the override. Production
|
||||||
|
// deployments leave `DevFixedCode` empty, so this branch is
|
||||||
|
// inert and the regular attempts gate still applies.
|
||||||
|
if s.devFixedCodeMatches(in.Code) {
|
||||||
|
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
|
||||||
zap.String("challenge_id", in.ChallengeID.String()),
|
zap.String("challenge_id", in.ChallengeID.String()),
|
||||||
zap.Int32("attempts", loaded.Attempts),
|
zap.Int32("attempts", loaded.Attempts),
|
||||||
)
|
)
|
||||||
return Session{}, ErrTooManyAttempts
|
} else {
|
||||||
}
|
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
||||||
|
s.deps.Logger.Info("auth challenge attempts exhausted",
|
||||||
if !s.devFixedCodeMatches(in.Code) {
|
zap.String("challenge_id", in.ChallengeID.String()),
|
||||||
|
zap.Int32("attempts", loaded.Attempts),
|
||||||
|
)
|
||||||
|
return Session{}, ErrTooManyAttempts
|
||||||
|
}
|
||||||
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
|
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
|
||||||
if errors.Is(err, ErrCodeMismatch) {
|
if errors.Is(err, ErrCodeMismatch) {
|
||||||
s.deps.Logger.Info("auth challenge code mismatch",
|
s.deps.Logger.Info("auth challenge code mismatch",
|
||||||
@@ -182,10 +195,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
|||||||
}
|
}
|
||||||
return Session{}, err
|
return Session{}, err
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
|
|
||||||
zap.String("challenge_id", in.ChallengeID.String()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-check permanent_block after verifying the code. SendEmailCode
|
// Re-check permanent_block after verifying the code. SendEmailCode
|
||||||
|
|||||||
@@ -91,11 +91,19 @@ const (
|
|||||||
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
||||||
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
||||||
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
||||||
|
envRuntimeStackLabel = "BACKEND_STACK_LABEL"
|
||||||
|
|
||||||
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
||||||
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
||||||
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
||||||
|
|
||||||
|
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
|
||||||
|
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
|
||||||
|
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
|
||||||
|
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||||
|
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||||
|
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||||
|
|
||||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
||||||
@@ -163,6 +171,12 @@ const (
|
|||||||
defaultNotificationWorkerInterval = 5 * time.Second
|
defaultNotificationWorkerInterval = 5 * time.Second
|
||||||
defaultNotificationMaxAttempts = 8
|
defaultNotificationMaxAttempts = 8
|
||||||
|
|
||||||
|
defaultDiplomailMaxBodyBytes = 4096
|
||||||
|
defaultDiplomailMaxSubjectBytes = 256
|
||||||
|
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||||
|
defaultDiplomailTranslatorMaxAttempts = 5
|
||||||
|
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||||
|
|
||||||
defaultDevSandboxEngineVersion = "0.1.0"
|
defaultDevSandboxEngineVersion = "0.1.0"
|
||||||
defaultDevSandboxPlayerCount = 20
|
defaultDevSandboxPlayerCount = 20
|
||||||
)
|
)
|
||||||
@@ -201,6 +215,7 @@ type Config struct {
|
|||||||
Engine EngineConfig
|
Engine EngineConfig
|
||||||
Runtime RuntimeConfig
|
Runtime RuntimeConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
|
Diplomail DiplomailConfig
|
||||||
DevSandbox DevSandboxConfig
|
DevSandbox DevSandboxConfig
|
||||||
|
|
||||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||||
@@ -395,6 +410,50 @@ type RuntimeConfig struct {
|
|||||||
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
||||||
// applied during stop / cancel / restart / patch.
|
// applied during stop / cancel / restart / patch.
|
||||||
StopGracePeriod time.Duration
|
StopGracePeriod time.Duration
|
||||||
|
|
||||||
|
// StackLabel is the optional value backend stamps as
|
||||||
|
// `galaxy.stack=<value>` on every engine container it spawns. It
|
||||||
|
// lets host-side tooling (Makefile, CI workflows) scope cleanup
|
||||||
|
// operations to a single dev stack without touching unrelated
|
||||||
|
// workloads on the same Docker daemon. When empty, the label is
|
||||||
|
// not applied.
|
||||||
|
StackLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// TranslatorURL is the base URL of the LibreTranslate-compatible
|
||||||
|
// instance the async translation worker calls. When empty, the
|
||||||
|
// worker still runs but falls through to "deliver original"
|
||||||
|
// (the noop translator returns engine=noop).
|
||||||
|
TranslatorURL string
|
||||||
|
|
||||||
|
// TranslatorTimeout bounds a single HTTP request to the
|
||||||
|
// translator. Worker retries (exponential backoff up to
|
||||||
|
// TranslatorMaxAttempts) layer on top.
|
||||||
|
TranslatorTimeout time.Duration
|
||||||
|
|
||||||
|
// TranslatorMaxAttempts is the number of times the worker tries
|
||||||
|
// to translate one (message, target_lang) pair before falling
|
||||||
|
// back to delivering the original body.
|
||||||
|
TranslatorMaxAttempts int
|
||||||
|
|
||||||
|
// WorkerInterval bounds how often the async translation worker
|
||||||
|
// scans for pending pairs. The worker handles one pair per tick.
|
||||||
|
WorkerInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationConfig configures the notification fan-out module
|
// NotificationConfig configures the notification fan-out module
|
||||||
@@ -494,6 +553,13 @@ func DefaultConfig() Config {
|
|||||||
WorkerInterval: defaultNotificationWorkerInterval,
|
WorkerInterval: defaultNotificationWorkerInterval,
|
||||||
MaxAttempts: defaultNotificationMaxAttempts,
|
MaxAttempts: defaultNotificationMaxAttempts,
|
||||||
},
|
},
|
||||||
|
Diplomail: DiplomailConfig{
|
||||||
|
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
|
||||||
|
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
|
||||||
|
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
|
||||||
|
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||||
|
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||||
|
},
|
||||||
DevSandbox: DevSandboxConfig{
|
DevSandbox: DevSandboxConfig{
|
||||||
EngineVersion: defaultDevSandboxEngineVersion,
|
EngineVersion: defaultDevSandboxEngineVersion,
|
||||||
PlayerCount: defaultDevSandboxPlayerCount,
|
PlayerCount: defaultDevSandboxPlayerCount,
|
||||||
@@ -648,6 +714,7 @@ func LoadFromEnv() (Config, error) {
|
|||||||
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
cfg.Runtime.StackLabel = strings.TrimSpace(loadString(envRuntimeStackLabel, cfg.Runtime.StackLabel))
|
||||||
|
|
||||||
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
||||||
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
||||||
@@ -657,6 +724,23 @@ func LoadFromEnv() (Config, error) {
|
|||||||
return Config{}, err
|
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.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
|
||||||
|
if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
||||||
@@ -853,6 +937,22 @@ func (c Config) Validate() error {
|
|||||||
if c.Notification.MaxAttempts <= 0 {
|
if c.Notification.MaxAttempts <= 0 {
|
||||||
return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
|
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 c.Diplomail.TranslatorTimeout <= 0 {
|
||||||
|
return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout)
|
||||||
|
}
|
||||||
|
if c.Diplomail.TranslatorMaxAttempts <= 0 {
|
||||||
|
return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts)
|
||||||
|
}
|
||||||
|
if c.Diplomail.WorkerInterval <= 0 {
|
||||||
|
return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval)
|
||||||
|
}
|
||||||
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
|
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
|
||||||
if _, err := netmail.ParseAddress(email); err != nil {
|
if _, err := netmail.ParseAddress(email); err != nil {
|
||||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# 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); strict soft-access for kicked players | shipped |
|
||||||
|
| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability | shipped |
|
||||||
|
| D | Body-language detection (whatlanggo) + translation cache + lazy per-read translator dispatch | shipped |
|
||||||
|
| E | LibreTranslate HTTP client + async translation worker with exponential backoff + delivery gating on translation completion | shipped |
|
||||||
|
|
||||||
|
## 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. The `sender_race_name`
|
||||||
|
column snapshots the sender's race in the game at send time when
|
||||||
|
the sender is a player with an active membership; the in-game UI
|
||||||
|
keys per-race thread grouping on this column.
|
||||||
|
- `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 |
|
||||||
|
| Paid-tier broadcast | paid-tier user | active membership; recipients = every other active member |
|
||||||
|
| Send admin (single user) | game owner OR site admin | recipient is any-status member of the game |
|
||||||
|
| Send admin (broadcast) | game owner OR site admin | recipient scope ∈ `active` / `active_and_removed` / `all_members`; sender excluded |
|
||||||
|
| Multi-game admin broadcast | site admin | scope `selected` (with `game_ids`) or `all_running` |
|
||||||
|
| Bulk purge | site admin | `older_than_years >= 1`; targets games with terminal status finished more than N years ago |
|
||||||
|
| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)`; non-active members see admin-kind only |
|
||||||
|
| 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 D will add body-language detection (whatlanggo) and the
|
||||||
|
translation cache + async worker.
|
||||||
|
|
||||||
|
System mail is produced internally by lobby lifecycle hooks:
|
||||||
|
`Service.transition()` emits `game.paused` / `game.cancelled` system
|
||||||
|
mail to every active member; `Service.changeMembershipStatus` /
|
||||||
|
`Service.AdminBanMember` emit `membership.removed` /
|
||||||
|
`membership.blocked` system mail addressed to the affected user.
|
||||||
|
|
||||||
|
## 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 filled at send time by the configured
|
||||||
|
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
||||||
|
≥ 25 runes; shorter bodies stay `und`).
|
||||||
|
|
||||||
|
## Recipient selection
|
||||||
|
|
||||||
|
`POST /messages` and `POST /admin` (when `target="user"`) accept the
|
||||||
|
recipient identifier in one of two shapes:
|
||||||
|
|
||||||
|
- `recipient_user_id` (uuid) — explicit user lookup; the recipient
|
||||||
|
may be any active member of the game.
|
||||||
|
- `recipient_race_name` (string) — resolves to the active member
|
||||||
|
with this race name in the game. Race names are unique by lobby
|
||||||
|
invariant; lobby-removed and blocked members cannot be reached
|
||||||
|
through the race-name shortcut (they no longer appear in the
|
||||||
|
active scope). Exactly one of the two fields must be supplied;
|
||||||
|
supplying both, or neither, returns `invalid_request`.
|
||||||
|
|
||||||
|
The race-name path lets the in-game UI compose mail directly off
|
||||||
|
the engine's `report.races[]` view without an extra membership
|
||||||
|
round-trip.
|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
Stage D adds a lazy translation cache. When a recipient reads a
|
||||||
|
message through `GET /api/v1/user/games/{game_id}/mail/messages/{id}`,
|
||||||
|
the handler resolves the caller's `accounts.preferred_language` and
|
||||||
|
asks `Service.GetMessage(…, targetLang)` to attach a translation:
|
||||||
|
|
||||||
|
- on cache hit (row in `diplomail_translations`), the rendering is
|
||||||
|
returned directly under `translated_subject` / `translated_body`;
|
||||||
|
- on cache miss, the configured `translator.Translator` is invoked.
|
||||||
|
A non-noop result is persisted and returned to the caller; the
|
||||||
|
noop translator that ships with Stage D returns `engine == "noop"`,
|
||||||
|
which is treated as "translation unavailable" and the caller falls
|
||||||
|
back to the original body.
|
||||||
|
|
||||||
|
The inbox listing (`/inbox`) reuses cached translations but never
|
||||||
|
calls the translator on miss — bulk listings stay fast even when a
|
||||||
|
real translator (LibreTranslate, SaaS engine) introduces I/O cost.
|
||||||
|
|
||||||
|
Future work plugs a real `translator.Translator` (LibreTranslate
|
||||||
|
HTTP client is the documented next step) without touching the rest
|
||||||
|
of the system.
|
||||||
|
|
||||||
|
## Async translation (Stage E)
|
||||||
|
|
||||||
|
Stage E switches the translation pipeline from "lazy at read" to
|
||||||
|
"async at send". The send path stays synchronous from the
|
||||||
|
caller's perspective: the message and recipient rows are inserted
|
||||||
|
in one transaction. What changes is delivery semantics:
|
||||||
|
|
||||||
|
- Recipients whose `preferred_language` matches the detected
|
||||||
|
`body_lang` (or whose body language is `und`) get
|
||||||
|
`available_at = now()` straight away and the push event fires
|
||||||
|
during the request.
|
||||||
|
- Recipients whose `preferred_language` differs are inserted with
|
||||||
|
`available_at IS NULL`. They are **not** visible in inbox, unread
|
||||||
|
count, or push events until the worker translates the message.
|
||||||
|
|
||||||
|
The worker (`internal/diplomail.Worker`, started as an
|
||||||
|
`app.Component` in `cmd/backend/main`) ticks once every
|
||||||
|
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`). Each tick:
|
||||||
|
|
||||||
|
1. Picks one distinct `(message_id, recipient_preferred_language)`
|
||||||
|
pair from `diplomail_recipients` where `available_at IS NULL`
|
||||||
|
and `next_translation_attempt_at` is unset or due.
|
||||||
|
2. Loads the source message, checks the translation cache.
|
||||||
|
3. On cache hit → marks every pending recipient of the pair
|
||||||
|
delivered and emits push.
|
||||||
|
4. On cache miss → asks the configured `Translator`:
|
||||||
|
- success → caches the translation, marks delivered, push;
|
||||||
|
- HTTP 400 (unsupported pair) → marks delivered without a
|
||||||
|
translation (fallback to original);
|
||||||
|
- other failure → bumps `translation_attempts`, schedules the
|
||||||
|
retry via `next_translation_attempt_at`, leaves pending.
|
||||||
|
5. After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
|
||||||
|
the worker falls back to delivering the original body so a
|
||||||
|
prolonged LibreTranslate outage does not strand messages.
|
||||||
|
|
||||||
|
Retry backoff is exponential `1s → 2s → 4s → 8s → 16s` (capped at
|
||||||
|
60s) per pair. Operators monitor the LibreTranslate dependency
|
||||||
|
through standard OpenTelemetry export — translation outcomes
|
||||||
|
surface in `diplomail.worker` logs at Info / Warn levels;
|
||||||
|
Grafana / Prometheus dashboards live outside this package.
|
||||||
|
|
||||||
|
### Multi-instance posture (known limitation)
|
||||||
|
|
||||||
|
`PickPendingTranslationPair` intentionally drops `FOR UPDATE`: the
|
||||||
|
worker is single-threaded per process, and we did not want a slow
|
||||||
|
LibreTranslate HTTP call to keep a row-lock open. The cost is a
|
||||||
|
small window where two backend instances pulling at the same
|
||||||
|
moment can both claim the same pair: the cache-write side stays
|
||||||
|
clean (`INSERT … ON CONFLICT DO NOTHING`), but each instance will
|
||||||
|
publish its own push event to every recipient of the pair, so the
|
||||||
|
duplicate push is the visible failure mode.
|
||||||
|
|
||||||
|
The current deployment runs a single backend instance and the
|
||||||
|
window does not exist. When the platform scales to multiple
|
||||||
|
instances, we will revisit the pickup query — either by holding
|
||||||
|
the lock through the HTTP call (with a short timeout to bound the
|
||||||
|
worst case) or by introducing a `claimed_at` column and a
|
||||||
|
short-lived advisory lease. The change is local to this package
|
||||||
|
and does not affect callers.
|
||||||
|
|
||||||
|
For the LibreTranslate operational recipe — installing, wiring,
|
||||||
|
manual smoke test — see
|
||||||
|
[`backend/docs/diplomail-translator-setup.md`](../../docs/diplomail-translator-setup.md).
|
||||||
|
|
||||||
|
## 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,634 @@
|
|||||||
|
package diplomail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendAdminPersonal persists an admin-kind message addressed to a
|
||||||
|
// single recipient and fan-outs the push event. The HTTP layer is
|
||||||
|
// responsible for the owner-vs-admin authorisation decision; this
|
||||||
|
// function trusts the caller designation it receives.
|
||||||
|
//
|
||||||
|
// The recipient may be in any membership status, so the lookup goes
|
||||||
|
// through MembershipLookup.GetMembershipAnyStatus. This lets the
|
||||||
|
// owner / admin reach a kicked player to explain the kick or follow
|
||||||
|
// up after a removal.
|
||||||
|
func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) {
|
||||||
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
||||||
|
}
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||||
|
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient, msgInsert.BodyLang, s.nowUTC())
|
||||||
|
|
||||||
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err)
|
||||||
|
}
|
||||||
|
if len(recipients) != 1 {
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients))
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipients[0].AvailableAt != nil { s.publishMessageReceived(ctx, msg, recipients[0]) }
|
||||||
|
return msg, recipients[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAdminBroadcast persists an admin-kind broadcast addressed to
|
||||||
|
// every member matching `RecipientScope`, then emits one push event
|
||||||
|
// per recipient. The caller's own membership row, when present, is
|
||||||
|
// excluded from the recipient list — broadcasters do not get a copy
|
||||||
|
// of their own message.
|
||||||
|
func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) {
|
||||||
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, err
|
||||||
|
}
|
||||||
|
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
||||||
|
return Message{}, nil, err
|
||||||
|
}
|
||||||
|
scope, err := normaliseScope(in.RecipientScope)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err)
|
||||||
|
}
|
||||||
|
members = filterOutCaller(members, in.CallerUserID)
|
||||||
|
if len(members) == 0 {
|
||||||
|
return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
gameName := members[0].GameName
|
||||||
|
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||||
|
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, err
|
||||||
|
}
|
||||||
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range recipients {
|
||||||
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||||
|
}
|
||||||
|
return msg, recipients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPlayerBroadcast persists a paid-tier player broadcast and
|
||||||
|
// fans out the push event to every other active member of the game.
|
||||||
|
// The send is `kind="personal"`, `sender_kind="player"`,
|
||||||
|
// `broadcast_scope="game_broadcast"` — recipients reply to it as if
|
||||||
|
// it were a single-recipient personal send, and the reply targets
|
||||||
|
// only the broadcaster. The caller's entitlement tier is checked
|
||||||
|
// against `EntitlementReader`; free-tier callers are rejected with
|
||||||
|
// ErrForbidden.
|
||||||
|
func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) {
|
||||||
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, err
|
||||||
|
}
|
||||||
|
if s.deps.Entitlements == nil {
|
||||||
|
return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden)
|
||||||
|
}
|
||||||
|
paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err)
|
||||||
|
}
|
||||||
|
if !paid {
|
||||||
|
return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
|
||||||
|
}
|
||||||
|
return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err)
|
||||||
|
}
|
||||||
|
callerID := in.SenderUserID
|
||||||
|
members = filterOutCaller(members, &callerID)
|
||||||
|
if len(members) == 0 {
|
||||||
|
return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
username := sender.UserName
|
||||||
|
senderRace := sender.RaceName
|
||||||
|
msgInsert := MessageInsert{
|
||||||
|
MessageID: uuid.New(),
|
||||||
|
GameID: in.GameID,
|
||||||
|
GameName: sender.GameName,
|
||||||
|
Kind: KindPersonal,
|
||||||
|
SenderKind: SenderKindPlayer,
|
||||||
|
SenderUserID: &callerID,
|
||||||
|
SenderUsername: &username,
|
||||||
|
SenderRaceName: &senderRace,
|
||||||
|
SenderIP: in.SenderIP,
|
||||||
|
Subject: subject,
|
||||||
|
Body: body,
|
||||||
|
BodyLang: s.deps.Detector.Detect(body),
|
||||||
|
BroadcastScope: BroadcastScopeGameBroadcast,
|
||||||
|
}
|
||||||
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||||
|
}
|
||||||
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, nil, fmt.Errorf("diplomail: send player broadcast: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range recipients {
|
||||||
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||||
|
}
|
||||||
|
return msg, recipients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAdminMultiGameBroadcast emits one admin-kind message per game
|
||||||
|
// resolved from the input scope and fans out the push events. A
|
||||||
|
// recipient who plays in multiple addressed games receives one
|
||||||
|
// independently-deletable inbox entry per game; this avoids cross-
|
||||||
|
// game leakage of admin context and keeps the per-game unread badge
|
||||||
|
// honest.
|
||||||
|
func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) {
|
||||||
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
scope, err := normaliseScope(in.RecipientScope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if s.deps.Games == nil {
|
||||||
|
return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
games, err := s.resolveMultiGameTargets(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if len(games) == 0 {
|
||||||
|
return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRecipients := 0
|
||||||
|
out := make([]Message, 0, len(games))
|
||||||
|
for _, game := range games {
|
||||||
|
members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err)
|
||||||
|
}
|
||||||
|
if len(members) == 0 {
|
||||||
|
s.deps.Logger.Debug("multi-game broadcast skips empty game",
|
||||||
|
zap.String("game_id", game.GameID.String()),
|
||||||
|
zap.String("scope", scope))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
|
||||||
|
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||||
|
}
|
||||||
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err)
|
||||||
|
}
|
||||||
|
for _, r := range recipients {
|
||||||
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
totalRecipients += len(recipients)
|
||||||
|
}
|
||||||
|
return out, totalRecipients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) {
|
||||||
|
switch in.Scope {
|
||||||
|
case MultiGameScopeAllRunning:
|
||||||
|
games, err := s.deps.Games.ListRunningGames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("diplomail: list running games: %w", err)
|
||||||
|
}
|
||||||
|
return games, nil
|
||||||
|
case MultiGameScopeSelected, "":
|
||||||
|
if len(in.GameIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
out := make([]GameSnapshot, 0, len(in.GameIDs))
|
||||||
|
for _, id := range in.GameIDs {
|
||||||
|
game, err := s.deps.Games.GetGame(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("diplomail: load game %s: %w", id, err)
|
||||||
|
}
|
||||||
|
out = append(out, game)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkCleanup deletes every diplomail_messages row tied to games that
|
||||||
|
// finished more than `OlderThanYears` years ago. Returns the affected
|
||||||
|
// game ids and the count of removed messages. The minimum allowed
|
||||||
|
// value is 1 year — finer-grained pruning would risk wiping live
|
||||||
|
// arbitration evidence.
|
||||||
|
func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) {
|
||||||
|
if in.OlderThanYears < 1 {
|
||||||
|
return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if s.deps.Games == nil {
|
||||||
|
return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0)
|
||||||
|
games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err)
|
||||||
|
}
|
||||||
|
if len(games) == 0 {
|
||||||
|
return CleanupResult{}, nil
|
||||||
|
}
|
||||||
|
gameIDs := make([]uuid.UUID, 0, len(games))
|
||||||
|
for _, g := range games {
|
||||||
|
gameIDs = append(gameIDs, g.GameID)
|
||||||
|
}
|
||||||
|
deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs)
|
||||||
|
if err != nil {
|
||||||
|
return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err)
|
||||||
|
}
|
||||||
|
return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessagesForAdmin returns a paginated, optionally-filtered view
|
||||||
|
// of every persisted message. Used by the admin observability
|
||||||
|
// endpoint to inspect what has been sent and trace abuse reports.
|
||||||
|
func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) {
|
||||||
|
rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return AdminMessagePage{}, err
|
||||||
|
}
|
||||||
|
page := filter.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := filter.PageSize
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
return AdminMessagePage{
|
||||||
|
Items: rows,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishLifecycle persists a system-kind message in response to a
|
||||||
|
// lobby lifecycle transition and fan-outs push events to the
|
||||||
|
// affected recipients. Game-scoped transitions (`game.paused`,
|
||||||
|
// `game.cancelled`) reach every active member; membership-scoped
|
||||||
|
// transitions (`membership.removed`, `membership.blocked`) reach the
|
||||||
|
// kicked player only. Failures inside the function are logged at
|
||||||
|
// Warn level — lifecycle hooks must not block the lobby state
|
||||||
|
// machine on a downstream mail failure.
|
||||||
|
func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||||
|
switch ev.Kind {
|
||||||
|
case LifecycleKindGamePaused, LifecycleKindGameCancelled:
|
||||||
|
return s.publishGameLifecycle(ctx, ev)
|
||||||
|
case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked:
|
||||||
|
return s.publishMembershipLifecycle(ctx, ev)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||||
|
members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err)
|
||||||
|
}
|
||||||
|
if len(members) == 0 {
|
||||||
|
s.deps.Logger.Debug("lifecycle skip: no active members",
|
||||||
|
zap.String("game_id", ev.GameID.String()),
|
||||||
|
zap.String("kind", ev.Kind))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
gameName := members[0].GameName
|
||||||
|
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
||||||
|
|
||||||
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
||||||
|
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||||
|
}
|
||||||
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
||||||
|
}
|
||||||
|
for _, r := range recipients {
|
||||||
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||||
|
if ev.TargetUser == nil {
|
||||||
|
return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("diplomail lifecycle: load target membership: %w", err)
|
||||||
|
}
|
||||||
|
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
||||||
|
|
||||||
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
||||||
|
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rcptInsert := buildRecipientInsert(msgInsert.MessageID, target, msgInsert.BodyLang, s.nowUTC())
|
||||||
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
||||||
|
}
|
||||||
|
if len(recipients) == 1 && recipients[0].AvailableAt != nil {
|
||||||
|
s.publishMessageReceived(ctx, msg, recipients[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareContent normalises subject and body the same way SendPersonal
|
||||||
|
// does. Factored out so admin and lifecycle paths share the
|
||||||
|
// length-and-utf8 validation rules.
|
||||||
|
func (s *Service) prepareContent(subject, body string) (string, string, error) {
|
||||||
|
subj := strings.TrimRight(subject, " \t")
|
||||||
|
bod := strings.TrimRight(body, " \t\n")
|
||||||
|
if err := s.validateContent(subj, bod); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return subj, bod, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAdminMessageInsert encapsulates the message-row construction
|
||||||
|
// for every admin-kind send. The CHECK constraint maps sender
|
||||||
|
// shapes:
|
||||||
|
//
|
||||||
|
// sender_kind='player' → CallerKind owner; sender_user_id set,
|
||||||
|
// sender_race_name resolved from
|
||||||
|
// Memberships.GetActiveMembership
|
||||||
|
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
||||||
|
// sender_kind='system' → CallerKind system; sender_username nil
|
||||||
|
func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
||||||
|
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
||||||
|
out := MessageInsert{
|
||||||
|
MessageID: uuid.New(),
|
||||||
|
GameID: gameID,
|
||||||
|
GameName: gameName,
|
||||||
|
Kind: KindAdmin,
|
||||||
|
SenderIP: senderIP,
|
||||||
|
Subject: subject,
|
||||||
|
Body: body,
|
||||||
|
BodyLang: s.deps.Detector.Detect(body),
|
||||||
|
BroadcastScope: scope,
|
||||||
|
}
|
||||||
|
switch callerKind {
|
||||||
|
case CallerKindOwner:
|
||||||
|
if callerUserID == nil {
|
||||||
|
return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
uid := *callerUserID
|
||||||
|
uname := callerUsername
|
||||||
|
out.SenderKind = SenderKindPlayer
|
||||||
|
out.SenderUserID = &uid
|
||||||
|
out.SenderUsername = &uname
|
||||||
|
// Owner race snapshot is best-effort: a private-game owner who
|
||||||
|
// has an active membership in their own game contributes a
|
||||||
|
// race name; an owner who is not a current member (or whose
|
||||||
|
// membership is removed/blocked) leaves the field nil. The
|
||||||
|
// CHECK constraint accepts both shapes for sender_kind='player'.
|
||||||
|
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
|
||||||
|
race := ownerMember.RaceName
|
||||||
|
out.SenderRaceName = &race
|
||||||
|
} else if !errors.Is(err, ErrNotFound) {
|
||||||
|
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
|
||||||
|
}
|
||||||
|
case CallerKindAdmin:
|
||||||
|
uname := callerUsername
|
||||||
|
out.SenderKind = SenderKindAdmin
|
||||||
|
out.SenderUsername = &uname
|
||||||
|
case CallerKindSystem:
|
||||||
|
out.SenderKind = SenderKindSystem
|
||||||
|
default:
|
||||||
|
return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert.
|
||||||
|
// The race-name snapshot is nullable so a kicked player with no race
|
||||||
|
// name on file is still addressable.
|
||||||
|
//
|
||||||
|
// `bodyLang` is the detected language of the message body. When the
|
||||||
|
// recipient's preferred_language matches body_lang (or body_lang is
|
||||||
|
// undetermined), the function fills AvailableAt with `now` so the
|
||||||
|
// recipient row is materialised already-delivered; otherwise
|
||||||
|
// AvailableAt stays nil and the translation worker takes over.
|
||||||
|
func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot, bodyLang string, now time.Time) RecipientInsert {
|
||||||
|
in := RecipientInsert{
|
||||||
|
RecipientID: uuid.New(),
|
||||||
|
MessageID: messageID,
|
||||||
|
GameID: m.GameID,
|
||||||
|
UserID: m.UserID,
|
||||||
|
RecipientUserName: m.UserName,
|
||||||
|
RecipientPreferredLanguage: normaliseLang(m.PreferredLanguage),
|
||||||
|
}
|
||||||
|
if m.RaceName != "" {
|
||||||
|
race := m.RaceName
|
||||||
|
in.RecipientRaceName = &race
|
||||||
|
}
|
||||||
|
if needsTranslation(bodyLang, in.RecipientPreferredLanguage) {
|
||||||
|
// AvailableAt left nil → worker will deliver after the
|
||||||
|
// translation cache is materialised (or after fallback).
|
||||||
|
} else {
|
||||||
|
t := now.UTC()
|
||||||
|
in.AvailableAt = &t
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsTranslation reports whether a recipient with preferredLang
|
||||||
|
// needs to wait for a translated rendering before the message is
|
||||||
|
// considered delivered. Undetermined body language and empty
|
||||||
|
// recipient preferences are short-circuited to "no translation
|
||||||
|
// needed" so we never block delivery on something the detector
|
||||||
|
// could not label.
|
||||||
|
func needsTranslation(bodyLang, preferredLang string) bool {
|
||||||
|
bodyLang = normaliseLang(bodyLang)
|
||||||
|
preferredLang = normaliseLang(preferredLang)
|
||||||
|
if bodyLang == "" || bodyLang == LangUndetermined {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if preferredLang == "" || preferredLang == LangUndetermined {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bodyLang != preferredLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseLang strips any region subtag and lowercases the result so
|
||||||
|
// `en-US` and `EN` both collapse to `en`. The diplomail layer uses
|
||||||
|
// ISO 639-1 codes; whatlanggo and LibreTranslate share that
|
||||||
|
// vocabulary.
|
||||||
|
func normaliseLang(tag string) string {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexAny(tag, "-_"); i > 0 {
|
||||||
|
tag = tag[:i]
|
||||||
|
}
|
||||||
|
return strings.ToLower(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error {
|
||||||
|
switch callerKind {
|
||||||
|
case CallerKindOwner:
|
||||||
|
if callerUserID == nil {
|
||||||
|
return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if callerUsername == "" {
|
||||||
|
return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
case CallerKindAdmin:
|
||||||
|
if callerUsername == "" {
|
||||||
|
return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
case CallerKindSystem:
|
||||||
|
// no extra checks
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normaliseScope(scope string) (string, error) {
|
||||||
|
switch scope {
|
||||||
|
case "", RecipientScopeActive:
|
||||||
|
return RecipientScopeActive, nil
|
||||||
|
case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers:
|
||||||
|
return scope, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot {
|
||||||
|
if callerUserID == nil {
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
out := make([]MemberSnapshot, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
if m.UserID == *callerUserID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGameLifecycle returns the (subject, body) pair persisted for
|
||||||
|
// the `game.paused` / `game.cancelled` system message. Bodies are in
|
||||||
|
// English; Stage D will translate them on demand into each
|
||||||
|
// recipient's preferred_language and cache the result.
|
||||||
|
func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) {
|
||||||
|
actor = strings.TrimSpace(actor)
|
||||||
|
if actor == "" {
|
||||||
|
actor = "the system"
|
||||||
|
}
|
||||||
|
reasonTail := ""
|
||||||
|
if r := strings.TrimSpace(reason); r != "" {
|
||||||
|
reasonTail = " Reason: " + r + "."
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case LifecycleKindGamePaused:
|
||||||
|
return "Game paused",
|
||||||
|
fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail)
|
||||||
|
case LifecycleKindGameCancelled:
|
||||||
|
return "Game cancelled",
|
||||||
|
fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail)
|
||||||
|
}
|
||||||
|
return "Game lifecycle update",
|
||||||
|
fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMembershipLifecycle returns the (subject, body) pair persisted
|
||||||
|
// for the `membership.removed` / `membership.blocked` system message.
|
||||||
|
func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) {
|
||||||
|
actor = strings.TrimSpace(actor)
|
||||||
|
if actor == "" {
|
||||||
|
actor = "the system"
|
||||||
|
}
|
||||||
|
reasonTail := ""
|
||||||
|
if r := strings.TrimSpace(reason); r != "" {
|
||||||
|
reasonTail = " Reason: " + r + "."
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case LifecycleKindMembershipRemoved:
|
||||||
|
return "Membership removed",
|
||||||
|
fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail)
|
||||||
|
case LifecycleKindMembershipBlocked:
|
||||||
|
return "Membership blocked",
|
||||||
|
fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail)
|
||||||
|
}
|
||||||
|
return "Membership update",
|
||||||
|
fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package diplomail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/config"
|
||||||
|
"galaxy/backend/internal/diplomail/detector"
|
||||||
|
"galaxy/backend/internal/diplomail/translator"
|
||||||
|
|
||||||
|
"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. Entitlements and Games are
|
||||||
|
// optional — they are used by Stage C surfaces (paid-tier player
|
||||||
|
// broadcast, multi-game admin broadcast, bulk cleanup). Wiring may
|
||||||
|
// pass nil for tests that do not exercise those paths.
|
||||||
|
type Deps struct {
|
||||||
|
Store *Store
|
||||||
|
Memberships MembershipLookup
|
||||||
|
Notification NotificationPublisher
|
||||||
|
Entitlements EntitlementReader
|
||||||
|
Games GameLookup
|
||||||
|
Detector detector.LanguageDetector
|
||||||
|
Translator translator.Translator
|
||||||
|
Config config.DiplomailConfig
|
||||||
|
Logger *zap.Logger
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntitlementReader is the read-only surface diplomail uses to gate
|
||||||
|
// the paid-tier player broadcast. The canonical implementation in
|
||||||
|
// `cmd/backend/main` reads
|
||||||
|
// `*user.Service.GetEntitlementSnapshot(userID).IsPaid`.
|
||||||
|
type EntitlementReader interface {
|
||||||
|
IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameLookup exposes the slim view of `games` the multi-game admin
|
||||||
|
// broadcast and bulk-cleanup paths consume. The canonical
|
||||||
|
// implementation walks the lobby cache plus an explicit store call
|
||||||
|
// for finished-game pruning.
|
||||||
|
type GameLookup interface {
|
||||||
|
// ListRunningGames returns every game whose `status` is one of
|
||||||
|
// the still-active values (running, paused, starting, …). The
|
||||||
|
// admin `all_running` broadcast scope iterates over the result.
|
||||||
|
ListRunningGames(ctx context.Context) ([]GameSnapshot, error)
|
||||||
|
|
||||||
|
// ListFinishedGamesBefore returns every game whose `finished_at`
|
||||||
|
// is older than `cutoff`. The bulk-purge admin endpoint reads
|
||||||
|
// this to compose the cascade-delete IN list.
|
||||||
|
ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error)
|
||||||
|
|
||||||
|
// GetGame returns one game snapshot identified by id, or
|
||||||
|
// ErrNotFound. Used by the multi-game broadcast to verify the
|
||||||
|
// caller-supplied id list before enqueuing fan-out work.
|
||||||
|
GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameSnapshot is the trim view of `games` consumed by the multi-game
|
||||||
|
// admin broadcast and the cleanup paths. The struct intentionally
|
||||||
|
// avoids the full `lobby.GameRecord` so the diplomail package stays
|
||||||
|
// decoupled from the lobby domain.
|
||||||
|
type GameSnapshot struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
GameName string
|
||||||
|
Status string
|
||||||
|
FinishedAt *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`, `preferred_language`) that
|
||||||
|
// we persist on each new message / recipient row.
|
||||||
|
type ActiveMembership struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
GameName string
|
||||||
|
UserName string
|
||||||
|
RaceName string
|
||||||
|
PreferredLanguage 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.
|
||||||
|
//
|
||||||
|
// GetActiveMembership returns ErrNotFound (the diplomail sentinel)
|
||||||
|
// when the user is not an active member of the game; the service
|
||||||
|
// boundary maps that to 403 forbidden.
|
||||||
|
//
|
||||||
|
// GetMembershipAnyStatus returns the same shape regardless of
|
||||||
|
// membership status (`active`, `removed`, `blocked`). Used by the
|
||||||
|
// inbox read path to check whether a kicked recipient still belongs
|
||||||
|
// to the game's roster; ErrNotFound is surfaced when the user has
|
||||||
|
// never been a member.
|
||||||
|
//
|
||||||
|
// ListMembers returns every roster row matching scope, in stable
|
||||||
|
// order. Scope values are `active`, `active_and_removed`, and
|
||||||
|
// `all_members` (the spec calls these out by name). Used by the
|
||||||
|
// broadcast composition step in admin / owner sends.
|
||||||
|
type MembershipLookup interface {
|
||||||
|
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
|
||||||
|
GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error)
|
||||||
|
ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient scope values accepted by ListMembers and by the
|
||||||
|
// `recipients` request field on admin / owner broadcasts.
|
||||||
|
const (
|
||||||
|
RecipientScopeActive = "active"
|
||||||
|
RecipientScopeActiveAndRemoved = "active_and_removed"
|
||||||
|
RecipientScopeAllMembers = "all_members"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemberSnapshot is the slim view of a membership row that survives
|
||||||
|
// all three status values. RaceName is the immutable string captured
|
||||||
|
// at registration time; an empty value is legal for rare cases where
|
||||||
|
// the row was inserted without one. PreferredLanguage is included so
|
||||||
|
// the broadcast and lifecycle paths can decide whether the recipient
|
||||||
|
// needs to wait for a translation before delivery.
|
||||||
|
type MemberSnapshot struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
GameName string
|
||||||
|
UserName string
|
||||||
|
RaceName string
|
||||||
|
PreferredLanguage string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,79 @@
|
|||||||
|
// Package detector wraps the body-language detection used by the
|
||||||
|
// diplomail subsystem. The package exposes a narrow `LanguageDetector`
|
||||||
|
// interface so the implementation can be swapped without touching the
|
||||||
|
// callers; the default backed-by-whatlanggo detector handles 84
|
||||||
|
// natural languages and ships with the embedded statistical profiles.
|
||||||
|
//
|
||||||
|
// Detection happens only on the body. Subjects are short and
|
||||||
|
// frequently template-like ("Re: ..."), so detecting on them adds
|
||||||
|
// noise. The diplomail Service feeds the body, captures the BCP 47
|
||||||
|
// tag returned here, and stores it in `diplomail_messages.body_lang`.
|
||||||
|
package detector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/abadojack/whatlanggo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Undetermined is the BCP 47 placeholder stored when detection cannot
|
||||||
|
// confidently identify a language (empty body, too-short body, mixed
|
||||||
|
// scripts the detector refuses to bet on).
|
||||||
|
const Undetermined = "und"
|
||||||
|
|
||||||
|
// LanguageDetector is the read-only surface diplomail consumes when
|
||||||
|
// it needs to label a message body. Detect must never panic and
|
||||||
|
// must never return an error: detection failure simply yields
|
||||||
|
// `Undetermined`.
|
||||||
|
type LanguageDetector interface {
|
||||||
|
Detect(body string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the package-default detector backed by `whatlanggo`.
|
||||||
|
// The instance is safe for concurrent use; whatlanggo's `Detect`
|
||||||
|
// reads the embedded profiles without state mutation. Callers that
|
||||||
|
// want a fixed allow-list can build their own implementation around
|
||||||
|
// the same interface.
|
||||||
|
func New() LanguageDetector {
|
||||||
|
return &whatlangDetector{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type whatlangDetector struct{}
|
||||||
|
|
||||||
|
// minRunes is the lower bound on body length below which whatlanggo
|
||||||
|
// can flip between near-synonyms; for shorter bodies we return
|
||||||
|
// `Undetermined` and let the noop translator skip the slot. The
|
||||||
|
// value matches whatlanggo's documented "stable above ~25 runes"
|
||||||
|
// guidance.
|
||||||
|
const minRunes = 25
|
||||||
|
|
||||||
|
// Detect returns the BCP 47 tag for body, or `Undetermined` when the
|
||||||
|
// body is empty / too short / whatlanggo refuses to label it. The
|
||||||
|
// trim is applied so leading whitespace does not bias the script
|
||||||
|
// detector toward Latin. We deliberately do not gate on
|
||||||
|
// `info.IsReliable()` because the gate is too conservative for the
|
||||||
|
// short sentences typical of in-game mail; a misclassification only
|
||||||
|
// hurts the translation cache key, never correctness.
|
||||||
|
func (d *whatlangDetector) Detect(body string) string {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return Undetermined
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(body) < minRunes {
|
||||||
|
return Undetermined
|
||||||
|
}
|
||||||
|
info := whatlanggo.Detect(body)
|
||||||
|
tag := info.Lang.Iso6391()
|
||||||
|
if tag == "" {
|
||||||
|
return Undetermined
|
||||||
|
}
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoopDetector returns the placeholder unconditionally. Used by
|
||||||
|
// tests and by Stage A code paths that predate the real detector.
|
||||||
|
type NoopDetector struct{}
|
||||||
|
|
||||||
|
// Detect always returns `Undetermined` regardless of input.
|
||||||
|
func (NoopDetector) Detect(string) string { return Undetermined }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package detector
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDetectKnownLanguages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := New()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "english paragraph",
|
||||||
|
text: "The trade agreement should be signed before the next turn. " +
|
||||||
|
"I expect a written response by the time the engine generates the next report.",
|
||||||
|
want: "en",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian paragraph",
|
||||||
|
text: "Привет! Я предлагаю заключить дипломатическое соглашение и провести " +
|
||||||
|
"совместную операцию по освоению гиперпространственных маршрутов. " +
|
||||||
|
"Жду твоего письменного ответа до конца следующего хода игры, " +
|
||||||
|
"чтобы мы успели согласовать детали и подписать договор вовремя.",
|
||||||
|
want: "ru",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := d.Detect(tc.text)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("Detect = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectShortOrEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := New()
|
||||||
|
short := []string{"", "hi", " "}
|
||||||
|
for _, s := range short {
|
||||||
|
if got := d.Detect(s); got != Undetermined {
|
||||||
|
t.Errorf("Detect(%q) = %q, want %q", s, got, Undetermined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 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"
|
||||||
|
"galaxy/backend/internal/diplomail/detector"
|
||||||
|
"galaxy/backend/internal/diplomail/translator"
|
||||||
|
|
||||||
|
"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.Detector == nil {
|
||||||
|
deps.Detector = detector.NoopDetector{}
|
||||||
|
}
|
||||||
|
if deps.Translator == nil {
|
||||||
|
deps.Translator = translator.NewNoop()
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,441 @@
|
|||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
if in.SenderUserID == recipientID {
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, recipientID)
|
||||||
|
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
|
||||||
|
senderRace := sender.RaceName
|
||||||
|
senderUserID := in.SenderUserID
|
||||||
|
msgInsert := MessageInsert{
|
||||||
|
MessageID: uuid.New(),
|
||||||
|
GameID: in.GameID,
|
||||||
|
GameName: sender.GameName,
|
||||||
|
Kind: KindPersonal,
|
||||||
|
SenderKind: SenderKindPlayer,
|
||||||
|
SenderUserID: &senderUserID,
|
||||||
|
SenderUsername: &username,
|
||||||
|
SenderRaceName: &senderRace,
|
||||||
|
SenderIP: in.SenderIP,
|
||||||
|
Subject: subject,
|
||||||
|
Body: body,
|
||||||
|
BodyLang: s.deps.Detector.Detect(body),
|
||||||
|
BroadcastScope: BroadcastScopeSingle,
|
||||||
|
}
|
||||||
|
raceName := recipient.RaceName
|
||||||
|
rcptInsert := buildRecipientInsert(
|
||||||
|
msgInsert.MessageID,
|
||||||
|
MemberSnapshot{
|
||||||
|
UserID: recipientID,
|
||||||
|
GameID: in.GameID,
|
||||||
|
GameName: recipient.GameName,
|
||||||
|
UserName: recipient.UserName,
|
||||||
|
RaceName: raceName,
|
||||||
|
PreferredLanguage: recipient.PreferredLanguage,
|
||||||
|
Status: "active",
|
||||||
|
},
|
||||||
|
msgInsert.BodyLang,
|
||||||
|
s.nowUTC(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipients[0].AvailableAt != nil {
|
||||||
|
s.publishMessageReceived(ctx, msg, recipients[0])
|
||||||
|
}
|
||||||
|
return msg, recipients[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveActiveRecipient turns a (user_id, race_name) pair into the
|
||||||
|
// canonical user id of an active member of gameID. Exactly one of the
|
||||||
|
// two inputs must be set; both-set or both-empty returns
|
||||||
|
// ErrInvalidInput. Race-name resolution is restricted to the active
|
||||||
|
// scope so lobby-removed and blocked members cannot be reached
|
||||||
|
// through the race-name shortcut. ErrInvalidInput is also returned
|
||||||
|
// when the race name matches zero members; ErrForbidden when the
|
||||||
|
// race name matches more than one active row (defence in depth — race
|
||||||
|
// names are unique within a game by lobby invariant).
|
||||||
|
func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) {
|
||||||
|
byRaceName = strings.TrimSpace(byRaceName)
|
||||||
|
hasUser := byUserID != uuid.Nil
|
||||||
|
hasRace := byRaceName != ""
|
||||||
|
switch {
|
||||||
|
case hasUser && hasRace:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput)
|
||||||
|
case !hasUser && !hasRace:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput)
|
||||||
|
case hasUser:
|
||||||
|
return byUserID, nil
|
||||||
|
}
|
||||||
|
members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err)
|
||||||
|
}
|
||||||
|
var found []MemberSnapshot
|
||||||
|
for _, m := range members {
|
||||||
|
if m.RaceName == byRaceName {
|
||||||
|
found = append(found, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(found) {
|
||||||
|
case 0:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName)
|
||||||
|
case 1:
|
||||||
|
return found[0].UserID, nil
|
||||||
|
default:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. The same sentinel is returned when
|
||||||
|
// the caller is no longer an active member of the game and the
|
||||||
|
// message is personal-kind: post-kick visibility is restricted to
|
||||||
|
// admin/system mail (item 8 of the spec).
|
||||||
|
//
|
||||||
|
// When `targetLang` is non-empty and differs from the message's
|
||||||
|
// `body_lang`, the function consults the translation cache; on a
|
||||||
|
// miss it asks the configured Translator to produce a rendering and
|
||||||
|
// persists the result. The noop translator returns the input
|
||||||
|
// unchanged with `engine == "noop"`, which is treated as
|
||||||
|
// "translation unavailable" — the entry comes back with `Translation
|
||||||
|
// == nil` and the caller renders the original body.
|
||||||
|
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) {
|
||||||
|
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return InboxEntry{}, err
|
||||||
|
}
|
||||||
|
allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return InboxEntry{}, err
|
||||||
|
}
|
||||||
|
if !allowed[entry.Kind] {
|
||||||
|
return InboxEntry{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil {
|
||||||
|
entry.Translation = tr
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTranslation returns the cached translation for
|
||||||
|
// (message, targetLang), lazily computing and persisting one on
|
||||||
|
// cache miss. Returns nil when no translation is needed (target is
|
||||||
|
// empty, matches `body_lang`, or the message body is itself
|
||||||
|
// undetermined) or when the configured translator declares the
|
||||||
|
// rendering unavailable.
|
||||||
|
func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
||||||
|
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil {
|
||||||
|
t := existing
|
||||||
|
return &t
|
||||||
|
} else if !errors.Is(err, ErrNotFound) {
|
||||||
|
s.deps.Logger.Warn("load translation failed",
|
||||||
|
zap.String("message_id", msg.MessageID.String()),
|
||||||
|
zap.String("target_lang", targetLang),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.deps.Translator == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Logger.Warn("translator call failed",
|
||||||
|
zap.String("message_id", msg.MessageID.String()),
|
||||||
|
zap.String("target_lang", targetLang),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if result.Engine == "" || result.Engine == "noop" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tr := Translation{
|
||||||
|
TranslationID: uuid.New(),
|
||||||
|
MessageID: msg.MessageID,
|
||||||
|
TargetLang: targetLang,
|
||||||
|
TranslatedSubject: result.Subject,
|
||||||
|
TranslatedBody: result.Body,
|
||||||
|
Translator: result.Engine,
|
||||||
|
}
|
||||||
|
stored, err := s.deps.Store.InsertTranslation(ctx, tr)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Logger.Warn("insert translation failed",
|
||||||
|
zap.String("message_id", msg.MessageID.String()),
|
||||||
|
zap.String("target_lang", targetLang),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. Personal
|
||||||
|
// messages are filtered out when the caller is no longer an active
|
||||||
|
// member of the game so a kicked player keeps read access to the
|
||||||
|
// admin/system explanation of the kick but not to historical
|
||||||
|
// player-to-player threads.
|
||||||
|
//
|
||||||
|
// When `targetLang` is non-empty and differs from a row's body
|
||||||
|
// language, the function consults the translation cache (without
|
||||||
|
// re-translating on miss; the per-message read endpoint owns that
|
||||||
|
// path so the bulk listing never blocks on translator I/O).
|
||||||
|
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) {
|
||||||
|
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allowed, err := s.allowedKinds(ctx, gameID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := entries
|
||||||
|
if !(allowed[KindPersonal] && allowed[KindAdmin]) {
|
||||||
|
out = make([]InboxEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if allowed[e.Kind] {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetLang == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupCachedTranslation reads an existing translation row without
|
||||||
|
// asking the Translator to compute one. The bulk inbox listing uses
|
||||||
|
// this to avoid per-row translator I/O; GetMessage uses the full
|
||||||
|
// `resolveTranslation` helper which falls through to the translator
|
||||||
|
// on cache miss.
|
||||||
|
func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
||||||
|
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
s.deps.Logger.Debug("inbox translation lookup failed",
|
||||||
|
zap.String("message_id", msg.MessageID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := existing
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedKinds resolves the set of message kinds the caller may read
|
||||||
|
// in gameID. An active member can read everything; a former member
|
||||||
|
// (status removed or blocked) can read admin-kind only. A user who
|
||||||
|
// has never been a member of the game but is still listed as a
|
||||||
|
// recipient (legacy / system message) is granted the same admin-only
|
||||||
|
// view. The function never returns an empty set: even non-members
|
||||||
|
// keep their read access to admin mail.
|
||||||
|
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
|
||||||
|
if s.deps.Memberships == nil {
|
||||||
|
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
||||||
|
}
|
||||||
|
if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
|
||||||
|
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
||||||
|
} else if !errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]bool{KindAdmin: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSent returns the sender-side view of personal messages
|
||||||
|
// authored by senderUserID in gameID, newest first. Each entry pairs
|
||||||
|
// the message with one of its recipient rows; single sends contribute
|
||||||
|
// one entry per message, broadcasts contribute one entry per
|
||||||
|
// addressee. Admin and 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) ([]InboxEntry, 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,822 @@
|
|||||||
|
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.SenderRaceName, 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.RecipientPreferredLanguage,
|
||||||
|
r.AvailableAt, r.TranslationAttempts, r.NextTranslationAttemptAt,
|
||||||
|
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
|
||||||
|
SenderRaceName *string
|
||||||
|
SenderIP string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
BodyLang string
|
||||||
|
BroadcastScope string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipientInsert carries the per-recipient snapshot. AvailableAt
|
||||||
|
// captures the async-delivery contract: when non-nil, the recipient
|
||||||
|
// row is materialised already-delivered (no translation needed or
|
||||||
|
// the language matches); when nil, the recipient is queued for the
|
||||||
|
// translation worker.
|
||||||
|
type RecipientInsert struct {
|
||||||
|
RecipientID uuid.UUID
|
||||||
|
MessageID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
RecipientUserName string
|
||||||
|
RecipientRaceName *string
|
||||||
|
RecipientPreferredLanguage string
|
||||||
|
AvailableAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.SenderRaceName, 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),
|
||||||
|
stringPtrArg(msg.SenderRaceName),
|
||||||
|
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,
|
||||||
|
r.RecipientPreferredLanguage, r.AvailableAt,
|
||||||
|
)
|
||||||
|
for _, in := range recipients {
|
||||||
|
rcptStmt = rcptStmt.VALUES(
|
||||||
|
in.RecipientID,
|
||||||
|
in.MessageID,
|
||||||
|
in.GameID,
|
||||||
|
in.UserID,
|
||||||
|
in.RecipientUserName,
|
||||||
|
stringPtrArg(in.RecipientRaceName),
|
||||||
|
in.RecipientPreferredLanguage,
|
||||||
|
timePtrArg(in.AvailableAt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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. Rows still waiting for
|
||||||
|
// the async translation worker (`available_at IS NULL`) are also
|
||||||
|
// excluded — they will appear once delivery is complete.
|
||||||
|
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()).
|
||||||
|
AND(r.AvailableAt.IS_NOT_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 the sender-side view of personal messages
|
||||||
|
// authored by senderUserID in gameID, newest first. Each
|
||||||
|
// `InboxEntry` carries the message together with one of its
|
||||||
|
// recipient rows — single sends produce one entry per message;
|
||||||
|
// game broadcasts produce one entry per addressee (the in-game
|
||||||
|
// mail UI collapses broadcast entries into a single stand-alone
|
||||||
|
// item by `message_id`). Admin / system rows have
|
||||||
|
// `sender_user_id IS NULL` and are excluded by the WHERE clause.
|
||||||
|
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
|
||||||
|
m := table.DiplomailMessages
|
||||||
|
r := table.DiplomailRecipients
|
||||||
|
cols := append(messageColumns(), recipientColumns()...)
|
||||||
|
stmt := postgres.SELECT(cols).
|
||||||
|
FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))).
|
||||||
|
WHERE(
|
||||||
|
m.GameID.EQ(postgres.UUID(gameID)).
|
||||||
|
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
|
||||||
|
).
|
||||||
|
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC())
|
||||||
|
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 sent %s/%s: %w", gameID, senderUserID, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// delivered messages addressed to userID in gameID. Recipients
|
||||||
|
// still waiting for translation (`available_at IS NULL`) are
|
||||||
|
// excluded so the badge does not flicker.
|
||||||
|
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()).
|
||||||
|
AND(r.AvailableAt.IS_NOT_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
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingTranslationPair carries one unit of work picked by the
|
||||||
|
// translation worker. Multiple recipients of the same message that
|
||||||
|
// share a preferred_language collapse into one pair, because the
|
||||||
|
// translation is shared via the diplomail_translations cache.
|
||||||
|
// CurrentAttempts is the highest `translation_attempts` value across
|
||||||
|
// the matching recipient rows, so the worker can decide whether the
|
||||||
|
// next attempt is the last one before falling back.
|
||||||
|
type PendingTranslationPair struct {
|
||||||
|
MessageID uuid.UUID
|
||||||
|
TargetLang string
|
||||||
|
CurrentAttempts int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickPendingTranslationPair returns one pair eligible for the
|
||||||
|
// translation worker, or `ok == false` when the queue is empty. The
|
||||||
|
// pair is the (message, target_lang) of any recipient where
|
||||||
|
// `available_at IS NULL` and `next_translation_attempt_at` is either
|
||||||
|
// unset or already due. The query intentionally drops the
|
||||||
|
// `FOR UPDATE` clause — the worker is single-threaded per process,
|
||||||
|
// and the optimistic UPDATE in `MarkPairDelivered` /
|
||||||
|
// `MarkPairFallback` filters by `available_at IS NULL`, so a stale
|
||||||
|
// pickup never delivers twice.
|
||||||
|
func (s *Store) PickPendingTranslationPair(ctx context.Context, now time.Time) (PendingTranslationPair, bool, error) {
|
||||||
|
r := table.DiplomailRecipients
|
||||||
|
stmt := postgres.SELECT(
|
||||||
|
r.MessageID.AS("message_id"),
|
||||||
|
r.RecipientPreferredLanguage.AS("target_lang"),
|
||||||
|
postgres.MAX(r.TranslationAttempts).AS("attempts"),
|
||||||
|
).
|
||||||
|
FROM(r).
|
||||||
|
WHERE(
|
||||||
|
r.AvailableAt.IS_NULL().
|
||||||
|
AND(r.RecipientPreferredLanguage.NOT_EQ(postgres.String(""))).
|
||||||
|
AND(r.NextTranslationAttemptAt.IS_NULL().
|
||||||
|
OR(r.NextTranslationAttemptAt.LT_EQ(postgres.TimestampzT(now.UTC())))),
|
||||||
|
).
|
||||||
|
GROUP_BY(r.MessageID, r.RecipientPreferredLanguage).
|
||||||
|
ORDER_BY(r.MessageID.ASC(), r.RecipientPreferredLanguage.ASC()).
|
||||||
|
LIMIT(1)
|
||||||
|
var dest struct {
|
||||||
|
MessageID uuid.UUID `alias:"message_id"`
|
||||||
|
TargetLang string `alias:"target_lang"`
|
||||||
|
Attempts int32 `alias:"attempts"`
|
||||||
|
}
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return PendingTranslationPair{}, false, nil
|
||||||
|
}
|
||||||
|
return PendingTranslationPair{}, false, fmt.Errorf("diplomail store: pick pending pair: %w", err)
|
||||||
|
}
|
||||||
|
if dest.MessageID == (uuid.UUID{}) {
|
||||||
|
return PendingTranslationPair{}, false, nil
|
||||||
|
}
|
||||||
|
return PendingTranslationPair{
|
||||||
|
MessageID: dest.MessageID,
|
||||||
|
TargetLang: dest.TargetLang,
|
||||||
|
CurrentAttempts: dest.Attempts,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPairDelivered flips every still-pending recipient of (messageID,
|
||||||
|
// targetLang) to `available_at = at`, optionally persisting the
|
||||||
|
// translation row alongside in the same transaction. Returns the
|
||||||
|
// recipients that were just delivered (used by the worker to fan out
|
||||||
|
// push events).
|
||||||
|
func (s *Store) MarkPairDelivered(ctx context.Context, messageID uuid.UUID, targetLang string, translation *Translation, at time.Time) ([]Recipient, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("diplomail store: begin deliver tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if translation != nil {
|
||||||
|
t := table.DiplomailTranslations
|
||||||
|
ins := t.INSERT(
|
||||||
|
t.TranslationID, t.MessageID, t.TargetLang,
|
||||||
|
t.TranslatedSubject, t.TranslatedBody, t.Translator,
|
||||||
|
).VALUES(
|
||||||
|
translation.TranslationID, translation.MessageID, translation.TargetLang,
|
||||||
|
translation.TranslatedSubject, translation.TranslatedBody, translation.Translator,
|
||||||
|
).ON_CONFLICT(t.MessageID, t.TargetLang).DO_NOTHING()
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return nil, fmt.Errorf("diplomail store: upsert translation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r := table.DiplomailRecipients
|
||||||
|
upd := r.UPDATE(r.AvailableAt, r.NextTranslationAttemptAt).
|
||||||
|
SET(postgres.TimestampzT(at.UTC()), postgres.NULL).
|
||||||
|
WHERE(
|
||||||
|
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||||
|
AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))).
|
||||||
|
AND(r.AvailableAt.IS_NULL()),
|
||||||
|
).
|
||||||
|
RETURNING(recipientColumns())
|
||||||
|
|
||||||
|
var rows []model.DiplomailRecipients
|
||||||
|
if err := upd.QueryContext(ctx, tx, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("diplomail store: mark pair delivered: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("diplomail store: commit deliver: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Recipient, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, recipientFromModel(row))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulePairRetry bumps the attempt counter and schedules the next
|
||||||
|
// translation attempt for `next`. The recipient rows stay in the
|
||||||
|
// pending queue (`available_at IS NULL`). Returns the new attempt
|
||||||
|
// counter so the worker can decide whether to fall back to the
|
||||||
|
// original on the next pickup.
|
||||||
|
func (s *Store) SchedulePairRetry(ctx context.Context, messageID uuid.UUID, targetLang string, next time.Time) (int32, error) {
|
||||||
|
r := table.DiplomailRecipients
|
||||||
|
upd := r.UPDATE(r.TranslationAttempts, r.NextTranslationAttemptAt).
|
||||||
|
SET(r.TranslationAttempts.ADD(postgres.Int(1)), postgres.TimestampzT(next.UTC())).
|
||||||
|
WHERE(
|
||||||
|
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||||
|
AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))).
|
||||||
|
AND(r.AvailableAt.IS_NULL()),
|
||||||
|
).
|
||||||
|
RETURNING(r.TranslationAttempts)
|
||||||
|
var dest []struct {
|
||||||
|
TranslationAttempts int32 `alias:"diplomail_recipients.translation_attempts"`
|
||||||
|
}
|
||||||
|
if err := upd.QueryContext(ctx, s.db, &dest); err != nil {
|
||||||
|
return 0, fmt.Errorf("diplomail store: schedule pair retry: %w", err)
|
||||||
|
}
|
||||||
|
if len(dest) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
max := dest[0].TranslationAttempts
|
||||||
|
for _, d := range dest[1:] {
|
||||||
|
if d.TranslationAttempts > max {
|
||||||
|
max = d.TranslationAttempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translationColumns is the canonical projection for
|
||||||
|
// diplomail_translations reads.
|
||||||
|
func translationColumns() postgres.ColumnList {
|
||||||
|
t := table.DiplomailTranslations
|
||||||
|
return postgres.ColumnList{
|
||||||
|
t.TranslationID, t.MessageID, t.TargetLang,
|
||||||
|
t.TranslatedSubject, t.TranslatedBody, t.Translator, t.TranslatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTranslation returns the cached translation row for
|
||||||
|
// (messageID, targetLang). Returns ErrNotFound when no cache row
|
||||||
|
// exists yet — the caller decides whether to compute and persist
|
||||||
|
// one.
|
||||||
|
func (s *Store) LoadTranslation(ctx context.Context, messageID uuid.UUID, targetLang string) (Translation, error) {
|
||||||
|
t := table.DiplomailTranslations
|
||||||
|
stmt := postgres.SELECT(translationColumns()).
|
||||||
|
FROM(t).
|
||||||
|
WHERE(t.MessageID.EQ(postgres.UUID(messageID)).
|
||||||
|
AND(t.TargetLang.EQ(postgres.String(targetLang)))).
|
||||||
|
LIMIT(1)
|
||||||
|
var row model.DiplomailTranslations
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Translation{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Translation{}, fmt.Errorf("diplomail store: load translation %s/%s: %w", messageID, targetLang, err)
|
||||||
|
}
|
||||||
|
return translationFromModel(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertTranslation persists a new translation cache row. The unique
|
||||||
|
// constraint on (message_id, target_lang) prevents duplicate
|
||||||
|
// renderings. Callers that race on the same (message, lang) pair
|
||||||
|
// should be prepared for a UNIQUE violation; the second writer can
|
||||||
|
// fall back to LoadTranslation.
|
||||||
|
func (s *Store) InsertTranslation(ctx context.Context, in Translation) (Translation, error) {
|
||||||
|
t := table.DiplomailTranslations
|
||||||
|
stmt := t.INSERT(
|
||||||
|
t.TranslationID, t.MessageID, t.TargetLang,
|
||||||
|
t.TranslatedSubject, t.TranslatedBody, t.Translator,
|
||||||
|
).VALUES(
|
||||||
|
in.TranslationID, in.MessageID, in.TargetLang,
|
||||||
|
in.TranslatedSubject, in.TranslatedBody, in.Translator,
|
||||||
|
).RETURNING(translationColumns())
|
||||||
|
|
||||||
|
var row model.DiplomailTranslations
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Translation{}, fmt.Errorf("diplomail store: insert translation %s/%s: %w", in.MessageID, in.TargetLang, err)
|
||||||
|
}
|
||||||
|
return translationFromModel(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func translationFromModel(row model.DiplomailTranslations) Translation {
|
||||||
|
return Translation{
|
||||||
|
TranslationID: row.TranslationID,
|
||||||
|
MessageID: row.MessageID,
|
||||||
|
TargetLang: row.TargetLang,
|
||||||
|
TranslatedSubject: row.TranslatedSubject,
|
||||||
|
TranslatedBody: row.TranslatedBody,
|
||||||
|
Translator: row.Translator,
|
||||||
|
TranslatedAt: row.TranslatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessagesForGames removes every diplomail_messages row whose
|
||||||
|
// game_id falls in the supplied set. The cascade defined on the
|
||||||
|
// `diplomail_recipients` and `diplomail_translations` foreign keys
|
||||||
|
// removes the per-recipient state and the cached translations in
|
||||||
|
// the same transaction. Returns the count of messages removed.
|
||||||
|
//
|
||||||
|
// Used by the admin bulk-purge endpoint; callers are expected to
|
||||||
|
// have already filtered the input set to terminal-state games.
|
||||||
|
func (s *Store) DeleteMessagesForGames(ctx context.Context, gameIDs []uuid.UUID) (int, error) {
|
||||||
|
if len(gameIDs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
args := make([]postgres.Expression, 0, len(gameIDs))
|
||||||
|
for _, id := range gameIDs {
|
||||||
|
args = append(args, postgres.UUID(id))
|
||||||
|
}
|
||||||
|
m := table.DiplomailMessages
|
||||||
|
stmt := m.DELETE().WHERE(m.GameID.IN(args...))
|
||||||
|
res, err := stmt.ExecContext(ctx, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("diplomail store: bulk delete messages: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("diplomail store: rows affected: %w", err)
|
||||||
|
}
|
||||||
|
return int(affected), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessagesForAdmin returns a paginated slice of messages
|
||||||
|
// matching filter. The result is ordered by created_at DESC,
|
||||||
|
// message_id DESC. Total is the count without pagination so the
|
||||||
|
// caller can render a "page X of N" envelope.
|
||||||
|
func (s *Store) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) ([]Message, int, error) {
|
||||||
|
m := table.DiplomailMessages
|
||||||
|
page := filter.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := filter.PageSize
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions := postgres.BoolExpression(nil)
|
||||||
|
addCondition := func(cond postgres.BoolExpression) {
|
||||||
|
if conditions == nil {
|
||||||
|
conditions = cond
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conditions = conditions.AND(cond)
|
||||||
|
}
|
||||||
|
if filter.GameID != nil {
|
||||||
|
addCondition(m.GameID.EQ(postgres.UUID(*filter.GameID)))
|
||||||
|
}
|
||||||
|
if filter.Kind != "" {
|
||||||
|
addCondition(m.Kind.EQ(postgres.String(filter.Kind)))
|
||||||
|
}
|
||||||
|
if filter.SenderKind != "" {
|
||||||
|
addCondition(m.SenderKind.EQ(postgres.String(filter.SenderKind)))
|
||||||
|
}
|
||||||
|
|
||||||
|
countStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(m)
|
||||||
|
if conditions != nil {
|
||||||
|
countStmt = countStmt.WHERE(conditions)
|
||||||
|
}
|
||||||
|
var countDest struct {
|
||||||
|
Count int64 `alias:"count"`
|
||||||
|
}
|
||||||
|
if err := countStmt.QueryContext(ctx, s.db, &countDest); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("diplomail store: count admin messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listStmt := postgres.SELECT(messageColumns()).FROM(m)
|
||||||
|
if conditions != nil {
|
||||||
|
listStmt = listStmt.WHERE(conditions)
|
||||||
|
}
|
||||||
|
listStmt = listStmt.
|
||||||
|
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()).
|
||||||
|
LIMIT(int64(pageSize)).
|
||||||
|
OFFSET(int64((page - 1) * pageSize))
|
||||||
|
|
||||||
|
var rows []model.DiplomailMessages
|
||||||
|
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("diplomail store: list admin messages: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Message, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, messageFromModel(row))
|
||||||
|
}
|
||||||
|
return out, int(countDest.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()).
|
||||||
|
AND(r.AvailableAt.IS_NOT_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
|
||||||
|
}
|
||||||
|
if row.SenderRaceName != nil {
|
||||||
|
name := *row.SenderRaceName
|
||||||
|
out.SenderRaceName = &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,
|
||||||
|
RecipientPreferredLanguage: row.RecipientPreferredLanguage,
|
||||||
|
AvailableAt: row.AvailableAt,
|
||||||
|
TranslationAttempts: row.TranslationAttempts,
|
||||||
|
NextTranslationAttemptAt: row.NextTranslationAttemptAt,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timePtrArg returns the jet argument expression for a nullable
|
||||||
|
// timestamptz column.
|
||||||
|
func timePtrArg(v *time.Time) postgres.Expression {
|
||||||
|
if v == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.TimestampzT(v.UTC())
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package translator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibreTranslateEngine is the engine identifier persisted in
|
||||||
|
// `diplomail_translations.translator` for cache rows produced by the
|
||||||
|
// LibreTranslate client.
|
||||||
|
const LibreTranslateEngine = "libretranslate"
|
||||||
|
|
||||||
|
// LibreTranslateConfig configures the HTTP client. URL is the base
|
||||||
|
// of the deployed instance (without `/translate`). Timeout bounds a
|
||||||
|
// single HTTP request; the worker layers retry / backoff on top.
|
||||||
|
type LibreTranslateConfig struct {
|
||||||
|
URL string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnsupportedLanguagePair classifies a LibreTranslate 400 response
|
||||||
|
// that indicates the engine cannot translate between the requested
|
||||||
|
// source / target codes. The worker treats this as terminal: no
|
||||||
|
// further retries, deliver the original.
|
||||||
|
var ErrUnsupportedLanguagePair = errors.New("translator: language pair not supported by libretranslate")
|
||||||
|
|
||||||
|
// NewLibreTranslate constructs a Translator that posts to
|
||||||
|
// `<URL>/translate`. Returns an error when URL is empty so wiring
|
||||||
|
// catches "translator misconfigured" at startup rather than at
|
||||||
|
// first-translation-attempt.
|
||||||
|
func NewLibreTranslate(cfg LibreTranslateConfig) (Translator, error) {
|
||||||
|
url := strings.TrimRight(strings.TrimSpace(cfg.URL), "/")
|
||||||
|
if url == "" {
|
||||||
|
return nil, errors.New("translator: libretranslate URL must be set")
|
||||||
|
}
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &libreTranslate{
|
||||||
|
endpoint: url + "/translate",
|
||||||
|
client: &http.Client{Timeout: timeout},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type libreTranslate struct {
|
||||||
|
endpoint string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestBody is the LibreTranslate POST /translate input shape.
|
||||||
|
// `q` is sent as a two-element array so the engine returns one
|
||||||
|
// translation per element in the same call (subject + body).
|
||||||
|
type requestBody struct {
|
||||||
|
Q []string `json:"q"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseBody is the LibreTranslate output shape when `q` is an
|
||||||
|
// array. The single-string-q variant is a different shape; we never
|
||||||
|
// emit a single-q request so the client always sees the array form.
|
||||||
|
type responseBody struct {
|
||||||
|
TranslatedText []string `json:"translatedText"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate posts subject + body to LibreTranslate, normalising the
|
||||||
|
// language codes and classifying the response. The 400 / unsupported-
|
||||||
|
// pair path is signalled by `ErrUnsupportedLanguagePair`. All other
|
||||||
|
// HTTP errors (timeout, 5xx, network failure) come back as wrapped
|
||||||
|
// errors so the worker can backoff and retry.
|
||||||
|
func (l *libreTranslate) Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error) {
|
||||||
|
src := normaliseLanguageCode(srcLang)
|
||||||
|
dst := normaliseLanguageCode(dstLang)
|
||||||
|
if src == "" || dst == "" {
|
||||||
|
return Result{}, fmt.Errorf("translator: missing source or target language (src=%q dst=%q)", srcLang, dstLang)
|
||||||
|
}
|
||||||
|
if src == dst {
|
||||||
|
return Result{Subject: subject, Body: body, Engine: NoopEngine}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, err := json.Marshal(requestBody{
|
||||||
|
Q: []string{subject, body},
|
||||||
|
Source: src,
|
||||||
|
Target: dst,
|
||||||
|
Format: "text",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("translator: marshal request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.endpoint, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("translator: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := l.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("translator: do request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("translator: read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusBadRequest {
|
||||||
|
return Result{}, fmt.Errorf("%w: %s", ErrUnsupportedLanguagePair, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return Result{}, fmt.Errorf("translator: libretranslate http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var out responseBody
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("translator: unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
if out.Error != "" {
|
||||||
|
return Result{}, fmt.Errorf("translator: libretranslate error: %s", out.Error)
|
||||||
|
}
|
||||||
|
if len(out.TranslatedText) != 2 {
|
||||||
|
return Result{}, fmt.Errorf("translator: libretranslate returned %d strings, want 2", len(out.TranslatedText))
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Subject: out.TranslatedText[0],
|
||||||
|
Body: out.TranslatedText[1],
|
||||||
|
Engine: LibreTranslateEngine,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseLanguageCode collapses a BCP 47 tag to the ISO 639-1 base
|
||||||
|
// that LibreTranslate expects (`en-US` → `en`, `EN` → `en`). The
|
||||||
|
// helper is mirrored on the diplomail service side; both sides need
|
||||||
|
// to use the same normalisation so cache keys line up.
|
||||||
|
func normaliseLanguageCode(tag string) string {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexAny(tag, "-_"); i > 0 {
|
||||||
|
tag = tag[:i]
|
||||||
|
}
|
||||||
|
return strings.ToLower(tag)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package translator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLibreTranslateHappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var (
|
||||||
|
requestSource string
|
||||||
|
requestTarget string
|
||||||
|
requestQ []string
|
||||||
|
requestFormat string
|
||||||
|
)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var in requestBody
|
||||||
|
if err := json.Unmarshal(body, &in); err != nil {
|
||||||
|
t.Errorf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
requestSource = in.Source
|
||||||
|
requestTarget = in.Target
|
||||||
|
requestQ = in.Q
|
||||||
|
requestFormat = in.Format
|
||||||
|
_ = json.NewEncoder(w).Encode(responseBody{
|
||||||
|
TranslatedText: []string{"[ru] " + in.Q[0], "[ru] " + in.Q[1]},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
tr, err := NewLibreTranslate(LibreTranslateConfig{URL: server.URL, Timeout: 2 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
res, err := tr.Translate(context.Background(), "en", "ru", "Hello", "World")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("translate: %v", err)
|
||||||
|
}
|
||||||
|
if res.Engine != LibreTranslateEngine {
|
||||||
|
t.Fatalf("engine = %q, want %q", res.Engine, LibreTranslateEngine)
|
||||||
|
}
|
||||||
|
if res.Subject != "[ru] Hello" || res.Body != "[ru] World" {
|
||||||
|
t.Fatalf("result = %+v", res)
|
||||||
|
}
|
||||||
|
if requestSource != "en" || requestTarget != "ru" || requestFormat != "text" {
|
||||||
|
t.Fatalf("request fields: src=%q dst=%q fmt=%q", requestSource, requestTarget, requestFormat)
|
||||||
|
}
|
||||||
|
if len(requestQ) != 2 || requestQ[0] != "Hello" || requestQ[1] != "World" {
|
||||||
|
t.Fatalf("request q = %v", requestQ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreTranslateNormalisesLanguageCodes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var src, dst string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var in requestBody
|
||||||
|
_ = json.Unmarshal(body, &in)
|
||||||
|
src, dst = in.Source, in.Target
|
||||||
|
_ = json.NewEncoder(w).Encode(responseBody{TranslatedText: []string{"a", "b"}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||||
|
if _, err := tr.Translate(context.Background(), "EN-US", "ru-RU", "x", "y"); err != nil {
|
||||||
|
t.Fatalf("translate: %v", err)
|
||||||
|
}
|
||||||
|
if src != "en" || dst != "ru" {
|
||||||
|
t.Fatalf("normalised codes src=%q dst=%q, want en/ru", src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreTranslateUnsupportedPair(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"language not supported"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||||
|
_, err := tr.Translate(context.Background(), "en", "xx", "subject", "body")
|
||||||
|
if !errors.Is(err, ErrUnsupportedLanguagePair) {
|
||||||
|
t.Fatalf("err = %v, want ErrUnsupportedLanguagePair", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreTranslateServerError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("kaboom"))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||||
|
_, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrUnsupportedLanguagePair) {
|
||||||
|
t.Fatalf("err mis-classified as unsupported pair: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "500") {
|
||||||
|
t.Fatalf("err = %v, want mention of 500", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreTranslateSameSourceAndTargetIsNoop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Errorf("translator should not call the server for identical src/dst: %s", r.URL.Path)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||||
|
res, err := tr.Translate(context.Background(), "en", "EN", "x", "y")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("translate: %v", err)
|
||||||
|
}
|
||||||
|
if res.Engine != NoopEngine {
|
||||||
|
t.Fatalf("engine = %q, want %q", res.Engine, NoopEngine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreTranslateRequiresURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := NewLibreTranslate(LibreTranslateConfig{URL: ""})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for empty URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibreTranslateRejectsMalformedArray defends against a server
|
||||||
|
// that returns a partial / unexpected `translatedText` payload. The
|
||||||
|
// client must surface an error (not panic, not return a half-empty
|
||||||
|
// Result) so the worker can decide between retry and fallback.
|
||||||
|
func TestLibreTranslateRejectsMalformedArray(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"single string", `{"translatedText": "only one"}`},
|
||||||
|
{"array of one", `{"translatedText": ["only one"]}`},
|
||||||
|
{"empty array", `{"translatedText": []}`},
|
||||||
|
{"missing field", `{"foo":"bar"}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
body := tc.body
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||||
|
res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for malformed body %q, got %+v", body, res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Package translator wraps the per-language rendering for the
|
||||||
|
// diplomail subsystem. The package exposes a narrow `Translator`
|
||||||
|
// interface so the actual translation backend (LibreTranslate, an
|
||||||
|
// in-process model, a SaaS engine, …) can be swapped without
|
||||||
|
// touching the rest of the codebase.
|
||||||
|
//
|
||||||
|
// Stage D ships a `NoopTranslator` that returns the input unchanged.
|
||||||
|
// The diplomail Service treats a `Name == NoopEngine` result as
|
||||||
|
// "translation unavailable" and refrains from writing a cache row;
|
||||||
|
// the inbox handler then returns the original body with a
|
||||||
|
// `translated == false` payload. The contract lets the rest of the
|
||||||
|
// system ship without a translation backend; future stages can wire
|
||||||
|
// a real `Translator` without code changes elsewhere.
|
||||||
|
package translator
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// NoopEngine is the engine identifier returned by `NoopTranslator`.
|
||||||
|
// The diplomail Service checks for this value to decide whether to
|
||||||
|
// persist a `diplomail_translations` row.
|
||||||
|
const NoopEngine = "noop"
|
||||||
|
|
||||||
|
// Result carries one translated rendering plus the engine identifier
|
||||||
|
// that produced it. The engine name is persisted as
|
||||||
|
// `diplomail_translations.translator` so an operator can see which
|
||||||
|
// backend produced each row.
|
||||||
|
type Result struct {
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
Engine string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translator is the read-only surface diplomail consumes when it
|
||||||
|
// needs to render a message for a recipient whose
|
||||||
|
// `preferred_language` differs from `body_lang`. Implementations
|
||||||
|
// must be safe for concurrent use; `Translate` may be invoked from
|
||||||
|
// the async worker on many messages at once.
|
||||||
|
type Translator interface {
|
||||||
|
// Translate renders `subject` and `body` from `srcLang` into
|
||||||
|
// `dstLang`. A nil error with `Result.Engine == NoopEngine`
|
||||||
|
// signals that no real rendering happened.
|
||||||
|
Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoop returns a Translator that always returns the input
|
||||||
|
// unchanged with engine name `NoopEngine`.
|
||||||
|
func NewNoop() Translator {
|
||||||
|
return noop{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type noop struct{}
|
||||||
|
|
||||||
|
func (noop) Translate(_ context.Context, _, _, subject, body string) (Result, error) {
|
||||||
|
return Result{
|
||||||
|
Subject: subject,
|
||||||
|
Body: body,
|
||||||
|
Engine: NoopEngine,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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
|
||||||
|
// SenderRaceName carries the snapshot of the sender's race in the
|
||||||
|
// game at send time. Non-nil for sender_kind='player' rows, nil
|
||||||
|
// for admin and system. The in-game mail UI groups personal
|
||||||
|
// threads by this name (Phase 28).
|
||||||
|
SenderRaceName *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, RecipientRaceName, and
|
||||||
|
// RecipientPreferredLanguage are snapshots taken at insert time so
|
||||||
|
// the inbox listing, admin search, and translation worker render
|
||||||
|
// correctly even after the source rows are renamed or revoked.
|
||||||
|
//
|
||||||
|
// AvailableAt encodes the async-translation contract introduced in
|
||||||
|
// Stage E:
|
||||||
|
//
|
||||||
|
// - non-nil → message is visible to the recipient (in inbox /
|
||||||
|
// unread counts / push events) starting from this timestamp;
|
||||||
|
// - nil → recipient is waiting for the translation worker to fan
|
||||||
|
// out the translated rendering. The translation_attempts counter
|
||||||
|
// tracks the number of failed LibreTranslate calls; the worker
|
||||||
|
// gives up after `MaxTranslationAttempts` and falls back to the
|
||||||
|
// original body, flipping AvailableAt to now().
|
||||||
|
type Recipient struct {
|
||||||
|
RecipientID uuid.UUID
|
||||||
|
MessageID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
RecipientUserName string
|
||||||
|
RecipientRaceName *string
|
||||||
|
RecipientPreferredLanguage string
|
||||||
|
AvailableAt *time.Time
|
||||||
|
TranslationAttempts int32
|
||||||
|
NextTranslationAttemptAt *time.Time
|
||||||
|
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. Translation, when non-nil, carries the
|
||||||
|
// per-recipient rendering returned from
|
||||||
|
// `Service.GetMessage(ctx, …, targetLang)` and surfaced under the
|
||||||
|
// `body_translated` payload field; Stage D ships a noop translator,
|
||||||
|
// so this field stays nil until a real backend is wired.
|
||||||
|
type InboxEntry struct {
|
||||||
|
Message
|
||||||
|
Recipient Recipient
|
||||||
|
Translation *Translation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation mirrors a row in `backend.diplomail_translations`. The
|
||||||
|
// engine identifier is preserved so an operator can see which
|
||||||
|
// backend produced the cached rendering.
|
||||||
|
type Translation struct {
|
||||||
|
TranslationID uuid.UUID
|
||||||
|
MessageID uuid.UUID
|
||||||
|
TargetLang string
|
||||||
|
TranslatedSubject string
|
||||||
|
TranslatedBody string
|
||||||
|
Translator string
|
||||||
|
TranslatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPersonalInput is the request payload for SendPersonal: the
|
||||||
|
// caller sending a single-recipient personal message. Exactly one of
|
||||||
|
// RecipientUserID and RecipientRaceName must be non-zero; the
|
||||||
|
// service resolves a non-empty RecipientRaceName to the active
|
||||||
|
// member with that race in the game. Other validation (active
|
||||||
|
// membership, body length, etc.) is performed inside the service.
|
||||||
|
type SendPersonalInput struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
SenderUserID uuid.UUID
|
||||||
|
RecipientUserID uuid.UUID
|
||||||
|
RecipientRaceName string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
SenderIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallerKind enumerates the privileged sender roles for admin-kind
|
||||||
|
// messages. Owners (`CallerKindOwner`) are players who own a private
|
||||||
|
// game; admins (`CallerKindAdmin`) hit the dedicated admin route;
|
||||||
|
// `CallerKindSystem` is reserved for internal lifecycle hooks.
|
||||||
|
const (
|
||||||
|
CallerKindOwner = "owner"
|
||||||
|
CallerKindAdmin = "admin"
|
||||||
|
CallerKindSystem = "system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendAdminPersonalInput is the request payload for an owner /
|
||||||
|
// admin / system sending an admin-kind message to a single
|
||||||
|
// recipient. Exactly one of RecipientUserID and RecipientRaceName
|
||||||
|
// must be non-zero; the service resolves a non-empty
|
||||||
|
// RecipientRaceName to the active member with that race in the
|
||||||
|
// game. Authorization (owner-vs-admin distinction) is enforced by
|
||||||
|
// the HTTP layer; the service trusts the caller designation.
|
||||||
|
type SendAdminPersonalInput struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
CallerKind string
|
||||||
|
CallerUserID *uuid.UUID
|
||||||
|
CallerUsername string
|
||||||
|
RecipientUserID uuid.UUID
|
||||||
|
RecipientRaceName string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
SenderIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAdminBroadcastInput is the request payload for an owner /
|
||||||
|
// admin / system broadcasting an admin-kind message inside a single
|
||||||
|
// game. RecipientScope selects the address book; the sender's own
|
||||||
|
// recipient row is never created (a broadcast author does not get a
|
||||||
|
// copy of their own message).
|
||||||
|
type SendAdminBroadcastInput struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
CallerKind string
|
||||||
|
CallerUserID *uuid.UUID
|
||||||
|
CallerUsername string
|
||||||
|
RecipientScope string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
SenderIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleEventKind enumerates the producer-side intents the lobby
|
||||||
|
// emits when a game-state or membership-state transition lands.
|
||||||
|
const (
|
||||||
|
LifecycleKindGamePaused = "game.paused"
|
||||||
|
LifecycleKindGameCancelled = "game.cancelled"
|
||||||
|
LifecycleKindMembershipRemoved = "membership.removed"
|
||||||
|
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendPlayerBroadcastInput is the request payload for the paid-tier
|
||||||
|
// player broadcast. The sender is a player; recipients are the
|
||||||
|
// active members of the game minus the sender. The resulting message
|
||||||
|
// is `kind="personal"`, `sender_kind="player"`,
|
||||||
|
// `broadcast_scope="game_broadcast"` — recipients may reply as if it
|
||||||
|
// were a personal send, but the reply goes back to the broadcaster
|
||||||
|
// only.
|
||||||
|
type SendPlayerBroadcastInput struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
SenderUserID uuid.UUID
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
SenderIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiGameBroadcastScope enumerates the admin multi-game broadcast
|
||||||
|
// modes. `selected` requires `GameIDs`; `all_running` enumerates
|
||||||
|
// every game whose status is non-terminal through GameLookup.
|
||||||
|
const (
|
||||||
|
MultiGameScopeSelected = "selected"
|
||||||
|
MultiGameScopeAllRunning = "all_running"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendMultiGameBroadcastInput is the request payload for the admin
|
||||||
|
// multi-game broadcast. The service materialises one message row per
|
||||||
|
// addressed game (so a recipient who plays in two games receives two
|
||||||
|
// independently-deletable inbox entries), then fan-outs the push
|
||||||
|
// events.
|
||||||
|
type SendMultiGameBroadcastInput struct {
|
||||||
|
CallerUsername string
|
||||||
|
Scope string
|
||||||
|
GameIDs []uuid.UUID
|
||||||
|
RecipientScope string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
SenderIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkCleanupInput selects messages eligible for purge. OlderThanYears
|
||||||
|
// must be >= 1; the service translates the value into a cutoff
|
||||||
|
// expressed in years and walks `GameLookup.ListFinishedGamesBefore`.
|
||||||
|
type BulkCleanupInput struct {
|
||||||
|
OlderThanYears int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupResult summarises a bulk-cleanup run for the admin response
|
||||||
|
// envelope.
|
||||||
|
type CleanupResult struct {
|
||||||
|
GameIDs []uuid.UUID
|
||||||
|
MessagesDeleted int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMessageListing is the filter passed to ListMessagesForAdmin.
|
||||||
|
// Pagination uses (Page, PageSize) consistent with the rest of the
|
||||||
|
// admin surface. Filters are AND-combined; the empty filter returns
|
||||||
|
// every persisted row.
|
||||||
|
type AdminMessageListing struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
GameID *uuid.UUID
|
||||||
|
Kind string
|
||||||
|
SenderKind string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMessagePage is the canonical pagination envelope.
|
||||||
|
type AdminMessagePage struct {
|
||||||
|
Items []Message
|
||||||
|
Total int
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleEvent is the payload lobby hands to PublishLifecycle when
|
||||||
|
// a transition needs to be reflected as durable system mail. The
|
||||||
|
// recipient set is derived by the service:
|
||||||
|
//
|
||||||
|
// - For game.* events the message fans out to every active member
|
||||||
|
// of the game except the actor (the actor sees the action in
|
||||||
|
// their own UI through other channels).
|
||||||
|
// - For membership.* events the message addresses exactly
|
||||||
|
// `TargetUser` (the kicked player), regardless of their current
|
||||||
|
// membership status — this is how a kicked player retains read
|
||||||
|
// access to the explanation of the kick.
|
||||||
|
type LifecycleEvent struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Actor string
|
||||||
|
Reason string
|
||||||
|
TargetUser *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package diplomail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/diplomail/translator"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// translationBackoff returns the sleep applied before retry attempt
|
||||||
|
// `attempt`. attempt is 1-indexed (the value the row carries AFTER
|
||||||
|
// the failure is recorded). The schedule mirrors the spec —
|
||||||
|
// 1s → 2s → 4s → 8s → 16s — so 5 failed attempts span ~31 seconds
|
||||||
|
// before the worker falls back to delivering the original.
|
||||||
|
func translationBackoff(attempt int32) time.Duration {
|
||||||
|
if attempt <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
out := time.Second
|
||||||
|
for i := int32(1); i < attempt; i++ {
|
||||||
|
out *= 2
|
||||||
|
}
|
||||||
|
const cap = 60 * time.Second
|
||||||
|
if out > cap {
|
||||||
|
return cap
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker drives the async translation pipeline. Each tick picks a
|
||||||
|
// single (message_id, target_lang) pair from
|
||||||
|
// `diplomail_recipients` where `available_at IS NULL`, asks the
|
||||||
|
// configured Translator to render the body, and either delivers the
|
||||||
|
// pending recipients (success) or schedules a retry (transient
|
||||||
|
// failure) or delivers them with a fallback to the original body
|
||||||
|
// (terminal failure / max attempts).
|
||||||
|
//
|
||||||
|
// The worker is single-threaded by design: one HTTP call to
|
||||||
|
// LibreTranslate at a time. This protects the upstream from spikes
|
||||||
|
// and keeps the implementation reviewable.
|
||||||
|
//
|
||||||
|
// Implements `internal/app.Component` so it plugs into the same
|
||||||
|
// lifecycle as the mail and notification workers.
|
||||||
|
type Worker struct {
|
||||||
|
svc *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorker constructs a Worker bound to svc. Returning a non-nil
|
||||||
|
// Worker even when the translator is the noop fallback is
|
||||||
|
// intentional — the pickup query still works and falls through to
|
||||||
|
// fallback delivery, which is the desired behaviour for setups
|
||||||
|
// without LibreTranslate.
|
||||||
|
func NewWorker(svc *Service) *Worker { return &Worker{svc: svc} }
|
||||||
|
|
||||||
|
// Run drives the worker loop until ctx is cancelled.
|
||||||
|
func (w *Worker) Run(ctx context.Context) error {
|
||||||
|
if w == nil || w.svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger := w.svc.deps.Logger.Named("worker")
|
||||||
|
interval := w.svc.deps.Config.WorkerInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 2 * time.Second
|
||||||
|
}
|
||||||
|
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
logger.Warn("diplomail worker initial tick failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
logger.Warn("diplomail worker tick failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown is a no-op: every translation outcome is committed inside
|
||||||
|
// tick before returning, so cancelling the parent ctx is enough.
|
||||||
|
func (w *Worker) Shutdown(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
// Tick exposes the per-tick work for tests so they can drive the
|
||||||
|
// worker without depending on the ticker.
|
||||||
|
func (w *Worker) Tick(ctx context.Context) error { return w.tick(ctx) }
|
||||||
|
|
||||||
|
// tick picks one pair from the queue and applies the result. The
|
||||||
|
// per-tick budget is one pair on purpose: the worker is single
|
||||||
|
// threaded and we do not want a fast LibreTranslate instance to
|
||||||
|
// starve the rest of the backend's I/O behind a long-running batch.
|
||||||
|
func (w *Worker) tick(ctx context.Context) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
pair, ok, err := w.svc.deps.Store.PickPendingTranslationPair(ctx, w.svc.nowUTC())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.processPair(ctx, pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processPair runs the full pipeline for one (message, target_lang).
|
||||||
|
// Steps:
|
||||||
|
//
|
||||||
|
// 1. Load the source message.
|
||||||
|
// 2. Check the translation cache. If a row already exists (another
|
||||||
|
// worker pre-populated it, or two pairs converged on the same
|
||||||
|
// target), reuse it and deliver.
|
||||||
|
// 3. Otherwise call the configured Translator.
|
||||||
|
// 4. Apply the outcome: success → cache + deliver; unsupported
|
||||||
|
// pair → deliver fallback (no cache row); other failure →
|
||||||
|
// schedule retry or deliver fallback after MaxAttempts.
|
||||||
|
// 5. Fan out push events for every recipient whose `available_at`
|
||||||
|
// just transitioned.
|
||||||
|
func (w *Worker) processPair(ctx context.Context, pair PendingTranslationPair) error {
|
||||||
|
logger := w.svc.deps.Logger.Named("worker").With(
|
||||||
|
zap.String("message_id", pair.MessageID.String()),
|
||||||
|
zap.String("target_lang", pair.TargetLang),
|
||||||
|
)
|
||||||
|
msg, err := w.svc.deps.Store.LoadMessage(ctx, pair.MessageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cached, err := w.svc.deps.Store.LoadTranslation(ctx, pair.MessageID, pair.TargetLang); err == nil {
|
||||||
|
t := cached
|
||||||
|
return w.deliverPair(ctx, msg, pair.TargetLang, &t, logger)
|
||||||
|
} else if !errors.Is(err, ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, callErr := w.svc.deps.Translator.Translate(ctx, msg.BodyLang, pair.TargetLang, msg.Subject, msg.Body)
|
||||||
|
if callErr == nil && result.Engine != "" && result.Engine != translator.NoopEngine {
|
||||||
|
tr := Translation{
|
||||||
|
TranslationID: uuid.New(),
|
||||||
|
MessageID: msg.MessageID,
|
||||||
|
TargetLang: pair.TargetLang,
|
||||||
|
TranslatedSubject: result.Subject,
|
||||||
|
TranslatedBody: result.Body,
|
||||||
|
Translator: result.Engine,
|
||||||
|
}
|
||||||
|
return w.deliverPair(ctx, msg, pair.TargetLang, &tr, logger)
|
||||||
|
}
|
||||||
|
if callErr == nil {
|
||||||
|
// Noop translator (or engine returned empty). Treat as
|
||||||
|
// "translation unavailable" — deliver fallback so users
|
||||||
|
// see the original.
|
||||||
|
logger.Debug("translator returned noop, delivering fallback")
|
||||||
|
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||||
|
}
|
||||||
|
if errors.Is(callErr, translator.ErrUnsupportedLanguagePair) {
|
||||||
|
logger.Info("language pair unsupported, delivering fallback", zap.Error(callErr))
|
||||||
|
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient failure — bump the attempts counter and schedule a
|
||||||
|
// retry. The next attempt timestamp is computed from the
|
||||||
|
// post-increment counter so the spec's 1s→2s→4s→8s→16s schedule
|
||||||
|
// applies between retries of the same pair.
|
||||||
|
maxAttempts := w.svc.deps.Config.TranslatorMaxAttempts
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
maxAttempts = 5
|
||||||
|
}
|
||||||
|
nextAttempt := pair.CurrentAttempts + 1
|
||||||
|
if int(nextAttempt) >= maxAttempts {
|
||||||
|
logger.Warn("translator max attempts reached, delivering fallback",
|
||||||
|
zap.Int32("attempts", nextAttempt), zap.Error(callErr))
|
||||||
|
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||||
|
}
|
||||||
|
next := w.svc.nowUTC().Add(translationBackoff(nextAttempt + 1))
|
||||||
|
if _, err := w.svc.deps.Store.SchedulePairRetry(ctx, pair.MessageID, pair.TargetLang, next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.Info("translator attempt failed, scheduled retry",
|
||||||
|
zap.Int32("attempts", nextAttempt),
|
||||||
|
zap.Time("next_attempt_at", next),
|
||||||
|
zap.Error(callErr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverPair flips every still-pending recipient of (messageID,
|
||||||
|
// targetLang) to delivered, optionally inserting the translation row
|
||||||
|
// in the same transaction, and emits push events to the recipients
|
||||||
|
// who were just unblocked.
|
||||||
|
func (w *Worker) deliverPair(ctx context.Context, msg Message, targetLang string, translation *Translation, logger *zap.Logger) error {
|
||||||
|
recipients, err := w.svc.deps.Store.MarkPairDelivered(ctx, msg.MessageID, targetLang, translation, w.svc.nowUTC())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
logger.Debug("deliver yielded no recipients (already delivered)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, r := range recipients {
|
||||||
|
w.svc.publishMessageReceived(ctx, msg, r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) {
|
|||||||
return g, ok
|
return g, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListGames returns a snapshot copy of every cached game. Terminal-
|
||||||
|
// state games (finished, cancelled) are evicted from the cache on
|
||||||
|
// `PutGame`, so the result reflects the live roster of running /
|
||||||
|
// paused / draft / starting / etc. games. The slice is freshly
|
||||||
|
// allocated and safe for the caller to mutate.
|
||||||
|
func (c *Cache) ListGames() []GameRecord {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
out := make([]GameRecord, 0, len(c.games))
|
||||||
|
for _, g := range c.games {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// PutGame stores game in the cache when its status is cacheable;
|
// PutGame stores game in the cache when its status is cacheable;
|
||||||
// terminal statuses (finished, cancelled) cause the entry to be evicted.
|
// terminal statuses (finished, cancelled) cause the entry to be evicted.
|
||||||
func (c *Cache) PutGame(game GameRecord) {
|
func (c *Cache) PutGame(game GameRecord) {
|
||||||
|
|||||||
@@ -51,6 +51,37 @@ type NotificationPublisher interface {
|
|||||||
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiplomailPublisher is the outbound surface the lobby uses to drop a
|
||||||
|
// durable system mail entry whenever a game-state or
|
||||||
|
// membership-state transition needs to land in the affected players'
|
||||||
|
// inboxes. The real implementation in `cmd/backend/main` adapts the
|
||||||
|
// `*diplomail.Service.PublishLifecycle` call; tests and partial
|
||||||
|
// wiring fall back to `NewNoopDiplomailPublisher`.
|
||||||
|
type DiplomailPublisher interface {
|
||||||
|
PublishLifecycle(ctx context.Context, event LifecycleEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleEvent is the open shape carried by a system-mail intent.
|
||||||
|
// `Kind` is one of the lobby-internal constants
|
||||||
|
// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only
|
||||||
|
// for membership-scoped events; the publisher derives the game-scoped
|
||||||
|
// recipient set itself.
|
||||||
|
type LifecycleEvent struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Actor string
|
||||||
|
Reason string
|
||||||
|
TargetUser *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle-event kinds the lobby emits.
|
||||||
|
const (
|
||||||
|
LifecycleKindGamePaused = "game.paused"
|
||||||
|
LifecycleKindGameCancelled = "game.cancelled"
|
||||||
|
LifecycleKindMembershipRemoved = "membership.removed"
|
||||||
|
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||||
|
)
|
||||||
|
|
||||||
// LobbyNotification is the open shape carried by a notification intent.
|
// LobbyNotification is the open shape carried by a notification intent.
|
||||||
// The implementation emits a small set of `Kind` values matching the catalog in
|
// The implementation emits a small set of `Kind` values matching the catalog in
|
||||||
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
||||||
@@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent
|
|||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs
|
||||||
|
// every call at debug level and returns nil. Used by tests and by
|
||||||
|
// the lobby Service factory when the Deps.Diplomail field is left
|
||||||
|
// nil.
|
||||||
|
func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")}
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopDiplomailPublisher struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error {
|
||||||
|
p.logger.Debug("noop diplomail lifecycle",
|
||||||
|
zap.String("kind", event.Kind),
|
||||||
|
zap.String("game_id", event.GameID.String()),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"galaxy/cronutil"
|
"galaxy/cronutil"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateGameInput is the parameter struct for Service.CreateGame.
|
// CreateGameInput is the parameter struct for Service.CreateGame.
|
||||||
@@ -233,6 +234,41 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
|
|||||||
return s.deps.Store.ListMyGames(ctx, userID)
|
return s.deps.Store.ListMyGames(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFinishedGamesBefore returns every game whose status is
|
||||||
|
// `finished` or `cancelled` and whose `finished_at` is strictly older
|
||||||
|
// than cutoff. The result walks the store through the admin-paged
|
||||||
|
// query with a 200-row batch size; the caller is expected to invoke
|
||||||
|
// this from rare admin workflows (diplomail bulk cleanup) rather
|
||||||
|
// than hot-path reads.
|
||||||
|
func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) {
|
||||||
|
const pageSize = 200
|
||||||
|
page := 1
|
||||||
|
var out []GameRecord
|
||||||
|
for {
|
||||||
|
batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err)
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, g := range batch {
|
||||||
|
if g.Status != GameStatusFinished && g.Status != GameStatusCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
if len(batch) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteGame removes the game and every referencing row (memberships,
|
// DeleteGame removes the game and every referencing row (memberships,
|
||||||
// applications, invites, runtime_records, player_mappings) via the
|
// applications, invites, runtime_records, player_mappings) via the
|
||||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||||
@@ -441,9 +477,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle
|
|||||||
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitGameLifecycleMail asks the diplomail publisher to drop a
|
||||||
|
// system-mail entry whenever a state change is user-visible. Only
|
||||||
|
// the `paused` and `cancelled` transitions emit mail today (the spec
|
||||||
|
// names them explicitly); `running`/`finished`/etc. are signalled by
|
||||||
|
// other channels and do not need a durable inbox entry.
|
||||||
|
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
|
||||||
|
var kind string
|
||||||
|
switch rule.To {
|
||||||
|
case GameStatusPaused:
|
||||||
|
kind = LifecycleKindGamePaused
|
||||||
|
case GameStatusCancelled:
|
||||||
|
kind = LifecycleKindGameCancelled
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := "the game owner"
|
||||||
|
if callerIsAdmin {
|
||||||
|
actor = "an administrator"
|
||||||
|
}
|
||||||
|
ev := LifecycleEvent{
|
||||||
|
GameID: game.GameID,
|
||||||
|
Kind: kind,
|
||||||
|
Actor: actor,
|
||||||
|
Reason: rule.Reason,
|
||||||
|
}
|
||||||
|
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||||
|
s.deps.Logger.Warn("publish lifecycle mail failed",
|
||||||
|
zap.String("game_id", game.GameID.String()),
|
||||||
|
zap.String("kind", kind),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkOwner enforces ownership semantics:
|
// checkOwner enforces ownership semantics:
|
||||||
//
|
//
|
||||||
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ type Deps struct {
|
|||||||
Cache *Cache
|
Cache *Cache
|
||||||
Runtime RuntimeGateway
|
Runtime RuntimeGateway
|
||||||
Notification NotificationPublisher
|
Notification NotificationPublisher
|
||||||
|
Diplomail DiplomailPublisher
|
||||||
Entitlement EntitlementProvider
|
Entitlement EntitlementProvider
|
||||||
Policy *Policy
|
Policy *Policy
|
||||||
Config config.LobbyConfig
|
Config config.LobbyConfig
|
||||||
@@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) {
|
|||||||
if deps.Notification == nil {
|
if deps.Notification == nil {
|
||||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||||
}
|
}
|
||||||
|
if deps.Diplomail == nil {
|
||||||
|
deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
|
||||||
|
}
|
||||||
if deps.Policy == nil {
|
if deps.Policy == nil {
|
||||||
policy, err := NewPolicy()
|
policy, err := NewPolicy()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID,
|
|||||||
zap.String("membership_id", updated.MembershipID.String()),
|
zap.String("membership_id", updated.MembershipID.String()),
|
||||||
zap.Error(pubErr))
|
zap.Error(pubErr))
|
||||||
}
|
}
|
||||||
|
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
|
||||||
_ = game
|
_ = game
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
@@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus(
|
|||||||
zap.String("kind", notificationKind),
|
zap.String("kind", notificationKind),
|
||||||
zap.Error(pubErr))
|
zap.Error(pubErr))
|
||||||
}
|
}
|
||||||
|
s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "")
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitMembershipLifecycleMail asks the diplomail publisher to drop a
|
||||||
|
// durable explanation into the kicked player's inbox. The mail
|
||||||
|
// survives the membership row going to `removed` / `blocked` so the
|
||||||
|
// player keeps read access to it (soft-access rule, item 8).
|
||||||
|
func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) {
|
||||||
|
var kind string
|
||||||
|
switch newStatus {
|
||||||
|
case MembershipStatusRemoved:
|
||||||
|
kind = LifecycleKindMembershipRemoved
|
||||||
|
case MembershipStatusBlocked:
|
||||||
|
kind = LifecycleKindMembershipBlocked
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := "the game owner"
|
||||||
|
if callerIsAdmin {
|
||||||
|
actor = "an administrator"
|
||||||
|
}
|
||||||
|
target := membership.UserID
|
||||||
|
ev := LifecycleEvent{
|
||||||
|
GameID: membership.GameID,
|
||||||
|
Kind: kind,
|
||||||
|
Actor: actor,
|
||||||
|
Reason: reason,
|
||||||
|
TargetUser: &target,
|
||||||
|
}
|
||||||
|
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||||
|
s.deps.Logger.Warn("publish membership lifecycle mail failed",
|
||||||
|
zap.String("membership_id", membership.MembershipID.String()),
|
||||||
|
zap.String("kind", kind),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
|
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
|
||||||
if game.Visibility == VisibilityPublic {
|
if game.Visibility == VisibilityPublic {
|
||||||
// Public-game membership management is admin-only.
|
// Public-game membership management is admin-only.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
||||||
KindGameTurnReady = "game.turn.ready"
|
KindGameTurnReady = "game.turn.ready"
|
||||||
KindGamePaused = "game.paused"
|
KindGamePaused = "game.paused"
|
||||||
|
KindDiplomailReceived = "diplomail.message.received"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CatalogEntry describes the per-kind delivery policy: which channels
|
// CatalogEntry describes the per-kind delivery policy: which channels
|
||||||
@@ -103,6 +104,9 @@ var catalog = map[string]CatalogEntry{
|
|||||||
KindGamePaused: {
|
KindGamePaused: {
|
||||||
Channels: []string{ChannelPush},
|
Channels: []string{ChannelPush},
|
||||||
},
|
},
|
||||||
|
KindDiplomailReceived: {
|
||||||
|
Channels: []string{ChannelPush},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupCatalog returns the per-kind policy and a boolean reporting
|
// LookupCatalog returns the per-kind policy and a boolean reporting
|
||||||
@@ -133,5 +137,6 @@ func SupportedKinds() []string {
|
|||||||
KindRuntimeStartConfigInvalid,
|
KindRuntimeStartConfigInvalid,
|
||||||
KindGameTurnReady,
|
KindGameTurnReady,
|
||||||
KindGamePaused,
|
KindGamePaused,
|
||||||
|
KindDiplomailReceived,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func TestCatalogChannels(t *testing.T) {
|
|||||||
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
||||||
KindGameTurnReady: {ChannelPush},
|
KindGameTurnReady: {ChannelPush},
|
||||||
KindGamePaused: {ChannelPush},
|
KindGamePaused: {ChannelPush},
|
||||||
|
KindDiplomailReceived: {ChannelPush},
|
||||||
}
|
}
|
||||||
for kind, want := range expect {
|
for kind, want := range expect {
|
||||||
entry, ok := LookupCatalog(kind)
|
entry, ok := LookupCatalog(kind)
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ import (
|
|||||||
// payload is `{game_id, turn, reason}` consumed by the same in-game
|
// 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
|
// shell layout, so there is no value in dragging a FB schema in for
|
||||||
// one consumer.
|
// 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{
|
var jsonFriendlyKinds = map[string]bool{
|
||||||
KindGameTurnReady: true,
|
KindGameTurnReady: true,
|
||||||
KindGamePaused: true,
|
KindGamePaused: true,
|
||||||
|
KindDiplomailReceived: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||||
@@ -88,6 +94,17 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
|||||||
"turn": int32(7),
|
"turn": int32(7),
|
||||||
"reason": "generation_failed",
|
"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{}
|
seenKinds := map[string]bool{}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
SenderRaceName *string
|
||||||
|
SenderIP string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
BodyLang string
|
||||||
|
BroadcastScope string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
RecipientPreferredLanguage string
|
||||||
|
AvailableAt *time.Time
|
||||||
|
TranslationAttempts int32
|
||||||
|
NextTranslationAttemptAt *time.Time
|
||||||
|
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,117 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
SenderRaceName 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")
|
||||||
|
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
|
||||||
|
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, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, 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,
|
||||||
|
SenderRaceName: SenderRaceNameColumn,
|
||||||
|
SenderIP: SenderIPColumn,
|
||||||
|
Subject: SubjectColumn,
|
||||||
|
Body: BodyColumn,
|
||||||
|
BodyLang: BodyLangColumn,
|
||||||
|
BroadcastScope: BroadcastScopeColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
RecipientPreferredLanguage postgres.ColumnString
|
||||||
|
AvailableAt postgres.ColumnTimestampz
|
||||||
|
TranslationAttempts postgres.ColumnInteger
|
||||||
|
NextTranslationAttemptAt postgres.ColumnTimestampz
|
||||||
|
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")
|
||||||
|
RecipientPreferredLanguageColumn = postgres.StringColumn("recipient_preferred_language")
|
||||||
|
AvailableAtColumn = postgres.TimestampzColumn("available_at")
|
||||||
|
TranslationAttemptsColumn = postgres.IntegerColumn("translation_attempts")
|
||||||
|
NextTranslationAttemptAtColumn = postgres.TimestampzColumn("next_translation_attempt_at")
|
||||||
|
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, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{RecipientPreferredLanguageColumn, TranslationAttemptsColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return diplomailRecipientsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
RecipientID: RecipientIDColumn,
|
||||||
|
MessageID: MessageIDColumn,
|
||||||
|
GameID: GameIDColumn,
|
||||||
|
UserID: UserIDColumn,
|
||||||
|
RecipientUserName: RecipientUserNameColumn,
|
||||||
|
RecipientRaceName: RecipientRaceNameColumn,
|
||||||
|
RecipientPreferredLanguage: RecipientPreferredLanguageColumn,
|
||||||
|
AvailableAt: AvailableAtColumn,
|
||||||
|
TranslationAttempts: TranslationAttemptsColumn,
|
||||||
|
NextTranslationAttemptAt: NextTranslationAttemptAtColumn,
|
||||||
|
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)
|
AuthChallenges = AuthChallenges.FromSchema(schema)
|
||||||
BlockedEmails = BlockedEmails.FromSchema(schema)
|
BlockedEmails = BlockedEmails.FromSchema(schema)
|
||||||
DeviceSessions = DeviceSessions.FromSchema(schema)
|
DeviceSessions = DeviceSessions.FromSchema(schema)
|
||||||
|
DiplomailMessages = DiplomailMessages.FromSchema(schema)
|
||||||
|
DiplomailRecipients = DiplomailRecipients.FromSchema(schema)
|
||||||
|
DiplomailTranslations = DiplomailTranslations.FromSchema(schema)
|
||||||
EngineVersions = EngineVersions.FromSchema(schema)
|
EngineVersions = EngineVersions.FromSchema(schema)
|
||||||
EntitlementRecords = EntitlementRecords.FromSchema(schema)
|
EntitlementRecords = EntitlementRecords.FromSchema(schema)
|
||||||
EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema)
|
EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema)
|
||||||
|
|||||||
@@ -606,7 +606,8 @@ CREATE TABLE notifications (
|
|||||||
'lobby.race_name.expired',
|
'lobby.race_name.expired',
|
||||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||||
'runtime.start_config_invalid',
|
'runtime.start_config_invalid',
|
||||||
'game.turn.ready', 'game.paused'
|
'game.turn.ready', 'game.paused',
|
||||||
|
'diplomail.message.received'
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -662,6 +663,126 @@ CREATE TABLE notification_malformed_intents (
|
|||||||
CREATE INDEX notification_malformed_intents_listing_idx
|
CREATE INDEX notification_malformed_intents_listing_idx
|
||||||
ON notification_malformed_intents (received_at DESC);
|
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_race_name is the immutable snapshot of the sender's race
|
||||||
|
-- in this game, captured at insert time when sender_kind='player'.
|
||||||
|
-- Admin and system messages carry NULL. The Phase 28 mail UI keys
|
||||||
|
-- per-race threading on this column.
|
||||||
|
sender_race_name 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)
|
||||||
|
),
|
||||||
|
-- sender_race_name is only meaningful for player senders. Admin
|
||||||
|
-- and system rows never carry a race; player rows carry one when
|
||||||
|
-- the sender has an active membership at send time (a non-playing
|
||||||
|
-- private-game owner may legitimately have none).
|
||||||
|
CONSTRAINT diplomail_messages_sender_race_chk CHECK (
|
||||||
|
sender_kind = 'player' OR sender_race_name IS NULL
|
||||||
|
),
|
||||||
|
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
||||||
|
(kind = 'personal' AND sender_kind = 'player') OR
|
||||||
|
(kind = 'admin' AND sender_kind IN ('player', '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,
|
||||||
|
recipient_preferred_language text NOT NULL DEFAULT '',
|
||||||
|
available_at timestamptz,
|
||||||
|
translation_attempts integer NOT NULL DEFAULT 0,
|
||||||
|
next_translation_attempt_at timestamptz,
|
||||||
|
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 AND available_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Index drives the translation worker's pending-pair pickup. The
|
||||||
|
-- partial filter keeps the scan tight: terminal-state recipients
|
||||||
|
-- (with a non-NULL available_at) never appear in this btree. The
|
||||||
|
-- composite ordering puts the next-attempt clock first so the
|
||||||
|
-- backoff filter (`next_translation_attempt_at <= now()`) seeks
|
||||||
|
-- before the secondary cluster on (message_id, lang).
|
||||||
|
CREATE INDEX diplomail_recipients_pending_translation_idx
|
||||||
|
ON diplomail_recipients (next_translation_attempt_at, message_id, recipient_preferred_language)
|
||||||
|
WHERE available_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
|
-- Geo domain
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|||||||
@@ -1,26 +1,46 @@
|
|||||||
# Backend migrations
|
# Backend migrations
|
||||||
|
|
||||||
Goose migrations embedded into the backend binary by `embed.go`. Applied
|
Goose (`pressly/goose/v3`) migrations embedded into the backend binary
|
||||||
at startup before any listener opens (see `internal/postgres`).
|
by `embed.go`. Applied at startup before any listener opens — see
|
||||||
|
`internal/postgres`.
|
||||||
|
|
||||||
## Pre-production single-file rule
|
## Authoring conventions
|
||||||
|
|
||||||
**While the platform is not yet in production, every schema change goes
|
- Each schema change is a new file with a monotonically increasing
|
||||||
into the existing `00001_init.sql` file** rather than a new
|
numeric prefix and a snake-case slug:
|
||||||
`00002_*`-prefixed file. The intent is to keep the schema in one
|
`0000N_short_description.sql`. Reuse of a prefix is forbidden once
|
||||||
canonical place so reviewers and developers do not have to reconstruct
|
the file is merged.
|
||||||
the latest shape from a chain of incremental migrations.
|
- `00001_init.sql` is the historical baseline. Treat it as immutable
|
||||||
|
history; do not edit it to land new schema. Squashing the chain back
|
||||||
|
into a fresh `00001` is reserved for the explicit pre-production
|
||||||
|
cut-over.
|
||||||
|
- Every file MUST contain both an `-- +goose Up` and `-- +goose Down`
|
||||||
|
section, even if Down is a single `DROP …` for the same artefacts.
|
||||||
|
Down migrations are exercised by the schema test and serve as the
|
||||||
|
documented rollback path.
|
||||||
|
- Destructive changes (dropping columns/tables, renaming with data
|
||||||
|
loss) MUST be split into at least two migrations so the chain stays
|
||||||
|
rollable forward and backward without coordinated code+schema
|
||||||
|
windows:
|
||||||
|
1. add the new shape, dual-write the data, leave the old shape in
|
||||||
|
place;
|
||||||
|
2. once all readers have switched, drop the old shape in a follow-up
|
||||||
|
migration.
|
||||||
|
- Migrations are applied automatically on backend startup, so a fresh
|
||||||
|
push to `development` plus the `dev-deploy.yaml` workflow brings the
|
||||||
|
long-lived dev database up to head without manual intervention.
|
||||||
|
`make -C tools/dev-deploy clean-data` is only needed when a developer
|
||||||
|
deliberately wants a fresh database.
|
||||||
|
- The integration harness (`backend/internal/postgres/migrations_test.go`)
|
||||||
|
spins up a disposable Postgres per run and asserts the final table
|
||||||
|
set. When a migration adds or removes tables, update the expected
|
||||||
|
list in the same patch.
|
||||||
|
|
||||||
Operationally this means that pulling a branch with schema changes
|
## Pre-production squash
|
||||||
requires a fresh database — the only consumer today is local development
|
|
||||||
and integration tests, both of which spin up disposable Postgres
|
|
||||||
instances.
|
|
||||||
|
|
||||||
> **Remove this rule before the first production deployment.** From
|
The chain may be squashed back into one clean `00001_init.sql` before
|
||||||
> that point on every schema change must be a new migration file with a
|
the first production deployment. That is a deliberate, one-time
|
||||||
> monotonically increasing prefix, and `00001_init.sql` becomes
|
operation; until then, additive numbered files are the rule. After the
|
||||||
> immutable history.
|
squash this file gets a short note that `00001_init.sql` represents
|
||||||
|
the production baseline and the policy above continues to apply for
|
||||||
If you need to make a change, edit `00001_init.sql` directly. Down
|
every later migration.
|
||||||
migrations should still be kept in sync (they live at the bottom of the
|
|
||||||
file — currently a single `DROP SCHEMA backend CASCADE`).
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ var expectedBackendTables = []string{
|
|||||||
"notification_malformed_intents",
|
"notification_malformed_intents",
|
||||||
"notification_routes",
|
"notification_routes",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
// Diplomail domain.
|
||||||
|
"diplomail_messages",
|
||||||
|
"diplomail_recipients",
|
||||||
|
"diplomail_translations",
|
||||||
// Geo domain.
|
// Geo domain.
|
||||||
"user_country_counters",
|
"user_country_counters",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,10 +537,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
|
|||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GAME_STATE_PATH": statePath,
|
"GAME_STATE_PATH": statePath,
|
||||||
},
|
},
|
||||||
Labels: map[string]string{
|
Labels: s.engineLabels(gameID.String(), version.Version),
|
||||||
"galaxy.game_id": gameID.String(),
|
|
||||||
"galaxy.engine_version": version.Version,
|
|
||||||
},
|
|
||||||
BindMounts: []dockerclient.BindMount{
|
BindMounts: []dockerclient.BindMount{
|
||||||
{
|
{
|
||||||
HostPath: hostStatePath,
|
HostPath: hostStatePath,
|
||||||
@@ -735,10 +732,7 @@ func (s *Service) runPatch(ctx context.Context, op OperationLog, target EngineVe
|
|||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GAME_STATE_PATH": statePath,
|
"GAME_STATE_PATH": statePath,
|
||||||
},
|
},
|
||||||
Labels: map[string]string{
|
Labels: s.engineLabels(op.GameID.String(), target.Version),
|
||||||
"galaxy.game_id": op.GameID.String(),
|
|
||||||
"galaxy.engine_version": target.Version,
|
|
||||||
},
|
|
||||||
BindMounts: []dockerclient.BindMount{
|
BindMounts: []dockerclient.BindMount{
|
||||||
{HostPath: hostStatePath, MountPath: s.deps.Config.ContainerStateMount},
|
{HostPath: hostStatePath, MountPath: s.deps.Config.ContainerStateMount},
|
||||||
},
|
},
|
||||||
@@ -938,6 +932,30 @@ func (s *Service) upsertRuntimeRecord(ctx context.Context, in runtimeRecordInser
|
|||||||
// containers attach to. Wired from cfg.Docker.Network through Deps.
|
// containers attach to. Wired from cfg.Docker.Network through Deps.
|
||||||
func (s *Service) dockerNetwork() string { return s.deps.DockerNetwork }
|
func (s *Service) dockerNetwork() string { return s.deps.DockerNetwork }
|
||||||
|
|
||||||
|
// engineLabels returns the label set stamped on every engine container
|
||||||
|
// spawned for gameID running engineVersion. The runtime adapter merges
|
||||||
|
// `dockerclient.ManagedLabel` separately; this helper covers the
|
||||||
|
// game-scoped labels plus an optional `galaxy.stack=<value>` from the
|
||||||
|
// runtime config so host-side tooling can scope cleanup by dev stack
|
||||||
|
// without touching unrelated workloads.
|
||||||
|
func (s *Service) engineLabels(gameID, engineVersion string) map[string]string {
|
||||||
|
return engineLabels(gameID, engineVersion, s.deps.Config.StackLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineLabels is the side-effect-free part of `(*Service).engineLabels`,
|
||||||
|
// exposed at package scope so unit tests can exercise the labelling
|
||||||
|
// rules without building a full Service.
|
||||||
|
func engineLabels(gameID, engineVersion, stackLabel string) map[string]string {
|
||||||
|
labels := map[string]string{
|
||||||
|
"galaxy.game_id": gameID,
|
||||||
|
"galaxy.engine_version": engineVersion,
|
||||||
|
}
|
||||||
|
if stackLabel != "" {
|
||||||
|
labels["galaxy.stack"] = stackLabel
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
// waitForEngineHealthz polls the engine `/healthz` endpoint until it
|
// waitForEngineHealthz polls the engine `/healthz` endpoint until it
|
||||||
// responds 2xx or until the timeout elapses. The Docker daemon
|
// responds 2xx or until the timeout elapses. The Docker daemon
|
||||||
// reports a container as `running` as soon as the entrypoint starts,
|
// reports a container as `running` as soon as the entrypoint starts,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEngineLabels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
gameID string
|
||||||
|
version string
|
||||||
|
stackLabel string
|
||||||
|
want map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "stack label omitted when empty",
|
||||||
|
gameID: "11111111-1111-1111-1111-111111111111",
|
||||||
|
version: "0.1.0",
|
||||||
|
stackLabel: "",
|
||||||
|
want: map[string]string{
|
||||||
|
"galaxy.game_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"galaxy.engine_version": "0.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stack label included when set",
|
||||||
|
gameID: "22222222-2222-2222-2222-222222222222",
|
||||||
|
version: "0.2.3",
|
||||||
|
stackLabel: "dev-deploy",
|
||||||
|
want: map[string]string{
|
||||||
|
"galaxy.game_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"galaxy.engine_version": "0.2.3",
|
||||||
|
"galaxy.stack": "dev-deploy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := engineLabels(tc.gameID, tc.version, tc.stackLabel)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("len(labels) = %d, want %d (got %v)", len(got), len(tc.want), got)
|
||||||
|
}
|
||||||
|
for k, v := range tc.want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("labels[%q] = %q, want %q", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ var pathParamStubs = map[string]string{
|
|||||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||||
"battle_id": "00000000-0000-0000-0000-000000000009",
|
"battle_id": "00000000-0000-0000-0000-000000000009",
|
||||||
|
"message_id": "00000000-0000-0000-0000-00000000000a",
|
||||||
"id": "1.2.3",
|
"id": "1.2.3",
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"turn": "42",
|
"turn": "42",
|
||||||
@@ -149,6 +150,35 @@ var requestBodyStubs = map[string]map[string]any{
|
|||||||
"user_id": pathParamStubs["user_id"],
|
"user_id": pathParamStubs["user_id"],
|
||||||
"reason": "ToS violation",
|
"reason": "ToS violation",
|
||||||
},
|
},
|
||||||
|
"userMailSendPersonal": {
|
||||||
|
"recipient_user_id": pathParamStubs["user_id"],
|
||||||
|
"subject": "Contract test subject",
|
||||||
|
"body": "Contract test body",
|
||||||
|
},
|
||||||
|
"userMailSendAdmin": {
|
||||||
|
"target": "user",
|
||||||
|
"recipient_user_id": pathParamStubs["user_id"],
|
||||||
|
"subject": "Contract test admin subject",
|
||||||
|
"body": "Contract test admin body",
|
||||||
|
},
|
||||||
|
"adminDiplomailSend": {
|
||||||
|
"target": "user",
|
||||||
|
"recipient_user_id": pathParamStubs["user_id"],
|
||||||
|
"subject": "Contract test admin subject",
|
||||||
|
"body": "Contract test admin body",
|
||||||
|
},
|
||||||
|
"userMailSendBroadcast": {
|
||||||
|
"subject": "Contract test paid broadcast",
|
||||||
|
"body": "Contract test paid broadcast body",
|
||||||
|
},
|
||||||
|
"adminDiplomailBroadcast": {
|
||||||
|
"scope": "all_running",
|
||||||
|
"subject": "Contract test multi-game broadcast",
|
||||||
|
"body": "Contract test multi-game broadcast body",
|
||||||
|
},
|
||||||
|
"adminDiplomailCleanup": {
|
||||||
|
"older_than_years": 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/basicauth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed
|
||||||
|
// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send /
|
||||||
|
// broadcast). The handler is intentionally separate from
|
||||||
|
// `AdminMailHandlers`, which owns the unrelated email outbox surface
|
||||||
|
// under `/api/v1/admin/mail/*`.
|
||||||
|
type AdminDiplomailHandlers struct {
|
||||||
|
svc *diplomail.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminDiplomailHandlers constructs the handler set. svc may be
|
||||||
|
// nil — in that case every handler returns 501 not_implemented.
|
||||||
|
func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send handles POST /api/v1/admin/games/{game_id}/mail. The body
|
||||||
|
// shape mirrors the owner route: `target="user"` requires
|
||||||
|
// `recipient_user_id`; `target="all"` accepts an optional
|
||||||
|
// `recipients` scope. The authenticated admin username is captured
|
||||||
|
// from the basicauth context and persisted as `sender_username`.
|
||||||
|
func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
||||||
|
if h.svc == nil {
|
||||||
|
return handlers.NotImplemented("adminDiplomailSend")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
if !ok || username == "" {
|
||||||
|
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req userMailSendAdminRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
switch req.Target {
|
||||||
|
case "", "user":
|
||||||
|
var recipientID uuid.UUID
|
||||||
|
if req.RecipientUserID != "" {
|
||||||
|
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
|
if parseErr != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientID = parsed
|
||||||
|
}
|
||||||
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindAdmin,
|
||||||
|
CallerUsername: username,
|
||||||
|
RecipientUserID: recipientID,
|
||||||
|
RecipientRaceName: req.RecipientRaceName,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||||
|
case "all":
|
||||||
|
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindAdmin,
|
||||||
|
CallerUsername: username,
|
||||||
|
RecipientScope: req.Recipients,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||||
|
default:
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast handles POST /api/v1/admin/mail/broadcast. Body:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "scope": "selected" | "all_running",
|
||||||
|
// "game_ids": ["..."],
|
||||||
|
// "recipients": "active" | "active_and_removed" | "all_members",
|
||||||
|
// "subject": "...",
|
||||||
|
// "body": "..."
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The handler routes through SendAdminMultiGameBroadcast and returns
|
||||||
|
// a fan-out receipt describing the message ids created and the
|
||||||
|
// total recipient count.
|
||||||
|
func (h *AdminDiplomailHandlers) Broadcast() gin.HandlerFunc {
|
||||||
|
if h.svc == nil {
|
||||||
|
return handlers.NotImplemented("adminDiplomailBroadcast")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
if !ok || username == "" {
|
||||||
|
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req adminDiplomailBroadcastRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameIDs := make([]uuid.UUID, 0, len(req.GameIDs))
|
||||||
|
for _, raw := range req.GameIDs {
|
||||||
|
parsed, err := uuid.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_ids must be valid UUIDs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameIDs = append(gameIDs, parsed)
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
msgs, total, err := h.svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{
|
||||||
|
CallerUsername: username,
|
||||||
|
Scope: req.Scope,
|
||||||
|
GameIDs: gameIDs,
|
||||||
|
RecipientScope: req.Recipients,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail broadcast", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := adminDiplomailBroadcastResponseWire{
|
||||||
|
RecipientCount: total,
|
||||||
|
Messages: make([]adminDiplomailBroadcastMessageWire, 0, len(msgs)),
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
out.Messages = append(out.Messages, adminDiplomailBroadcastMessageWire{
|
||||||
|
MessageID: m.MessageID.String(),
|
||||||
|
GameID: m.GameID.String(),
|
||||||
|
GameName: m.GameName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup handles POST /api/v1/admin/mail/cleanup. Body:
|
||||||
|
//
|
||||||
|
// { "older_than_years": 1 }
|
||||||
|
//
|
||||||
|
// The endpoint removes every diplomail_messages row whose game
|
||||||
|
// finished more than the supplied number of years ago. The cascade
|
||||||
|
// on the recipient and translation tables prunes the per-user state
|
||||||
|
// in the same transaction. Returns a CleanupResult envelope.
|
||||||
|
func (h *AdminDiplomailHandlers) Cleanup() gin.HandlerFunc {
|
||||||
|
if h.svc == nil {
|
||||||
|
return handlers.NotImplemented("adminDiplomailCleanup")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
if !ok || username == "" {
|
||||||
|
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = username
|
||||||
|
var req adminDiplomailCleanupRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
result, err := h.svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: req.OlderThanYears})
|
||||||
|
if err != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail cleanup", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := adminDiplomailCleanupResponseWire{
|
||||||
|
MessagesDeleted: result.MessagesDeleted,
|
||||||
|
GameIDs: make([]string, 0, len(result.GameIDs)),
|
||||||
|
}
|
||||||
|
for _, id := range result.GameIDs {
|
||||||
|
out.GameIDs = append(out.GameIDs, id.String())
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/v1/admin/mail/messages. Supports pagination
|
||||||
|
// via `page` and `page_size`, plus optional `game_id`, `kind`, and
|
||||||
|
// `sender_kind` filters.
|
||||||
|
func (h *AdminDiplomailHandlers) List() gin.HandlerFunc {
|
||||||
|
if h.svc == nil {
|
||||||
|
return handlers.NotImplemented("adminDiplomailList")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
if !ok || username == "" {
|
||||||
|
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := diplomail.AdminMessageListing{
|
||||||
|
Page: parsePositiveQueryInt(c.Query("page"), 1),
|
||||||
|
PageSize: parsePositiveQueryInt(c.Query("page_size"), 50),
|
||||||
|
Kind: c.Query("kind"),
|
||||||
|
SenderKind: c.Query("sender_kind"),
|
||||||
|
}
|
||||||
|
if raw := c.Query("game_id"); raw != "" {
|
||||||
|
parsed, err := uuid.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.GameID = &parsed
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
page, err := h.svc.ListMessagesForAdmin(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail list", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := adminDiplomailListResponseWire{
|
||||||
|
Total: page.Total,
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Items: make([]adminDiplomailMessageWire, 0, len(page.Items)),
|
||||||
|
}
|
||||||
|
for _, m := range page.Items {
|
||||||
|
entry := adminDiplomailMessageWire{
|
||||||
|
MessageID: m.MessageID.String(),
|
||||||
|
GameID: m.GameID.String(),
|
||||||
|
GameName: m.GameName,
|
||||||
|
Kind: m.Kind,
|
||||||
|
SenderKind: m.SenderKind,
|
||||||
|
SenderIP: m.SenderIP,
|
||||||
|
Subject: m.Subject,
|
||||||
|
Body: m.Body,
|
||||||
|
BodyLang: m.BodyLang,
|
||||||
|
BroadcastScope: m.BroadcastScope,
|
||||||
|
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||||
|
}
|
||||||
|
if m.SenderUserID != nil {
|
||||||
|
s := m.SenderUserID.String()
|
||||||
|
entry.SenderUserID = &s
|
||||||
|
}
|
||||||
|
if m.SenderUsername != nil {
|
||||||
|
s := *m.SenderUsername
|
||||||
|
entry.SenderUsername = &s
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, entry)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailBroadcastRequestWire struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
GameIDs []string `json:"game_ids,omitempty"`
|
||||||
|
Recipients string `json:"recipients,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailBroadcastMessageWire struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
GameID string `json:"game_id"`
|
||||||
|
GameName string `json:"game_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailBroadcastResponseWire struct {
|
||||||
|
RecipientCount int `json:"recipient_count"`
|
||||||
|
Messages []adminDiplomailBroadcastMessageWire `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailCleanupRequestWire struct {
|
||||||
|
OlderThanYears int `json:"older_than_years"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailCleanupResponseWire struct {
|
||||||
|
MessagesDeleted int `json:"messages_deleted"`
|
||||||
|
GameIDs []string `json:"game_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailMessageWire 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"`
|
||||||
|
SenderIP string `json:"sender_ip,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminDiplomailListResponseWire struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Items []adminDiplomailMessageWire `json:"items"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
"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"
|
||||||
|
"galaxy/backend/internal/user"
|
||||||
|
|
||||||
|
"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 the
|
||||||
|
// personal subset; Stage B adds the owner-only admin send path,
|
||||||
|
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
|
||||||
|
// to resolve the owner's `user_name` for the `sender_username` column.
|
||||||
|
type UserMailHandlers struct {
|
||||||
|
svc *diplomail.Service
|
||||||
|
lobby *lobby.Service
|
||||||
|
users *user.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserMailHandlers constructs the handler set. svc may be nil — in
|
||||||
|
// that case every handler returns 501 not_implemented. lobby and
|
||||||
|
// users are optional: when either is nil the admin-send handler
|
||||||
|
// degrades to 501 (the personal-send and read paths stay functional).
|
||||||
|
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &UserMailHandlers{
|
||||||
|
svc: svc,
|
||||||
|
lobby: lobbySvc,
|
||||||
|
users: users,
|
||||||
|
logger: logger.Named("http.user.mail"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferredLanguage looks up the caller's `accounts.preferred_language`
|
||||||
|
// so the per-message read can attach the cached translation when
|
||||||
|
// available. Failures are logged at debug level and the function
|
||||||
|
// returns an empty string — translation is best-effort and the
|
||||||
|
// caller still receives the original body.
|
||||||
|
func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string {
|
||||||
|
if h.users == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
account, err := h.users.GetAccount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Debug("resolve preferred_language failed",
|
||||||
|
zap.String("user_id", userID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return account.PreferredLanguage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
var recipientID uuid.UUID
|
||||||
|
if req.RecipientUserID != "" {
|
||||||
|
parsed, perr := uuid.Parse(req.RecipientUserID)
|
||||||
|
if perr != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientID = parsed
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: userID,
|
||||||
|
RecipientUserID: recipientID,
|
||||||
|
RecipientRaceName: req.RecipientRaceName,
|
||||||
|
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()
|
||||||
|
targetLang := h.preferredLanguage(ctx, userID)
|
||||||
|
entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang)
|
||||||
|
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()
|
||||||
|
targetLang := h.preferredLanguage(ctx, userID)
|
||||||
|
items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang)
|
||||||
|
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([]userMailMessageDetailWire, 0, len(items))}
|
||||||
|
for _, entry := range items {
|
||||||
|
out.Items = append(out.Items, mailMessageDetailToWire(entry, false))
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast.
|
||||||
|
//
|
||||||
|
// The endpoint is the paid-tier player broadcast: any player on a
|
||||||
|
// non-`free` entitlement tier may send one personal message that
|
||||||
|
// fans out to every other active member of the game. The result
|
||||||
|
// rows carry `kind="personal"`, `sender_kind="player"`,
|
||||||
|
// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403.
|
||||||
|
func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc {
|
||||||
|
if h.svc == nil {
|
||||||
|
return handlers.NotImplemented("userMailSendBroadcast")
|
||||||
|
}
|
||||||
|
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 userMailSendBroadcastRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: userID,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
|
||||||
|
//
|
||||||
|
// Owner-only: the caller must be the owner of the private game. The
|
||||||
|
// handler resolves the owner's `user_name` so the
|
||||||
|
// `sender_username` column carries a useful identity, then routes to
|
||||||
|
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
|
||||||
|
// `target="all"`). Site administrators use the separate admin route
|
||||||
|
// in `handlers_admin_mail_send.go`.
|
||||||
|
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
||||||
|
if h.svc == nil || h.lobby == nil || h.users == nil {
|
||||||
|
return handlers.NotImplemented("userMailSendAdmin")
|
||||||
|
}
|
||||||
|
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 userMailSendAdminRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
game, err := h.lobby.GetGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if game.OwnerUserID == nil || *game.OwnerUserID != userID {
|
||||||
|
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
account, err := h.users.GetAccount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Target {
|
||||||
|
case "", "user":
|
||||||
|
var recipientID uuid.UUID
|
||||||
|
if req.RecipientUserID != "" {
|
||||||
|
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
|
if parseErr != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientID = parsed
|
||||||
|
}
|
||||||
|
callerUserID := userID
|
||||||
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindOwner,
|
||||||
|
CallerUserID: &callerUserID,
|
||||||
|
CallerUsername: account.UserName,
|
||||||
|
RecipientUserID: recipientID,
|
||||||
|
RecipientRaceName: req.RecipientRaceName,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||||
|
case "all":
|
||||||
|
callerUserID := userID
|
||||||
|
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindOwner,
|
||||||
|
CallerUserID: &callerUserID,
|
||||||
|
CallerUsername: account.UserName,
|
||||||
|
RecipientScope: req.Recipients,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||||
|
default:
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Exactly one of `recipient_user_id` and `recipient_race_name` must
|
||||||
|
// be supplied; the service rejects ambiguous and empty inputs.
|
||||||
|
type userMailSendRequestWire struct {
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMailSendBroadcastRequestWire mirrors the request body for the
|
||||||
|
// paid-tier player broadcast. There is no `target` discriminator —
|
||||||
|
// the recipient set is always "every other active member".
|
||||||
|
type userMailSendBroadcastRequestWire struct {
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMailSendAdminRequestWire mirrors the request body for the
|
||||||
|
// owner-only admin send. `target="user"` requires exactly one of
|
||||||
|
// `recipient_user_id` and `recipient_race_name`; `target="all"`
|
||||||
|
// accepts the optional `recipients` scope (default `active`).
|
||||||
|
type userMailSendAdminRequestWire struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
|
Recipients string `json:"recipients,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMailBroadcastReceiptWire is the response shape returned after a
|
||||||
|
// successful broadcast. It carries the canonical message metadata
|
||||||
|
// together with the count of materialised recipient rows so the
|
||||||
|
// caller (UI, admin tool) can confirm the fan-out happened.
|
||||||
|
type userMailBroadcastReceiptWire 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"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
BodyLang string `json:"body_lang"`
|
||||||
|
BroadcastScope string `json:"broadcast_scope"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RecipientCount int `json:"recipient_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
|
||||||
|
return userMailBroadcastReceiptWire{
|
||||||
|
MessageID: m.MessageID.String(),
|
||||||
|
GameID: m.GameID.String(),
|
||||||
|
GameName: m.GameName,
|
||||||
|
Kind: m.Kind,
|
||||||
|
SenderKind: m.SenderKind,
|
||||||
|
Subject: m.Subject,
|
||||||
|
Body: m.Body,
|
||||||
|
BodyLang: m.BodyLang,
|
||||||
|
BroadcastScope: m.BroadcastScope,
|
||||||
|
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||||
|
RecipientCount: len(recipients),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Translation fields are populated when a cached rendering exists
|
||||||
|
// for the caller's `preferred_language`; the UI renders
|
||||||
|
// `body_translated` and surfaces the original through a
|
||||||
|
// "show original" toggle.
|
||||||
|
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"`
|
||||||
|
SenderRaceName *string `json:"sender_race_name,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"`
|
||||||
|
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
||||||
|
TranslatedBody *string `json:"translated_body,omitempty"`
|
||||||
|
TranslationLang *string `json:"translation_lang,omitempty"`
|
||||||
|
Translator *string `json:"translator,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userMailInboxListWire struct {
|
||||||
|
Items []userMailMessageDetailWire `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMailSentListWire mirrors the response shape for the
|
||||||
|
// sender-side listing. Phase 28's in-game UI threads sent messages
|
||||||
|
// by the recipient's race name, so the wire carries the full
|
||||||
|
// message detail (including the recipient snapshot) — single sends
|
||||||
|
// contribute one row per message, broadcasts contribute one row per
|
||||||
|
// addressee and the UI collapses them by `message_id`.
|
||||||
|
type userMailSentListWire struct {
|
||||||
|
Items []userMailMessageDetailWire `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.SenderRaceName != nil {
|
||||||
|
s := *entry.SenderRaceName
|
||||||
|
out.SenderRaceName = &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
|
||||||
|
}
|
||||||
|
if entry.Translation != nil {
|
||||||
|
tr := entry.Translation
|
||||||
|
subj := tr.TranslatedSubject
|
||||||
|
body := tr.TranslatedBody
|
||||||
|
lang := tr.TargetLang
|
||||||
|
engine := tr.Translator
|
||||||
|
out.TranslatedSubject = &subj
|
||||||
|
out.TranslatedBody = &body
|
||||||
|
out.TranslationLang = &lang
|
||||||
|
out.Translator = &engine
|
||||||
|
}
|
||||||
|
_ = justCreated
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
UserLobbyMy *UserLobbyMyHandlers
|
||||||
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
||||||
UserGames *UserGamesHandlers
|
UserGames *UserGamesHandlers
|
||||||
|
UserMail *UserMailHandlers
|
||||||
UserSessions *UserSessionsHandlers
|
UserSessions *UserSessionsHandlers
|
||||||
AdminAdminAccounts *AdminAdminAccountsHandlers
|
AdminAdminAccounts *AdminAdminAccountsHandlers
|
||||||
AdminUsers *AdminUsersHandlers
|
AdminUsers *AdminUsersHandlers
|
||||||
@@ -75,6 +76,7 @@ type RouterDependencies struct {
|
|||||||
AdminRuntimes *AdminRuntimesHandlers
|
AdminRuntimes *AdminRuntimesHandlers
|
||||||
AdminEngineVersions *AdminEngineVersionsHandlers
|
AdminEngineVersions *AdminEngineVersionsHandlers
|
||||||
AdminMail *AdminMailHandlers
|
AdminMail *AdminMailHandlers
|
||||||
|
AdminDiplomail *AdminDiplomailHandlers
|
||||||
AdminNotifications *AdminNotificationsHandlers
|
AdminNotifications *AdminNotificationsHandlers
|
||||||
AdminGeo *AdminGeoHandlers
|
AdminGeo *AdminGeoHandlers
|
||||||
InternalSessions *InternalSessionsHandlers
|
InternalSessions *InternalSessionsHandlers
|
||||||
@@ -163,6 +165,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
|||||||
if deps.UserGames == nil {
|
if deps.UserGames == nil {
|
||||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||||
}
|
}
|
||||||
|
if deps.UserMail == nil {
|
||||||
|
deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger)
|
||||||
|
}
|
||||||
if deps.UserSessions == nil {
|
if deps.UserSessions == nil {
|
||||||
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
|||||||
if deps.AdminMail == nil {
|
if deps.AdminMail == nil {
|
||||||
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
||||||
}
|
}
|
||||||
|
if deps.AdminDiplomail == nil {
|
||||||
|
deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger)
|
||||||
|
}
|
||||||
if deps.AdminNotifications == nil {
|
if deps.AdminNotifications == nil {
|
||||||
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
||||||
}
|
}
|
||||||
@@ -255,6 +263,9 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
|||||||
my.GET("/invites", deps.UserLobbyMy.Invites())
|
my.GET("/invites", deps.UserLobbyMy.Invites())
|
||||||
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
|
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
|
||||||
|
|
||||||
|
lobbyMail := lobbyGroup.Group("/mail")
|
||||||
|
lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts())
|
||||||
|
|
||||||
raceNames := lobbyGroup.Group("/race-names")
|
raceNames := lobbyGroup.Group("/race-names")
|
||||||
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
||||||
|
|
||||||
@@ -265,6 +276,16 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
|||||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||||
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
||||||
|
|
||||||
|
userMail := userGames.Group("/:game_id/mail")
|
||||||
|
userMail.POST("/messages", deps.UserMail.SendPersonal())
|
||||||
|
userMail.POST("/broadcast", deps.UserMail.SendBroadcast())
|
||||||
|
userMail.POST("/admin", deps.UserMail.SendAdmin())
|
||||||
|
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 := group.Group("/sessions")
|
||||||
userSessions.GET("", deps.UserSessions.List())
|
userSessions.GET("", deps.UserSessions.List())
|
||||||
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
|
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
|
||||||
@@ -299,6 +320,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
|||||||
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
||||||
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
||||||
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
||||||
|
games.POST("/:game_id/mail", deps.AdminDiplomail.Send())
|
||||||
|
|
||||||
runtimes := group.Group("/runtimes")
|
runtimes := group.Group("/runtimes")
|
||||||
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
||||||
@@ -318,6 +340,9 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
|||||||
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
||||||
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
||||||
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
||||||
|
mail.GET("/messages", deps.AdminDiplomail.List())
|
||||||
|
mail.POST("/broadcast", deps.AdminDiplomail.Broadcast())
|
||||||
|
mail.POST("/cleanup", deps.AdminDiplomail.Cleanup())
|
||||||
|
|
||||||
notifications := group.Group("/notifications")
|
notifications := group.Group("/notifications")
|
||||||
notifications.GET("", deps.AdminNotifications.List())
|
notifications.GET("", deps.AdminNotifications.List())
|
||||||
|
|||||||
@@ -1144,6 +1144,295 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$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/broadcast:
|
||||||
|
post:
|
||||||
|
tags: [User]
|
||||||
|
operationId: userMailSendBroadcast
|
||||||
|
summary: Send a paid-tier personal broadcast to a game's active members
|
||||||
|
description: |
|
||||||
|
Paid-tier players (`entitlement.is_paid == true`) may send one
|
||||||
|
personal message that fans out to every other active member of
|
||||||
|
the game. Free-tier callers receive 403. The resulting rows
|
||||||
|
carry `kind="personal"`, `sender_kind="player"`,
|
||||||
|
`broadcast_scope="game_broadcast"`. Recipients reply through
|
||||||
|
the regular personal-send endpoint; the reply targets the
|
||||||
|
broadcaster only.
|
||||||
|
security:
|
||||||
|
- UserHeader: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/XUserID"
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserMailSendBroadcastRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Personal broadcast accepted; receipt carries the recipient count.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||||
|
"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/admin:
|
||||||
|
post:
|
||||||
|
tags: [User]
|
||||||
|
operationId: userMailSendAdmin
|
||||||
|
summary: Send a non-replyable admin notification (owner only)
|
||||||
|
description: |
|
||||||
|
Owner-only: the caller must be the owner of the private game.
|
||||||
|
`target="user"` requires `recipient_user_id`; `target="all"`
|
||||||
|
accepts an optional `recipients` scope (`active` by default,
|
||||||
|
plus `active_and_removed` and `all_members`). The message
|
||||||
|
carries `kind="admin"` and is therefore non-replyable.
|
||||||
|
security:
|
||||||
|
- UserHeader: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/XUserID"
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
|
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
"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:
|
/api/v1/user/sessions:
|
||||||
get:
|
get:
|
||||||
tags: [User]
|
tags: [User]
|
||||||
@@ -1704,6 +1993,176 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/admin/mail/broadcast:
|
||||||
|
post:
|
||||||
|
tags: [Admin]
|
||||||
|
operationId: adminDiplomailBroadcast
|
||||||
|
summary: Multi-game admin broadcast
|
||||||
|
description: |
|
||||||
|
Fans out one admin-kind broadcast across the games selected
|
||||||
|
by `scope`. `scope="selected"` requires `game_ids`;
|
||||||
|
`scope="all_running"` enumerates every game whose status is
|
||||||
|
non-terminal. Recipients are resolved per-game via the same
|
||||||
|
scope vocabulary as the per-game admin send. A recipient
|
||||||
|
appearing in multiple addressed games receives one
|
||||||
|
independently-deletable inbox entry per game.
|
||||||
|
security:
|
||||||
|
- AdminBasicAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminDiplomailBroadcastRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Broadcast accepted; per-game message ids and total recipient count.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminDiplomailBroadcastResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/admin/mail/cleanup:
|
||||||
|
post:
|
||||||
|
tags: [Admin]
|
||||||
|
operationId: adminDiplomailCleanup
|
||||||
|
summary: Bulk-purge diplomail messages from old finished games
|
||||||
|
description: |
|
||||||
|
Removes every `diplomail_messages` row whose game finished
|
||||||
|
more than `older_than_years` years ago. Cascading FKs prune
|
||||||
|
the recipient and translation tables in the same transaction.
|
||||||
|
`older_than_years` must be >= 1.
|
||||||
|
security:
|
||||||
|
- AdminBasicAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminDiplomailCleanupRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Cleanup result.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminDiplomailCleanupResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/admin/mail/messages:
|
||||||
|
get:
|
||||||
|
tags: [Admin]
|
||||||
|
operationId: adminDiplomailList
|
||||||
|
summary: Paginated admin view of diplomail messages
|
||||||
|
description: |
|
||||||
|
Returns the canonical message rows for admin observability.
|
||||||
|
Optional filters: `game_id`, `kind` (personal / admin),
|
||||||
|
`sender_kind` (player / admin / system). Pagination via
|
||||||
|
`page` and `page_size`.
|
||||||
|
security:
|
||||||
|
- AdminBasicAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
- name: game_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: kind
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [personal, admin]
|
||||||
|
- name: sender_kind
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [player, admin, system]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated diplomail messages.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminDiplomailListResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/admin/games/{game_id}/mail:
|
||||||
|
post:
|
||||||
|
tags: [Admin]
|
||||||
|
operationId: adminDiplomailSend
|
||||||
|
summary: Send a diplomatic-mail admin notification to one game
|
||||||
|
description: |
|
||||||
|
Site-admin send for the diplomatic-mail subsystem. Body shape
|
||||||
|
mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin`
|
||||||
|
endpoint. `target="user"` requires `recipient_user_id`;
|
||||||
|
`target="all"` accepts an optional `recipients` scope
|
||||||
|
(`active` / `active_and_removed` / `all_members`). The
|
||||||
|
authenticated admin username is persisted as `sender_username`.
|
||||||
|
security:
|
||||||
|
- AdminBasicAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
|
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/admin/runtimes/{game_id}:
|
/api/v1/admin/runtimes/{game_id}:
|
||||||
get:
|
get:
|
||||||
tags: [Admin]
|
tags: [Admin]
|
||||||
@@ -2247,6 +2706,13 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
MessageID:
|
||||||
|
name: message_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
NotificationID:
|
NotificationID:
|
||||||
name: notification_id
|
name: notification_id
|
||||||
in: path
|
in: path
|
||||||
@@ -3599,6 +4065,407 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/DeviceSession"
|
$ref: "#/components/schemas/DeviceSession"
|
||||||
|
UserMailSendRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [body]
|
||||||
|
properties:
|
||||||
|
recipient_user_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: |
|
||||||
|
Either `recipient_user_id` or `recipient_race_name` must
|
||||||
|
be supplied; supplying both is rejected as
|
||||||
|
`invalid_request`.
|
||||||
|
recipient_race_name:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Resolves to the active member with this race name in the
|
||||||
|
game. Mutually exclusive with `recipient_user_id`. The
|
||||||
|
server returns `forbidden` when the matching member is no
|
||||||
|
longer active (lobby-removed / blocked).
|
||||||
|
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.
|
||||||
|
UserMailSendAdminRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [target, body]
|
||||||
|
properties:
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
enum: [user, all]
|
||||||
|
recipient_user_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: |
|
||||||
|
One of `recipient_user_id` and `recipient_race_name` is
|
||||||
|
required when `target="user"`. Identifies the recipient
|
||||||
|
of the personal admin message by uuid; the recipient may
|
||||||
|
be in any membership status (admin notifications can
|
||||||
|
reach kicked players when addressed by user_id).
|
||||||
|
recipient_race_name:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Optional alternative to `recipient_user_id` when
|
||||||
|
`target="user"`. Resolves to the active member with this
|
||||||
|
race name in the game; lobby-removed and blocked members
|
||||||
|
cannot be reached through the race-name shortcut.
|
||||||
|
recipients:
|
||||||
|
type: string
|
||||||
|
enum: [active, active_and_removed, all_members]
|
||||||
|
description: |
|
||||||
|
Optional scope when `target="all"`. Defaults to `active`.
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
UserMailBroadcastReceipt:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- message_id
|
||||||
|
- game_id
|
||||||
|
- kind
|
||||||
|
- sender_kind
|
||||||
|
- body
|
||||||
|
- body_lang
|
||||||
|
- broadcast_scope
|
||||||
|
- created_at
|
||||||
|
- recipient_count
|
||||||
|
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]
|
||||||
|
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
|
||||||
|
recipient_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
UserMailSendBroadcastRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [body]
|
||||||
|
properties:
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
AdminDiplomailBroadcastRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [scope, body]
|
||||||
|
properties:
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum: [selected, all_running]
|
||||||
|
game_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
recipients:
|
||||||
|
type: string
|
||||||
|
enum: [active, active_and_removed, all_members]
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
AdminDiplomailBroadcastResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [recipient_count, messages]
|
||||||
|
properties:
|
||||||
|
recipient_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
messages:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [message_id, game_id]
|
||||||
|
properties:
|
||||||
|
message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
game_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
game_name:
|
||||||
|
type: string
|
||||||
|
AdminDiplomailCleanupRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [older_than_years]
|
||||||
|
properties:
|
||||||
|
older_than_years:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
AdminDiplomailCleanupResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [messages_deleted, game_ids]
|
||||||
|
properties:
|
||||||
|
messages_deleted:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
game_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
AdminDiplomailMessage:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- message_id
|
||||||
|
- game_id
|
||||||
|
- kind
|
||||||
|
- sender_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]
|
||||||
|
sender_kind:
|
||||||
|
type: string
|
||||||
|
enum: [player, admin, system]
|
||||||
|
sender_user_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
sender_username:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
sender_ip:
|
||||||
|
type: string
|
||||||
|
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
|
||||||
|
AdminDiplomailListResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [total, page, page_size, items]
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
page_size:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AdminDiplomailMessage"
|
||||||
|
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
|
||||||
|
sender_race_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: |
|
||||||
|
Snapshot of the sender's race name in this game at send
|
||||||
|
time. Populated when `sender_kind="player"` and the
|
||||||
|
sender had an active membership at send time; nil for
|
||||||
|
admin and system messages, and for player messages sent
|
||||||
|
by a private-game owner who was not an active member at
|
||||||
|
send time. The in-game UI keys per-race threading on this
|
||||||
|
field.
|
||||||
|
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
|
||||||
|
translated_subject:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Subject rendered into the caller's preferred_language by
|
||||||
|
the translation cache. Absent when the caller's language
|
||||||
|
matches `body_lang` or the translator could not produce
|
||||||
|
a rendering.
|
||||||
|
translated_body:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Body rendered into the caller's preferred_language. Same
|
||||||
|
absence semantics as `translated_subject`.
|
||||||
|
translation_lang:
|
||||||
|
type: string
|
||||||
|
description: BCP 47 tag of the rendered translation.
|
||||||
|
translator:
|
||||||
|
type: string
|
||||||
|
description: Identifier of the translation engine that produced the cached row.
|
||||||
|
UserMailInboxList:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
|
UserMailSentList:
|
||||||
|
description: |
|
||||||
|
Sender-side listing of personal messages authored by the
|
||||||
|
caller. Each item carries the same shape as inbox entries
|
||||||
|
(including the recipient snapshot); single sends contribute
|
||||||
|
one row per message, broadcasts contribute one row per
|
||||||
|
addressee so the in-game UI can collapse them by
|
||||||
|
`message_id` into a single stand-alone item.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
|
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:
|
responses:
|
||||||
NotImplementedError:
|
NotImplementedError:
|
||||||
description: Endpoint is documented but not implemented yet.
|
description: Endpoint is documented but not implemented yet.
|
||||||
|
|||||||
+171
-11
@@ -192,10 +192,12 @@ because they cross domain boundaries:
|
|||||||
`race_name`) remain `text`.
|
`race_name`) remain `text`.
|
||||||
- Foreign keys are intra-domain only: `accounts → entitlement_*` /
|
- Foreign keys are intra-domain only: `accounts → entitlement_*` /
|
||||||
`sanction_*` / `limit_*`; `games → applications` / `invites` /
|
`sanction_*` / `limit_*`; `games → applications` / `invites` /
|
||||||
`memberships` (with `ON DELETE CASCADE`); `mail_payloads →
|
`memberships` / `diplomail_messages` (each with
|
||||||
mail_deliveries → mail_recipients` / `mail_attempts` /
|
`ON DELETE CASCADE`); `mail_payloads → mail_deliveries →
|
||||||
`mail_dead_letters`; `notifications → notification_routes` /
|
mail_recipients` / `mail_attempts` / `mail_dead_letters`;
|
||||||
`notification_dead_letters`. Cross-domain references
|
`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
|
(`memberships.user_id`, `games.owner_user_id`, etc.) are kept as
|
||||||
opaque `uuid` columns because each domain runs its own cleanup
|
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
|
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
|
Notifications are an in-process pipeline. The closed catalog is
|
||||||
defined in `backend/internal/notification/catalog.go` and currently
|
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
|
submitted/approved/rejected, membership removed/blocked, race name
|
||||||
registered/pending/expired) and 3 admin-recipient runtime kinds
|
registered/pending/expired), 3 admin-recipient runtime kinds (image
|
||||||
(image pull failed, container start failed, start config invalid).
|
pull failed, container start failed, start config invalid), 2 game
|
||||||
Per-kind delivery channels (push, email, or both) and the admin-vs-
|
lifecycle kinds (turn ready, game paused), and the
|
||||||
per-user recipient routing live in the same file.
|
`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:
|
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
|
this user about this thing"; clients still derive their actual game
|
||||||
state through normal user-facing reads.
|
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)
|
## 13. Container Lifecycle (in-process)
|
||||||
|
|
||||||
`backend/internal/runtime` owns the lifecycle of game-engine containers
|
`backend/internal/runtime` owns the lifecycle of game-engine containers
|
||||||
@@ -658,6 +691,55 @@ stream opens is `event_type = gateway.server_time`, reusing the opening
|
|||||||
`request_id` as `event_id` and carrying `server_time_ms` so clients can
|
`request_id` as `event_id` and carrying `server_time_ms` so clients can
|
||||||
calibrate offset without a separate time request.
|
calibrate offset without a separate time request.
|
||||||
|
|
||||||
|
#### Unsigned `gateway.heartbeat` keepalive
|
||||||
|
|
||||||
|
Browser fetch-streaming layers (notably WebKit/Safari) close response
|
||||||
|
bodies they consider idle after roughly 15-30 seconds without
|
||||||
|
incoming bytes. A push stream in a quiet game (no `game.turn.ready`,
|
||||||
|
no diplomatic mail) would otherwise be torn down and reopened
|
||||||
|
repeatedly; events that fire during the reconnect window vanish
|
||||||
|
because `push.Hub` queues are not persisted across subscription
|
||||||
|
closes. To keep the body active, the gateway emits a
|
||||||
|
`gateway.heartbeat` event after `GATEWAY_PUSH_HEARTBEAT_INTERVAL` of
|
||||||
|
stream silence (default `15s`; set to `0s` to disable). Every real
|
||||||
|
event resets the silence timer, so the heartbeat fires rarely on
|
||||||
|
busy streams.
|
||||||
|
|
||||||
|
Heartbeats are sent **unsigned**: every field except `event_type` is
|
||||||
|
left at its protobuf default and no Ed25519 signature is computed.
|
||||||
|
The client short-circuits on the `gateway.heartbeat` type before
|
||||||
|
calling `verifyEvent` / `verifyPayloadHash` and never dispatches the
|
||||||
|
event to handlers. The security implication is intentional —
|
||||||
|
heartbeats carry no payload that the UI acts on, so an injected
|
||||||
|
heartbeat trivially fails to cause any user-visible state change.
|
||||||
|
TLS still protects the wire and the rest of the signed envelope is
|
||||||
|
unchanged for real events.
|
||||||
|
|
||||||
|
##### Wire cost projections
|
||||||
|
|
||||||
|
| Clients | 15 s | 30 s | 60 s |
|
||||||
|
| ------: | ---: | ---: | ---: |
|
||||||
|
| 100 | 25 MB/day | 13 MB/day | 6 MB/day |
|
||||||
|
| 1 000 | 250 MB/day | 125 MB/day | 62 MB/day |
|
||||||
|
| 10 000 | 2.5 GB/day | 1.3 GB/day | 0.6 GB/day |
|
||||||
|
| 100 000 | 25 GB/day | 12.5 GB/day | 6 GB/day |
|
||||||
|
|
||||||
|
Per-heartbeat budget at ~45 bytes on the wire (proto + Connect
|
||||||
|
framing + HTTP/2 DATA header + amortised TLS overhead), worst case
|
||||||
|
when no real event ever displaces a tick. Active streams trade
|
||||||
|
heartbeat traffic for real-event traffic 1:1, so the table is the
|
||||||
|
upper bound at the chosen interval. Larger deployments that are
|
||||||
|
willing to take a marginally higher Safari reconnect risk should
|
||||||
|
raise `GATEWAY_PUSH_HEARTBEAT_INTERVAL` toward 30 s before paying
|
||||||
|
the full table; setting `0s` reclaims all bytes at the cost of the
|
||||||
|
visible Safari reconnect loop returning.
|
||||||
|
|
||||||
|
Observability: every emission increments the
|
||||||
|
`gateway.push.heartbeats_sent{outcome}` counter, where
|
||||||
|
`outcome=sent` is the steady-state line item the operator budgets
|
||||||
|
bandwidth against and a sudden `outcome=error` bump means the
|
||||||
|
upstream connection is failing before the gateway can flush.
|
||||||
|
|
||||||
### Verification order at gateway
|
### Verification order at gateway
|
||||||
|
|
||||||
Before any payload is forwarded to backend, gateway must:
|
Before any payload is forwarded to backend, gateway must:
|
||||||
@@ -751,7 +833,85 @@ addition.
|
|||||||
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
|
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
|
||||||
bound). Probes are excluded from anti-replay and rate limiting.
|
bound). Probes are excluded from anti-replay and rate limiting.
|
||||||
|
|
||||||
## 18. Deployment Topology (informational)
|
## 18. CI and Environments
|
||||||
|
|
||||||
|
The repository is monorepo and intentionally so — semver tags and
|
||||||
|
per-service rollouts are achievable without splitting the code into
|
||||||
|
multiple repositories.
|
||||||
|
|
||||||
|
Branches:
|
||||||
|
|
||||||
|
- `main` — production-track. Direct pushes are disallowed; the only
|
||||||
|
way in is a PR merge from `development`.
|
||||||
|
- `development` — long-lived dev integration branch. Every merge
|
||||||
|
triggers an auto-deploy into the long-lived dev environment on the
|
||||||
|
CI host, reachable through the host Caddy at
|
||||||
|
`https://www.galaxy.lan` and `https://api.galaxy.lan`.
|
||||||
|
- `feature/*` — short-lived branches off `development`. Merged back
|
||||||
|
via PR; PRs run unit + integration checks before merge.
|
||||||
|
|
||||||
|
Workflows under `.gitea/workflows/`:
|
||||||
|
|
||||||
|
| File | Trigger | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||||
|
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||||
|
| `integration.yaml` | PR to `development` / `main`; push to `development` | testcontainers integration suite. |
|
||||||
|
| `dev-deploy.yaml` | push to `development`; `workflow_dispatch` on any ref | Build images, seed UI volume, `compose up` against `tools/dev-deploy/`. |
|
||||||
|
| `prod-build.yaml` | push to `main` | Build production images and persist `docker save` bundles as artifacts. |
|
||||||
|
| `deploy-prod.yaml` | manual `workflow_dispatch` | Placeholder for the future SSH-based production rollout. |
|
||||||
|
|
||||||
|
Deployment cadence: the dev environment is single-tenant. Pushes to
|
||||||
|
`feature/*` branches run only the test workflows; `dev-deploy.yaml`
|
||||||
|
does not auto-fire. To preview a feature branch on the shared dev
|
||||||
|
environment, trigger `dev-deploy.yaml` manually from the Gitea UI
|
||||||
|
against the desired ref. The deploy is idempotent — the next merge
|
||||||
|
into `development` overwrites the manually deployed state.
|
||||||
|
|
||||||
|
Environments:
|
||||||
|
|
||||||
|
- **`tools/local-dev/`** — single-developer playground. Bound to
|
||||||
|
host ports, Vite dev server runs on the host. Not driven by CI.
|
||||||
|
- **`tools/dev-deploy/`** — long-lived dev environment behind
|
||||||
|
`*.galaxy.lan`, redeployed on every merge into `development`.
|
||||||
|
- **production** — future. Images come from the
|
||||||
|
`galaxy-images-commit-<sha>` artifact produced by `prod-build.yaml`
|
||||||
|
and are shipped to the production host via `docker save` →
|
||||||
|
`ssh prod docker load` → `docker compose up -d`.
|
||||||
|
|
||||||
|
### Container labels
|
||||||
|
|
||||||
|
Every Galaxy-managed Docker **container** carries an opinionated
|
||||||
|
label so that host-side tooling (Makefiles, CI workflows,
|
||||||
|
`preclean.sh`) can scope its operations to Galaxy-owned containers
|
||||||
|
and never touch unrelated workloads on the shared daemon.
|
||||||
|
|
||||||
|
| Label | Values | Set by | Used by |
|
||||||
|
|-------|--------|--------|---------|
|
||||||
|
| `galaxy.stack` | `local-dev`, `dev-deploy`, `integration` | `tools/{local-dev,dev-deploy}/docker-compose.yml` for compose-managed services; backend reads `BACKEND_STACK_LABEL` and stamps engines it spawns. `integration/testenv/backend.go` passes `integration` to every backend-under-test. | `tools/{local-dev,dev-deploy}/Makefile`, `.gitea/workflows/dev-deploy.yaml`, `integration/scripts/preclean.sh`. |
|
||||||
|
| `galaxy.backend` | `1` | `backend/internal/dockerclient` adapter on every engine container. | `integration/scripts/preclean.sh` — AND-combined with `galaxy.stack=integration` to leave dev-deploy / local-dev engines untouched. |
|
||||||
|
| `galaxy.game_id` | `<uuid>` | Backend on engine create. | Reconciler reattach loop. |
|
||||||
|
| `galaxy.engine_version` | `<semver>` | Backend on engine create. | Reconciler version checks. |
|
||||||
|
| `galaxy.test.kind` | `integration-image` | `integration/testenv/images.go` on local image builds. | `integration/scripts/preclean.sh` (filter for `docker rmi`). |
|
||||||
|
| `org.testcontainers` | `true` | `testcontainers-go` (automatic). | `integration/scripts/preclean.sh`. |
|
||||||
|
|
||||||
|
The contract: any Makefile target, CI step, or script that issues
|
||||||
|
`docker rm` / `docker rmi` / `docker network rm` MUST scope itself via
|
||||||
|
one of the labels above. Compose-managed resources are additionally
|
||||||
|
scoped by their compose project name (`galaxy-dev`, `galaxy-local-dev`),
|
||||||
|
which Compose enforces on `docker compose up/down`; the labels make the
|
||||||
|
contract explicit and survive hand-rolled cleanup commands as well.
|
||||||
|
|
||||||
|
**Scope deliberately limited to containers.** Labels are NOT stamped
|
||||||
|
on named volumes or user-defined networks. Adding labels there would
|
||||||
|
change the compose config-hash for the volume/network on every label
|
||||||
|
revision and force `docker compose up` to recreate them — which for a
|
||||||
|
postgres data volume means destroying the database, and for a shared
|
||||||
|
network can deadlock if any container is still attached. Containers
|
||||||
|
alone are sufficient for the cleanup contract; stateful resources stay
|
||||||
|
untouched by compose between deploys.
|
||||||
|
|
||||||
|
## 19. Deployment Topology (informational)
|
||||||
|
|
||||||
- MVP runs three executables: one `gateway` instance, one `backend`
|
- MVP runs three executables: one `gateway` instance, one `backend`
|
||||||
instance, and N `galaxy-game-{game_id}` containers managed by backend.
|
instance, and N `galaxy-game-{game_id}` containers managed by backend.
|
||||||
@@ -770,7 +930,7 @@ Future scale-out hooks (not in MVP):
|
|||||||
- mTLS between gateway and backend.
|
- mTLS between gateway and backend.
|
||||||
- Docker-socket-proxy sidecar fronting Docker daemon access.
|
- Docker-socket-proxy sidecar fronting Docker daemon access.
|
||||||
|
|
||||||
## 19. Glossary
|
## 20. Glossary
|
||||||
|
|
||||||
- **device_session_id** — opaque identifier of an authenticated client
|
- **device_session_id** — opaque identifier of an authenticated client
|
||||||
device; primary key of the device session record.
|
device; primary key of the device session record.
|
||||||
|
|||||||
+300
-5
@@ -47,6 +47,7 @@ same scenario when they participate in the same business flow.
|
|||||||
8. [Notifications and mail](#8-notifications-and-mail)
|
8. [Notifications and mail](#8-notifications-and-mail)
|
||||||
9. [Geo signal](#9-geo-signal)
|
9. [Geo signal](#9-geo-signal)
|
||||||
10. [Administration](#10-administration)
|
10. [Administration](#10-administration)
|
||||||
|
11. [Diplomatic mail](#11-diplomatic-mail)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -705,10 +706,14 @@ renders one battle at a time. Entry points:
|
|||||||
The viewer is a logically isolated component that consumes a
|
The viewer is a logically isolated component that consumes a
|
||||||
`BattleReport` (shape per `pkg/model/report/battle.go`). The page
|
`BattleReport` (shape per `pkg/model/report/battle.go`). The page
|
||||||
loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches
|
loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches
|
||||||
the report through the backend gateway route
|
the report through the signed `user.games.battle` ConnectRPC
|
||||||
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
|
command on the authenticated edge: the gateway translates the
|
||||||
which forwards verbatim to the engine's
|
verified envelope into `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
|
||||||
`GET /api/v1/battle/:turn/:uuid`.
|
against the backend, which in turn proxies the engine's
|
||||||
|
`GET /api/v1/battle/:turn/:uuid`. For synthetic games the loader
|
||||||
|
short-circuits to the in-memory fixture map populated by the
|
||||||
|
synthetic-report envelope (see below) and never touches the
|
||||||
|
gateway.
|
||||||
|
|
||||||
Visual model is radial: the planet sits at the centre, races are
|
Visual model is radial: the planet sits at the centre, races are
|
||||||
placed at equal angular spacing on an outer ring, and each race is
|
placed at equal angular spacing on an outer ring, and each race is
|
||||||
@@ -781,7 +786,51 @@ producer; adding one is purely additive (register the kind in the
|
|||||||
catalog, extend the migration `CHECK` constraint, and call
|
catalog, extend the migration `CHECK` constraint, and call
|
||||||
`notification.Submit` from the appropriate domain module).
|
`notification.Submit` from the appropriate domain module).
|
||||||
|
|
||||||
### 6.7 Cross-references
|
### 6.7 Map visibility controls
|
||||||
|
|
||||||
|
The map view (`ui/frontend/src/lib/active-view/map.svelte`)
|
||||||
|
carries a gear-icon popover (`map-toggles.svelte`) in the
|
||||||
|
canvas's top-right corner, providing the player with a per-game
|
||||||
|
visibility surface. The popover lists three groups of controls;
|
||||||
|
every change applies within one frame (no Pixi remount):
|
||||||
|
|
||||||
|
- **Objects** — six independent checkboxes: hyperspace groups,
|
||||||
|
incoming groups, unidentified groups, cargo routes, battle
|
||||||
|
markers, bombing markers.
|
||||||
|
- **Planets** — four rows: foreign / uninhabited / unidentified
|
||||||
|
planet kinds plus a "show unreachable planets" switch that, when
|
||||||
|
off, hides every non-LOCAL planet that sits beyond
|
||||||
|
`FlightDistance(localPlayerDrive)` of every LOCAL planet
|
||||||
|
(torus-aware metric).
|
||||||
|
- **View** — "visible hyperspace" toggle (slightly lighter
|
||||||
|
overlay outside the union of
|
||||||
|
`VisibilityDistance(localPlayerDrive)` circles around LOCAL
|
||||||
|
planets; LOCAL planets are always exempt — the toggle is
|
||||||
|
named after the visible part of the map rather than the
|
||||||
|
obscured one) plus the torus / no-wrap radio that switches
|
||||||
|
the renderer mode while preserving the camera centre.
|
||||||
|
|
||||||
|
LOCAL planets are always rendered — they have no toggle. Every
|
||||||
|
other toggle defaults to ON. Hiding a planet cascades onto every
|
||||||
|
primitive anchored on it: battle and bombing markers on the
|
||||||
|
planet, in-space and incoming ship-group points plus their
|
||||||
|
trajectory lines flying *to* the planet, and cargo-route arrows
|
||||||
|
whose source or destination is that planet. The cascade keeps
|
||||||
|
the map free of orphan glyphs pointing at empty space.
|
||||||
|
|
||||||
|
Visibility state persists per game in the
|
||||||
|
`game-map-toggles/{gameId}` cache namespace (see
|
||||||
|
[`ui/docs/storage.md`](../ui/docs/storage.md)). Whenever a new
|
||||||
|
server-side turn becomes the player's current turn — either via
|
||||||
|
`setGame` opening the player on a server `currentTurn` greater
|
||||||
|
than the last persisted `lastResetTurn`, or via the user
|
||||||
|
explicitly clicking the pending-turn affordance — every toggle
|
||||||
|
is force-reset to defaults so the new turn's content cannot be
|
||||||
|
silently hidden by a stale preference. History-mode navigation
|
||||||
|
(`viewTurn`) keeps the shared toggle state intact across past
|
||||||
|
turns.
|
||||||
|
|
||||||
|
### 6.8 Cross-references
|
||||||
|
|
||||||
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
|
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
|
||||||
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
||||||
@@ -815,6 +864,18 @@ internal hub. The first frame the client receives is a
|
|||||||
gateway-signed bootstrap event carrying the current server time, so
|
gateway-signed bootstrap event carrying the current server time, so
|
||||||
the client can calibrate its local clock without a separate request.
|
the client can calibrate its local clock without a separate request.
|
||||||
|
|
||||||
|
While the stream is open, gateway tracks a silence timer; if no real
|
||||||
|
event has been forwarded for `GATEWAY_PUSH_HEARTBEAT_INTERVAL`
|
||||||
|
(default `15s`, `0s` disables), gateway emits an unsigned
|
||||||
|
`gateway.heartbeat` event to keep browser fetch-streaming layers
|
||||||
|
from closing the response body as idle. Real events reset the
|
||||||
|
timer, so on busy streams the heartbeat fires rarely. The UI client
|
||||||
|
short-circuits the heartbeat type before signature verification and
|
||||||
|
never dispatches it to handlers — see
|
||||||
|
[`docs/ARCHITECTURE.md` § 15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary)
|
||||||
|
for the wire-cost projection and the security rationale of leaving
|
||||||
|
the heartbeat unsigned.
|
||||||
|
|
||||||
### 7.3 Backend → gateway control
|
### 7.3 Backend → gateway control
|
||||||
|
|
||||||
Backend hosts a single gRPC service `Push.SubscribePush`, consumed
|
Backend hosts a single gRPC service `Push.SubscribePush`, consumed
|
||||||
@@ -1153,3 +1214,237 @@ counters are populated by the runtime, and operators can only read.
|
|||||||
- Mail outbox and notification dispatcher:
|
- Mail outbox and notification dispatcher:
|
||||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||||
[§12](ARCHITECTURE.md#12-notification-pipeline) and [Section 8](#8-notifications-and-mail).
|
[§12](ARCHITECTURE.md#12-notification-pipeline) and [Section 8](#8-notifications-and-mail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Diplomatic mail
|
||||||
|
|
||||||
|
This scenario covers the player-to-player and admin-to-player
|
||||||
|
messaging system exposed inside a game. The system is conceptually
|
||||||
|
part of the lobby (messages outlive game runtime restarts), but
|
||||||
|
they are surfaced exclusively inside the in-game UI; the lobby
|
||||||
|
surfaces only an unread counter.
|
||||||
|
|
||||||
|
### 11.1 Scope
|
||||||
|
|
||||||
|
In scope: sending personal mail between active members of the same
|
||||||
|
game; replying to personal mail; reading and marking-read /
|
||||||
|
soft-deleting one's own incoming mail; admin / owner notifications
|
||||||
|
addressed to one player or broadcast to a game; paid-tier player
|
||||||
|
broadcasts; site-admin multi-game broadcasts; bulk purge of
|
||||||
|
messages tied to terminated games; auto-translation of the body
|
||||||
|
into the recipient's `preferred_language` with a cached rendering.
|
||||||
|
|
||||||
|
Out of scope: out-of-game chat, group chats spanning multiple
|
||||||
|
games, file attachments, message editing or unsend, end-to-end
|
||||||
|
encryption.
|
||||||
|
|
||||||
|
### 11.2 The message model
|
||||||
|
|
||||||
|
Every send produces exactly one row in `diplomail_messages` plus
|
||||||
|
one row per recipient in `diplomail_recipients`. A broadcast to N
|
||||||
|
recipients is one message + N recipient rows; the translation row,
|
||||||
|
when materialised, is shared across every recipient with the same
|
||||||
|
target language.
|
||||||
|
|
||||||
|
`diplomail_messages.kind` is the closed set
|
||||||
|
`{personal, admin}`. Personal messages are replyable (the
|
||||||
|
recipient sends back a new personal message); admin messages are
|
||||||
|
non-replyable acknowledgements of a state change or operator
|
||||||
|
action. `sender_kind` is `{player, admin, system}` and identifies
|
||||||
|
the originator's role: a player owns the game (admin notification
|
||||||
|
from owner), a site administrator pushed it (admin notification
|
||||||
|
from operator), or the lobby state machine produced it
|
||||||
|
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||||
|
`membership.blocked`).
|
||||||
|
|
||||||
|
`broadcast_scope` records whether the send was a single-recipient
|
||||||
|
delivery (`single`), a one-game broadcast (`game_broadcast`), or a
|
||||||
|
cross-game admin broadcast (`multi_game_broadcast`). Recipients of
|
||||||
|
a multi-game broadcast see one independently-deletable inbox entry
|
||||||
|
per game they were addressed in.
|
||||||
|
|
||||||
|
Per-row snapshots travel with each message: `game_name`,
|
||||||
|
`sender_username`, `sender_ip`, plus on the recipient row
|
||||||
|
`recipient_user_name`, `recipient_race_name`, and
|
||||||
|
`recipient_preferred_language`. These survive game-name changes,
|
||||||
|
membership revocation, account soft-delete, and the eventual
|
||||||
|
bulk-purge cascade — they let the admin observability surface
|
||||||
|
render correctly long after the live rows have moved on.
|
||||||
|
|
||||||
|
Bodies and subjects are plain UTF-8 text. The server does not
|
||||||
|
parse, sanitise, or escape HTML; the client renders bodies through
|
||||||
|
`textContent`. Maximum body size is
|
||||||
|
`BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default `4096`); maximum
|
||||||
|
subject size is `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default
|
||||||
|
`256`).
|
||||||
|
|
||||||
|
### 11.3 Sending mail
|
||||||
|
|
||||||
|
Personal sends require active membership in the game for both the
|
||||||
|
sender and the recipient. Free-tier players send one personal
|
||||||
|
message per request. Paid-tier players additionally have access to
|
||||||
|
a game-scoped broadcast that addresses every other active member
|
||||||
|
in one call; replies fan back to the broadcast author.
|
||||||
|
|
||||||
|
Game owners (of private games) and site administrators send admin
|
||||||
|
notifications. The owner endpoint lives under the user surface
|
||||||
|
(authenticated by `X-User-ID`, owner check enforced); the admin
|
||||||
|
endpoint lives under the admin surface (HTTP Basic). Both accept
|
||||||
|
`target=user` (single recipient) or `target=all` (game broadcast).
|
||||||
|
Site administrators additionally have a multi-game endpoint that
|
||||||
|
accepts `scope=selected` with a list of game ids or
|
||||||
|
`scope=all_running` that enumerates every game with non-terminal
|
||||||
|
status.
|
||||||
|
|
||||||
|
Broadcast composition is parameterised by `recipients`: `active`
|
||||||
|
(default), `active_and_removed`, or `all_members` (includes
|
||||||
|
blocked rows for audit-style mail). The broadcast author's own
|
||||||
|
recipient row is never created.
|
||||||
|
|
||||||
|
A paid-tier broadcast is rejected with `403 forbidden` when the
|
||||||
|
caller's entitlement tier is `free`.
|
||||||
|
|
||||||
|
### 11.4 Receiving mail
|
||||||
|
|
||||||
|
The recipient sees the message in their in-game inbox once the
|
||||||
|
async translation worker has finished processing it (see
|
||||||
|
[§11.6](#116-translation)). Until then the row stays invisible:
|
||||||
|
absent from the inbox listing, not counted in the unread badge, no
|
||||||
|
push event delivered. This avoids a surprise where the inbox shows
|
||||||
|
a row with no translation and an outdated unread count.
|
||||||
|
|
||||||
|
The unread badge in the lobby aggregates by game. The
|
||||||
|
`/api/v1/user/lobby/mail/unread-counts` endpoint returns one entry
|
||||||
|
per game with non-zero unread plus the global total; the lobby UI
|
||||||
|
renders the total badge and a per-game tile counter without
|
||||||
|
exposing the messages themselves.
|
||||||
|
|
||||||
|
Marking a message as read is idempotent. Soft-deletion requires the
|
||||||
|
message to already be marked read — a client cannot erase an
|
||||||
|
unopened message. Soft-deletion is per-recipient: the underlying
|
||||||
|
message row survives until the admin bulk-purge endpoint removes
|
||||||
|
the entire game's mail tree.
|
||||||
|
|
||||||
|
The message detail response includes both the original body and,
|
||||||
|
when available, the cached translation; the client UI defaults to
|
||||||
|
the translated text and offers a "show original" toggle.
|
||||||
|
|
||||||
|
The in-game UI groups personal mail into per-race threads — every
|
||||||
|
personal message exchanged between the local player and another
|
||||||
|
race lands in one thread keyed on the other party's race. System
|
||||||
|
mail, admin notifications, and the player's own paid-tier
|
||||||
|
broadcasts render as stand-alone entries in the same list pane and
|
||||||
|
are never threaded. `read_at` and `deleted_at` drive the local
|
||||||
|
unread counter and the soft-delete affordance but are not surfaced
|
||||||
|
to the user — diplomatic mail does not promise read receipts. The
|
||||||
|
compose form picks the recipient by race name (resolved
|
||||||
|
server-side from `Memberships.ListMembers(game_id, "active")`); no
|
||||||
|
client-side memberships listing is fetched. See
|
||||||
|
[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md) for the
|
||||||
|
detailed UI breakdown.
|
||||||
|
|
||||||
|
### 11.5 Lifecycle hooks
|
||||||
|
|
||||||
|
Three lobby transitions land as system mail in the affected
|
||||||
|
players' inboxes:
|
||||||
|
|
||||||
|
- **Game paused / cancelled.** When the game state machine moves
|
||||||
|
through `paused` or `cancelled`, the lobby emits a system mail
|
||||||
|
addressed to every active member. The message explains the
|
||||||
|
transition with a server-rendered template, so even an offline
|
||||||
|
player finds the context the next time they open the inbox.
|
||||||
|
- **Membership removed / blocked.** Manual self-leave, owner-driven
|
||||||
|
removal, and admin ban each emit a system mail addressed to the
|
||||||
|
affected player only. This mail survives the membership going
|
||||||
|
to `removed` / `blocked`, so a kicked player keeps read access
|
||||||
|
to the explanation forever (soft-access rule).
|
||||||
|
|
||||||
|
Future inactivity-driven removal must call the same publisher so
|
||||||
|
the explanation reaches the affected player; the lobby package
|
||||||
|
README pins this contract for the next implementer.
|
||||||
|
|
||||||
|
### 11.6 Translation
|
||||||
|
|
||||||
|
`diplomail_messages.body_lang` is filled at send time by an
|
||||||
|
in-process language detector that operates on the body only.
|
||||||
|
Subject inherits the body's detected language for the translation
|
||||||
|
cache lookup. When detection cannot confidently label the body
|
||||||
|
(too short, empty, mixed scripts) the value is the BCP 47
|
||||||
|
`und` ("undetermined") sentinel and the translation pipeline is
|
||||||
|
short-circuited — recipients receive the original.
|
||||||
|
|
||||||
|
Translation happens asynchronously. Every recipient row stores a
|
||||||
|
snapshot of the addressee's `preferred_language` plus an
|
||||||
|
`available_at` timestamp. A recipient whose language matches the
|
||||||
|
detected `body_lang` (or whose preferred language is empty / the
|
||||||
|
body language is `und`) gets `available_at = now()` on insert and
|
||||||
|
the push event fires immediately. A recipient whose language
|
||||||
|
differs is inserted with `available_at IS NULL` and waits for the
|
||||||
|
translation worker.
|
||||||
|
|
||||||
|
The worker (`internal/diplomail.Worker`) ticks every
|
||||||
|
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`) and processes
|
||||||
|
one `(message_id, target_lang)` pair per tick. It consults the
|
||||||
|
translation cache first; on miss it asks the configured
|
||||||
|
`Translator`. The default deployment ships the LibreTranslate HTTP
|
||||||
|
client; an empty `BACKEND_DIPLOMAIL_TRANSLATOR_URL` falls back to
|
||||||
|
the noop translator that delivers every message in the original
|
||||||
|
language.
|
||||||
|
|
||||||
|
Translation outcomes:
|
||||||
|
|
||||||
|
- **Success.** A row in `diplomail_translations` is inserted (or
|
||||||
|
reused if another worker won the race), every pending recipient
|
||||||
|
of the pair is flipped to `available_at = now()`, and one push
|
||||||
|
event per recipient is published.
|
||||||
|
- **Unsupported language pair** (HTTP 400 from LibreTranslate).
|
||||||
|
No translation row is persisted; recipients are delivered with
|
||||||
|
the original body. Subsequent reads return the original.
|
||||||
|
- **Transient failure** (timeout, 5xx, network error). The
|
||||||
|
attempt counter is bumped and the next attempt is scheduled via
|
||||||
|
exponential backoff `1s → 2s → 4s → 8s → 16s` (capped at 60s).
|
||||||
|
After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
|
||||||
|
the worker falls back to delivering the original body. A
|
||||||
|
prolonged translator outage therefore stalls delivery by at
|
||||||
|
most ~30 seconds per pair before the receiver sees the
|
||||||
|
original.
|
||||||
|
|
||||||
|
The translation cache is shared: a broadcast to N recipients with
|
||||||
|
the same preferred language produces one cache row and one
|
||||||
|
translator call, not N.
|
||||||
|
|
||||||
|
### 11.7 Storage and purge
|
||||||
|
|
||||||
|
Messages live in `diplomail_messages`; per-recipient state lives
|
||||||
|
in `diplomail_recipients` with a foreign-key cascade to the
|
||||||
|
message; translations live in `diplomail_translations` also with a
|
||||||
|
cascade. The sender IP is captured at insert time from
|
||||||
|
`X-Forwarded-For` (forwarded by gateway) for evidence preservation.
|
||||||
|
|
||||||
|
There is no automatic retention. The admin bulk-purge endpoint
|
||||||
|
removes every message whose game finished more than
|
||||||
|
`older_than_years` years ago (minimum `1`); the cascade drops the
|
||||||
|
recipient and translation rows in the same transaction.
|
||||||
|
|
||||||
|
### 11.8 Operator visibility
|
||||||
|
|
||||||
|
The admin surface exposes a paginated listing of every persisted
|
||||||
|
message (`/api/v1/admin/mail/messages`) filterable by `game_id`,
|
||||||
|
`kind`, and `sender_kind`. The bulk-purge endpoint
|
||||||
|
(`/api/v1/admin/mail/cleanup`) accepts the `older_than_years`
|
||||||
|
threshold. Per-game admin sends and multi-game broadcasts live
|
||||||
|
under `/api/v1/admin/games/{game_id}/mail` and
|
||||||
|
`/api/v1/admin/mail/broadcast`.
|
||||||
|
|
||||||
|
### 11.9 Cross-references
|
||||||
|
|
||||||
|
- Package overview and stage map:
|
||||||
|
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||||
|
- LibreTranslate setup recipe for local development:
|
||||||
|
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||||
|
- Storage detail:
|
||||||
|
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||||
|
- Push transport for delivery events: [Section 7](#7-push-channel).
|
||||||
|
- Notification catalog kind `diplomail.message.received`:
|
||||||
|
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||||
|
|||||||
+297
-5
@@ -47,6 +47,7 @@ field-level-валидация — всё это лежит в нижнеуро
|
|||||||
8. [Уведомления и почта](#8-уведомления-и-почта)
|
8. [Уведомления и почта](#8-уведомления-и-почта)
|
||||||
9. [Гео-сигнал](#9-гео-сигнал)
|
9. [Гео-сигнал](#9-гео-сигнал)
|
||||||
10. [Администрирование](#10-администрирование)
|
10. [Администрирование](#10-администрирование)
|
||||||
|
11. [Дипломатическая почта](#11-дипломатическая-почта)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -723,10 +724,13 @@ Battle Viewer — отдельное представление, заменяю
|
|||||||
Сам Viewer — логически изолированный компонент, потребляющий
|
Сам Viewer — логически изолированный компонент, потребляющий
|
||||||
`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка
|
`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка
|
||||||
(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт
|
(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт
|
||||||
через backend-маршрут
|
подписанной ConnectRPC-командой `user.games.battle` на
|
||||||
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
|
аутентифицированном edge: gateway переводит верифицированный
|
||||||
который проксирует ответ engine-эндпоинта
|
envelope в `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
|
||||||
`GET /api/v1/battle/:turn/:uuid`.
|
к backend-у, тот в свою очередь проксирует engine-эндпоинт
|
||||||
|
`GET /api/v1/battle/:turn/:uuid`. Для synthetic-игр загрузчик
|
||||||
|
короткозамыкает запрос на in-memory карту фикстур, наполненную из
|
||||||
|
synthetic-report envelope (см. ниже), и не обращается к gateway.
|
||||||
|
|
||||||
Визуальная модель — радиальная: планета в центре, расы по внешней
|
Визуальная модель — радиальная: планета в центре, расы по внешней
|
||||||
окружности на равных угловых интервалах, внутри расы — облако
|
окружности на равных угловых интервалах, внутри расы — облако
|
||||||
@@ -802,7 +806,52 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
|
|||||||
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
|
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
|
||||||
`notification.Submit` из подходящего доменного модуля).
|
`notification.Submit` из подходящего доменного модуля).
|
||||||
|
|
||||||
### 6.7 Перекрёстные ссылки
|
### 6.7 Управление видимостью карты
|
||||||
|
|
||||||
|
Карта (`ui/frontend/src/lib/active-view/map.svelte`) несёт
|
||||||
|
попап-«шестерёнку» (`map-toggles.svelte`) в правом верхнем углу
|
||||||
|
канваса — посекторный интерфейс видимости для конкретной игры.
|
||||||
|
Попап содержит три группы элементов; любое изменение применяется
|
||||||
|
в пределах одного кадра (без перемонтирования Pixi):
|
||||||
|
|
||||||
|
- **Объекты** — шесть независимых чекбоксов: группы в
|
||||||
|
гиперпространстве, входящие группы, неопознанные группы,
|
||||||
|
грузовые маршруты, метки сражений, метки бомбардировок.
|
||||||
|
- **Планеты** — четыре строки: чужие / необитаемые /
|
||||||
|
неопознанные виды планет плюс выключатель «показывать
|
||||||
|
недостижимые планеты», который при выключении прячет каждую
|
||||||
|
не-LOCAL планету, отстоящую дальше
|
||||||
|
`FlightDistance(localPlayerDrive)` от любой LOCAL-планеты
|
||||||
|
(метрика учитывает торическую развёртку).
|
||||||
|
- **Вид** — переключатель «видимое гиперпространство» (чуть
|
||||||
|
более светлая заливка вне объединения окружностей
|
||||||
|
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
|
||||||
|
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
|
||||||
|
области карты, а не по затемнённой) плюс радиогруппа
|
||||||
|
«торус / без переноса», переключающая режим рендерера с
|
||||||
|
сохранением центра камеры.
|
||||||
|
|
||||||
|
LOCAL-планеты отрисовываются всегда — для них тоггла нет.
|
||||||
|
Остальные тогглы по умолчанию включены. Скрытие планеты
|
||||||
|
каскадно прячет все привязанные к ней примитивы: метки сражений
|
||||||
|
и бомбардировок на этой планете, точки in-space и incoming
|
||||||
|
ship-групп вместе с траекториями, летящих *к* этой планете, и
|
||||||
|
грузовые стрелки, чей источник или назначение — эта планета.
|
||||||
|
Каскад не оставляет на карте «осиротевших» меток, указывающих
|
||||||
|
в пустоту.
|
||||||
|
|
||||||
|
Состояние видимости сохраняется по игре в namespace кеша
|
||||||
|
`game-map-toggles/{gameId}` (см.
|
||||||
|
[`ui/docs/storage.md`](../ui/docs/storage.md)). При каждом
|
||||||
|
новом серверном ходе, ставшем текущим — либо через `setGame`,
|
||||||
|
обнаруживший `currentTurn` сервера выше последнего сохранённого
|
||||||
|
`lastResetTurn`, либо при явном клике пользователя по
|
||||||
|
аффордансу «новый ход» — все тогглы принудительно
|
||||||
|
сбрасываются в дефолт, чтобы новое содержимое хода не оказалось
|
||||||
|
скрытым устаревшими настройками. Навигация по истории
|
||||||
|
(`viewTurn`) общее состояние тогглов не сбрасывает.
|
||||||
|
|
||||||
|
### 6.8 Перекрёстные ссылки
|
||||||
|
|
||||||
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
|
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
|
||||||
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
||||||
@@ -841,6 +890,18 @@ control-канал backend → gateway, который производит эт
|
|||||||
с текущим серверным временем, чтобы клиент мог калибровать свои
|
с текущим серверным временем, чтобы клиент мог калибровать свои
|
||||||
локальные часы без отдельного запроса.
|
локальные часы без отдельного запроса.
|
||||||
|
|
||||||
|
Пока стрим открыт, gateway отслеживает таймер тишины; если за
|
||||||
|
`GATEWAY_PUSH_HEARTBEAT_INTERVAL` (по умолчанию `15s`, `0s`
|
||||||
|
отключает) не пришло ни одного реального события, gateway
|
||||||
|
отправляет неподписанное `gateway.heartbeat`-событие, чтобы
|
||||||
|
браузерные fetch-streaming слои не закрыли response body как idle.
|
||||||
|
Реальные события сбрасывают таймер, поэтому на нагруженных
|
||||||
|
стримах heartbeat срабатывает редко. UI-клиент короткозамыкает
|
||||||
|
heartbeat-тип до верификации подписи и никогда не дотягивает его
|
||||||
|
до handlers — см.
|
||||||
|
[`docs/ARCHITECTURE.md` § 15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary)
|
||||||
|
для расчёта траффика и обоснования отсутствия подписи у heartbeat.
|
||||||
|
|
||||||
### 7.3 Управление backend → gateway
|
### 7.3 Управление backend → gateway
|
||||||
|
|
||||||
Backend хостит единственный gRPC-сервис `Push.SubscribePush`,
|
Backend хостит единственный gRPC-сервис `Push.SubscribePush`,
|
||||||
@@ -1193,3 +1254,234 @@ dead-letters и malformed notification-намерения. Они также м
|
|||||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||||
[§12](ARCHITECTURE.md#12-notification-pipeline) и
|
[§12](ARCHITECTURE.md#12-notification-pipeline) и
|
||||||
[Раздел 8](#8-уведомления-и-почта).
|
[Раздел 8](#8-уведомления-и-почта).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Дипломатическая почта
|
||||||
|
|
||||||
|
Сценарий описывает обмен сообщениями между игроками одной партии и
|
||||||
|
адресные / широковещательные уведомления от администрации и
|
||||||
|
владельца партии. Подсистема концептуально часть лобби (сообщения
|
||||||
|
переживают рестарты движка), но видна только внутри игрового UI;
|
||||||
|
в лобби виден лишь счётчик непрочитанного.
|
||||||
|
|
||||||
|
### 11.1 Состав
|
||||||
|
|
||||||
|
В составе: отправка персональной почты между активными участниками
|
||||||
|
одной партии; ответы на персональную почту; чтение, отметка
|
||||||
|
«прочитано» и soft-удаление своей входящей почты; адресные и
|
||||||
|
широковещательные уведомления от админов и владельцев; платный
|
||||||
|
broadcast от игроков; мультигеймовая admin-рассылка; ручная
|
||||||
|
массовая чистка почты завершённых партий; авто-перевод тела
|
||||||
|
сообщения на `preferred_language` получателя с кэшированием.
|
||||||
|
|
||||||
|
Вне состава: чат вне партии, групповые чаты с участниками разных
|
||||||
|
партий, вложения, редактирование / отзыв сообщения,
|
||||||
|
end-to-end-шифрование.
|
||||||
|
|
||||||
|
### 11.2 Модель сообщения
|
||||||
|
|
||||||
|
Каждая отправка порождает ровно одну строку в `diplomail_messages`
|
||||||
|
плюс по одной строке на получателя в `diplomail_recipients`.
|
||||||
|
Broadcast на N получателей — одно сообщение и N recipient-строк;
|
||||||
|
строка перевода, если материализована, общая для всех получателей
|
||||||
|
с одинаковым целевым языком.
|
||||||
|
|
||||||
|
`diplomail_messages.kind` — закрытое множество
|
||||||
|
`{personal, admin}`. Персональные сообщения допускают ответ
|
||||||
|
(получатель отправляет новое персональное сообщение);
|
||||||
|
admin-сообщения не предполагают ответа — это уведомления о смене
|
||||||
|
состояния или операторском действии. `sender_kind` — это
|
||||||
|
`{player, admin, system}` и определяет роль отправителя: игрок-
|
||||||
|
владелец партии (admin-уведомление от owner), site-администратор
|
||||||
|
(admin-уведомление от оператора) или собственно автомат лобби
|
||||||
|
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||||
|
`membership.blocked`).
|
||||||
|
|
||||||
|
`broadcast_scope` фиксирует тип отправки: одному получателю
|
||||||
|
(`single`), рассылка по одной партии (`game_broadcast`) или
|
||||||
|
admin-рассылка по нескольким партиям (`multi_game_broadcast`).
|
||||||
|
Получатели multi_game-рассылки видят отдельную, независимо
|
||||||
|
удаляемую запись inbox в каждой адресованной партии.
|
||||||
|
|
||||||
|
Снимки сохраняются прямо в строках сообщения и получателя:
|
||||||
|
`game_name`, `sender_username`, `sender_ip` и на стороне
|
||||||
|
получателя — `recipient_user_name`, `recipient_race_name` и
|
||||||
|
`recipient_preferred_language`. Они переживают переименование
|
||||||
|
партии, отзыв членства, soft-delete аккаунта и итоговый
|
||||||
|
bulk-purge — admin observability отрисовывается корректно даже
|
||||||
|
после исчезновения «живых» строк.
|
||||||
|
|
||||||
|
Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит
|
||||||
|
и не экранирует HTML; клиент рендерит тело через `textContent`.
|
||||||
|
Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||||
|
(по умолчанию `4096`); максимум для subject —
|
||||||
|
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`).
|
||||||
|
|
||||||
|
### 11.3 Отправка почты
|
||||||
|
|
||||||
|
Персональная отправка требует активного членства в партии и от
|
||||||
|
отправителя, и от получателя. Игроки free-tier отправляют одно
|
||||||
|
персональное сообщение за запрос. Игрокам платных тиров доступен
|
||||||
|
и игровой broadcast — одна отправка на всех остальных активных
|
||||||
|
участников партии; ответы возвращаются автору broadcast.
|
||||||
|
|
||||||
|
Владельцы (приватных партий) и site-администраторы отправляют
|
||||||
|
admin-уведомления. Endpoint владельца находится на user-поверхности
|
||||||
|
(аутентификация по `X-User-ID`, проверка владельца в обработчике);
|
||||||
|
endpoint администратора — на admin-поверхности (HTTP Basic). Оба
|
||||||
|
принимают `target=user` (один получатель) или `target=all`
|
||||||
|
(broadcast в одной партии). Site-администратору доступен
|
||||||
|
дополнительный multi-game endpoint, принимающий
|
||||||
|
`scope=selected` со списком game_id или `scope=all_running` —
|
||||||
|
перебор всех партий в нетерминальных состояниях.
|
||||||
|
|
||||||
|
Состав получателей broadcast параметризуется полем `recipients`:
|
||||||
|
`active` (по умолчанию), `active_and_removed` или `all_members`
|
||||||
|
(включает блокированных, для аудит-уведомлений). Собственная
|
||||||
|
recipient-строка автора broadcast не создаётся.
|
||||||
|
|
||||||
|
Player-broadcast от free-tier пользователя отклоняется кодом
|
||||||
|
`403 forbidden`.
|
||||||
|
|
||||||
|
### 11.4 Получение почты
|
||||||
|
|
||||||
|
Получатель видит сообщение в своём inbox только после того, как
|
||||||
|
асинхронный worker перевода обработал его (см.
|
||||||
|
[§11.6](#116-перевод)). До этого строка невидима: не выводится в
|
||||||
|
inbox-листинге, не учитывается в badge непрочитанного, push-событие
|
||||||
|
не доставляется. Это исключает ситуацию «строка появилась, перевод
|
||||||
|
не подъехал, badge мигает».
|
||||||
|
|
||||||
|
Badge непрочитанного в лобби агрегируется по партиям. Endpoint
|
||||||
|
`/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи
|
||||||
|
на каждую партию с ненулевым unread плюс общий total; UI лобби
|
||||||
|
отображает общий badge и плитки по партиям, не раскрывая самих
|
||||||
|
сообщений.
|
||||||
|
|
||||||
|
Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже
|
||||||
|
было помечено прочитанным — клиент не может стереть неоткрытое
|
||||||
|
сообщение. Soft-удаление действует только для одного получателя:
|
||||||
|
строка самого сообщения переживает удаление вплоть до admin
|
||||||
|
bulk-purge всей почты соответствующей партии.
|
||||||
|
|
||||||
|
Ответ message-detail содержит и оригинальное тело, и (если есть
|
||||||
|
кэш) перевод; UI по умолчанию показывает перевод и предлагает
|
||||||
|
переключение «показать оригинал».
|
||||||
|
|
||||||
|
Внутриигровой UI группирует личную почту по веткам по расам —
|
||||||
|
каждая личная переписка между локальным игроком и другой расой
|
||||||
|
оказывается в одной ветке, ключевая по расе собеседника.
|
||||||
|
Системные сообщения, административные уведомления и собственные
|
||||||
|
рассылки игрока (платный тариф) показываются отдельными
|
||||||
|
автономными записями в том же списке и никогда не группируются.
|
||||||
|
`read_at` и `deleted_at` поддерживают локальный счётчик
|
||||||
|
непрочитанного и кнопку удаления, но не показываются игроку —
|
||||||
|
дипломатическая почта не обещает уведомления о прочтении. Форма
|
||||||
|
compose выбирает получателя по имени расы (сервер резолвит через
|
||||||
|
`Memberships.ListMembers(game_id, "active")`); клиент не тянет
|
||||||
|
отдельный список членов. Подробнее — в
|
||||||
|
[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md).
|
||||||
|
|
||||||
|
### 11.5 Хуки жизненного цикла
|
||||||
|
|
||||||
|
Три транзитных перехода в лобби порождают system mail в inbox
|
||||||
|
затронутых игроков:
|
||||||
|
|
||||||
|
- **Пауза / отмена игры.** Когда автомат партии проходит через
|
||||||
|
`paused` или `cancelled`, лобби эмитит system-сообщение всем
|
||||||
|
активным членам. Текст рендерится сервером по шаблону, чтобы
|
||||||
|
игрок, открывший inbox позже, нашёл объяснение даже без
|
||||||
|
одновременной push-сессии.
|
||||||
|
- **Удаление / блокировка членства.** Сам-выход, удаление
|
||||||
|
владельцем и admin-бан порождают system-сообщение только для
|
||||||
|
затронутого игрока. Это письмо переживает переход членства в
|
||||||
|
`removed` / `blocked` — игрок сохраняет к нему read-доступ
|
||||||
|
навсегда (правило soft-доступа).
|
||||||
|
|
||||||
|
Будущее удаление по неактивности должно вызывать тот же publisher,
|
||||||
|
чтобы объяснение дошло до затронутого игрока; README пакета
|
||||||
|
прибивает этот контракт для следующего реализатора.
|
||||||
|
|
||||||
|
### 11.6 Перевод
|
||||||
|
|
||||||
|
`diplomail_messages.body_lang` заполняется на стороне сервера в
|
||||||
|
момент отправки внутрипроцессным детектором языка, работающим
|
||||||
|
только по телу. Subject наследует язык тела для ключа кэша
|
||||||
|
перевода. Когда детектор не может уверенно классифицировать тело
|
||||||
|
(слишком короткое, пустое, смешанные скрипты), значение —
|
||||||
|
плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода
|
||||||
|
обходится стороной — получатели видят оригинал.
|
||||||
|
|
||||||
|
Перевод выполняется асинхронно. Каждая recipient-строка содержит
|
||||||
|
снимок `preferred_language` получателя плюс метку `available_at`.
|
||||||
|
Получатель, чей язык совпадает с детектированным `body_lang` (или
|
||||||
|
чей preferred_language пуст / язык тела — `und`), получает
|
||||||
|
`available_at = now()` сразу при вставке, и push-событие
|
||||||
|
отправляется в момент `POST`. Получатель с отличающимся языком
|
||||||
|
вставляется с `available_at IS NULL` и ждёт worker.
|
||||||
|
|
||||||
|
Worker (`internal/diplomail.Worker`) тикает каждые
|
||||||
|
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и
|
||||||
|
обрабатывает по одной паре `(message_id, target_lang)` за тик. Он
|
||||||
|
сначала смотрит в кэш переводов; на miss дёргает настроенный
|
||||||
|
`Translator`. Дефолт production-сборки — LibreTranslate HTTP
|
||||||
|
клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет
|
||||||
|
noop-translator, который доставляет сообщение в оригинале.
|
||||||
|
|
||||||
|
Исходы перевода:
|
||||||
|
|
||||||
|
- **Успех.** Строка в `diplomail_translations` создаётся (или
|
||||||
|
переиспользуется, если параллельная попытка успела раньше),
|
||||||
|
все pending-получатели пары переключаются на
|
||||||
|
`available_at = now()`, и по каждому отправляется push.
|
||||||
|
- **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate).
|
||||||
|
Строка перевода не сохраняется; получатели доставляются с
|
||||||
|
оригинальным телом. Последующие чтения возвращают оригинал.
|
||||||
|
- **Транзитный сбой** (timeout, 5xx, network error). Счётчик
|
||||||
|
попыток увеличивается, следующая попытка планируется по
|
||||||
|
экспоненциальному backoff `1s → 2s → 4s → 8s → 16s`
|
||||||
|
(с потолком 60s). После
|
||||||
|
`BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`)
|
||||||
|
worker fallback'ит на оригинальное тело. Длительный отказ
|
||||||
|
переводчика тормозит доставку максимум на ~30 секунд на пару
|
||||||
|
до того, как получатель увидит оригинал.
|
||||||
|
|
||||||
|
Кэш переводов общий: broadcast на N получателей с одинаковым
|
||||||
|
preferred_language порождает одну строку кэша и один вызов
|
||||||
|
переводчика, не N.
|
||||||
|
|
||||||
|
### 11.7 Хранение и purge
|
||||||
|
|
||||||
|
Сообщения живут в `diplomail_messages`; per-recipient state — в
|
||||||
|
`diplomail_recipients` с FK-каскадом на сообщение; переводы — в
|
||||||
|
`diplomail_translations` тоже с каскадом. IP-адрес отправителя
|
||||||
|
снимается из `X-Forwarded-For` (форвардит gateway) и хранится в
|
||||||
|
сообщении для сохранения доказательств.
|
||||||
|
|
||||||
|
Автоматического retention нет. Admin bulk-purge endpoint удаляет
|
||||||
|
все сообщения, чья партия завершилась более `older_than_years`
|
||||||
|
лет назад (минимум `1`); каскад удаляет recipient- и
|
||||||
|
translation-строки той же транзакцией.
|
||||||
|
|
||||||
|
### 11.8 Видимость для оператора
|
||||||
|
|
||||||
|
Admin-поверхность экспонирует постраничный листинг всех сообщений
|
||||||
|
(`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind`
|
||||||
|
и `sender_kind`. Bulk-purge endpoint
|
||||||
|
(`/api/v1/admin/mail/cleanup`) принимает порог
|
||||||
|
`older_than_years`. Per-game admin-отправки и multi-game
|
||||||
|
broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail`
|
||||||
|
и `/api/v1/admin/mail/broadcast`.
|
||||||
|
|
||||||
|
### 11.9 Перекрёстные ссылки
|
||||||
|
|
||||||
|
- Обзор пакета и карта стадий:
|
||||||
|
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||||
|
- Рецепт развёртывания LibreTranslate для локальной разработки:
|
||||||
|
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||||
|
- Детали хранения:
|
||||||
|
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||||
|
- Push-транспорт для событий доставки: [Раздел 7](#7-канал-push).
|
||||||
|
- Notification-каталог: kind `diplomail.message.received`:
|
||||||
|
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||||
|
|||||||
@@ -272,29 +272,19 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
|||||||
if productionAvailable <= 0 {
|
if productionAvailable <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
ships := uint(0)
|
ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
|
||||||
pa := productionAvailable
|
productionAvailable,
|
||||||
var MATneed, totalCost float64
|
float64(p.Material),
|
||||||
for {
|
float64(p.Resources),
|
||||||
MATneed = shipMass - float64(p.Material)
|
shipMass,
|
||||||
if MATneed < 0 {
|
)
|
||||||
MATneed = 0
|
p.Mat(materialLeft)
|
||||||
}
|
pval := game.F(progress)
|
||||||
totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources))
|
if p.Production.Progress != nil {
|
||||||
if pa < totalCost {
|
pval += *p.Production.Progress
|
||||||
progress := pa / totalCost
|
|
||||||
pval := game.F(progress)
|
|
||||||
if p.Production.Progress != nil {
|
|
||||||
pval += *p.Production.Progress
|
|
||||||
}
|
|
||||||
p.Production.Progress = &pval
|
|
||||||
fval := game.F(pa)
|
|
||||||
p.Production.ProdUsed = &fval
|
|
||||||
return ships
|
|
||||||
} else {
|
|
||||||
pa -= totalCost
|
|
||||||
p.Mat(float64(p.Material) - shipMass + MATneed)
|
|
||||||
ships += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
p.Production.Progress = &pval
|
||||||
|
used := game.F(productionUsed)
|
||||||
|
p.Production.ProdUsed = &used
|
||||||
|
return ships
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,21 @@ func TestPlanetRandomName(t *testing.T) {
|
|||||||
for _, pc := range []g.PlanetClass{g.PlanetClassHW, g.PlanetClassDW, g.PlanetClassGiant, g.PlanetClassBig, g.PlanetClassNormal, g.PlanetClassRich, g.PlanetClassAsterioid} {
|
for _, pc := range []g.PlanetClass{g.PlanetClassHW, g.PlanetClassDW, g.PlanetClassGiant, g.PlanetClassBig, g.PlanetClassNormal, g.PlanetClassRich, g.PlanetClassAsterioid} {
|
||||||
t.Run(string(pc), func(t *testing.T) {
|
t.Run(string(pc), func(t *testing.T) {
|
||||||
name := g.NewPlanet(pc, g.Coordinate{0, 0}, 0, 0).RandomName()
|
name := g.NewPlanet(pc, g.Coordinate{0, 0}, 0, 0).RandomName()
|
||||||
g := re.FindStringSubmatch(name)
|
// `RandomName` formats `<class>-<4-digit>-<4-digit>`,
|
||||||
assert.NotNilf(t, g, "cannot parse: %q", name)
|
// where each suffix is an independent `rand.Intn(1000)`.
|
||||||
if g == nil {
|
// We assert the wire format and the class prefix; an
|
||||||
|
// earlier revision also asserted `g[2] != g[3]`, which
|
||||||
|
// flaked on the legitimate ~0.1% collision (a property
|
||||||
|
// the function does not — and need not — guarantee:
|
||||||
|
// `generate_game.go` already tolerates duplicate names
|
||||||
|
// across planets).
|
||||||
|
groups := re.FindStringSubmatch(name)
|
||||||
|
assert.NotNilf(t, groups, "cannot parse: %q", name)
|
||||||
|
if groups == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.Equalf(t, 4, len(g), "regexp groups")
|
assert.Equalf(t, 4, len(groups), "regexp groups")
|
||||||
assert.Equal(t, string(pc), g[1])
|
assert.Equal(t, string(pc), groups[1])
|
||||||
assert.NotEqual(t, g[2], g[3])
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package game
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"galaxy/calc"
|
"galaxy/calc"
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -208,11 +207,12 @@ func (sg ShipGroup) Speed(st *ShipType) float64 {
|
|||||||
|
|
||||||
// Мощность бомбардировки
|
// Мощность бомбардировки
|
||||||
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
|
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
|
||||||
return (math.Sqrt(st.Weapons.F()*sg.TechLevel(TechWeapons).F())/10. + 1.) *
|
return calc.BombingPower(
|
||||||
st.Weapons.F() *
|
st.Weapons.F(),
|
||||||
sg.TechLevel(TechWeapons).F() *
|
sg.TechLevel(TechWeapons).F(),
|
||||||
float64(st.Armament) *
|
float64(st.Armament),
|
||||||
float64(sg.Number)
|
float64(sg.Number),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sg ShipGroup) CargoString() string {
|
func (sg ShipGroup) CargoString() string {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (r Race) TechLevel(t Tech) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Race) FlightDistance() float64 {
|
func (r Race) FlightDistance() float64 {
|
||||||
return calc.FligthDistance(r.TechLevel(TechDrive))
|
return calc.FlightDistance(r.TechLevel(TechDrive))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Race) VisibilityDistance() float64 {
|
func (r Race) VisibilityDistance() float64 {
|
||||||
|
|||||||
+11
-1
@@ -294,7 +294,17 @@ the stream to the verified `user_id` and `device_session_id`, sends one
|
|||||||
signed `gateway.server_time` bootstrap event whose FlatBuffers payload carries
|
signed `gateway.server_time` bootstrap event whose FlatBuffers payload carries
|
||||||
`server_time_ms`, registers the active stream in the in-memory `PushHub`, and
|
`server_time_ms`, registers the active stream in the in-memory `PushHub`, and
|
||||||
then forwards signed client-facing events consumed from the configured client
|
then forwards signed client-facing events consumed from the configured client
|
||||||
event Redis stream. User-targeted events fan out to every active stream for
|
event Redis stream. After the bootstrap, the stream is wrapped with a
|
||||||
|
silence-based heartbeat: when no real event has been forwarded for
|
||||||
|
`GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`, set to `0s` to disable),
|
||||||
|
the gateway emits an unsigned `gateway.heartbeat` event so browser
|
||||||
|
fetch-streaming layers (Safari is the most aggressive) keep the response
|
||||||
|
body open and pending push events are not lost into the client-side
|
||||||
|
reconnect window. Each emission is counted by the
|
||||||
|
`gateway.push.heartbeats_sent{outcome}` metric; see
|
||||||
|
[`docs/ARCHITECTURE.md` § 15](../docs/ARCHITECTURE.md#15-transport-security-model-gateway-boundary)
|
||||||
|
for the bandwidth projection and the reasoning behind the unsigned
|
||||||
|
envelope. User-targeted events fan out to every active stream for
|
||||||
that user. Session-targeted events fan out only to streams whose
|
that user. Session-targeted events fan out only to streams whose
|
||||||
`user_id` and `device_session_id` both match the event target. Each active
|
`user_id` and `device_session_id` both match the event target. Each active
|
||||||
stream uses a bounded in-memory queue; when that queue overflows, only the
|
stream uses a bounded in-memory queue; when that queue overflows, only the
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
|||||||
userRoutes := backendclient.UserRoutes(backend.REST())
|
userRoutes := backendclient.UserRoutes(backend.REST())
|
||||||
lobbyRoutes := backendclient.LobbyRoutes(backend.REST())
|
lobbyRoutes := backendclient.LobbyRoutes(backend.REST())
|
||||||
gameRoutes := backendclient.GameRoutes(backend.REST())
|
gameRoutes := backendclient.GameRoutes(backend.REST())
|
||||||
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes))
|
mailRoutes := backendclient.MailRoutes(backend.REST())
|
||||||
|
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes)+len(mailRoutes))
|
||||||
for k, v := range userRoutes {
|
for k, v := range userRoutes {
|
||||||
allRoutes[k] = v
|
allRoutes[k] = v
|
||||||
}
|
}
|
||||||
@@ -196,6 +197,9 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
|||||||
for k, v := range gameRoutes {
|
for k, v := range gameRoutes {
|
||||||
allRoutes[k] = v
|
allRoutes[k] = v
|
||||||
}
|
}
|
||||||
|
for k, v := range mailRoutes {
|
||||||
|
allRoutes[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
cleanup := func() error {
|
cleanup := func() error {
|
||||||
return closeRedisClient()
|
return closeRedisClient()
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.
|
|||||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
|
||||||
}
|
}
|
||||||
return c.executeUserGamesReport(ctx, command.UserID, req)
|
return c.executeUserGamesReport(ctx, command.UserID, req)
|
||||||
|
case reportmodel.MessageTypeUserGamesBattle:
|
||||||
|
req, err := transcoder.PayloadToGameBattleRequest(command.PayloadBytes)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
|
||||||
|
}
|
||||||
|
return c.executeUserGamesBattle(ctx, command.UserID, req)
|
||||||
default:
|
default:
|
||||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command: unsupported message type %q", command.MessageType)
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command: unsupported message type %q", command.MessageType)
|
||||||
}
|
}
|
||||||
@@ -127,6 +133,26 @@ func (c *RESTClient) executeUserGamesReport(ctx context.Context, userID string,
|
|||||||
return projectUserGamesReportResponse(status, respBody)
|
return projectUserGamesReportResponse(status, respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeUserGamesBattle(ctx context.Context, userID string, req *reportmodel.GameBattleRequest) (downstream.UnaryResult, error) {
|
||||||
|
if req.GameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.battle: game_id must not be empty")
|
||||||
|
}
|
||||||
|
if req.BattleID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.battle: battle_id must not be empty")
|
||||||
|
}
|
||||||
|
target := fmt.Sprintf("%s/api/v1/user/games/%s/battles/%d/%s",
|
||||||
|
c.baseURL,
|
||||||
|
url.PathEscape(req.GameID.String()),
|
||||||
|
req.Turn,
|
||||||
|
url.PathEscape(req.BattleID.String()),
|
||||||
|
)
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.battle: %w", err)
|
||||||
|
}
|
||||||
|
return projectUserGamesBattleResponse(status, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
// buildEngineCommandBody serialises a slice of typed commands into the
|
// buildEngineCommandBody serialises a slice of typed commands into the
|
||||||
// JSON shape expected by backend's command/order handlers (a
|
// JSON shape expected by backend's command/order handlers (a
|
||||||
// `gamerest.Command` with the actor field left empty — backend rebinds
|
// `gamerest.Command` with the actor field left empty — backend rebinds
|
||||||
@@ -262,3 +288,32 @@ func projectUserGamesReportResponse(statusCode int, payload []byte) (downstream.
|
|||||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectUserGamesBattleResponse decodes the engine's BattleReport JSON
|
||||||
|
// payload (forwarded by backend's user.games.battle proxy) and
|
||||||
|
// re-encodes it as a FlatBuffers BattleReport for the signed-gRPC
|
||||||
|
// client. 404 from backend surfaces as the canonical `not_found`
|
||||||
|
// gateway error so the UI can render its "battle not found" state.
|
||||||
|
func projectUserGamesBattleResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
switch {
|
||||||
|
case statusCode == http.StatusOK:
|
||||||
|
var report reportmodel.BattleReport
|
||||||
|
if err := json.Unmarshal(payload, &report); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode engine battle report: %w", err)
|
||||||
|
}
|
||||||
|
encoded, err := transcoder.BattleReportToPayload(&report)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("encode battle report payload: %w", err)
|
||||||
|
}
|
||||||
|
return downstream.UnaryResult{
|
||||||
|
ResultCode: userCommandResultCodeOK,
|
||||||
|
PayloadBytes: encoded,
|
||||||
|
}, nil
|
||||||
|
case statusCode == http.StatusServiceUnavailable:
|
||||||
|
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||||
|
case statusCode >= 400 && statusCode <= 599:
|
||||||
|
return projectUserBackendError(statusCode, payload)
|
||||||
|
default:
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"galaxy/gateway/internal/backendclient"
|
"galaxy/gateway/internal/backendclient"
|
||||||
"galaxy/gateway/internal/downstream"
|
"galaxy/gateway/internal/downstream"
|
||||||
ordermodel "galaxy/model/order"
|
ordermodel "galaxy/model/order"
|
||||||
|
reportmodel "galaxy/model/report"
|
||||||
"galaxy/transcoder"
|
"galaxy/transcoder"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -170,6 +171,78 @@ func TestExecuteUserGamesOrderGetRejectsNegativeTurn(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "user.games.order.get")
|
assert.Contains(t, err.Error(), "user.games.order.get")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteUserGamesBattleForwardsAndDecodesResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gameID := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa")
|
||||||
|
battleID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodGet, r.Method)
|
||||||
|
require.Equal(t,
|
||||||
|
"/api/v1/user/games/"+gameID.String()+"/battles/7/"+battleID.String(),
|
||||||
|
r.URL.Path,
|
||||||
|
)
|
||||||
|
require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID))
|
||||||
|
raceID := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||||
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||||
|
"id": battleID.String(),
|
||||||
|
"planet": uint(42),
|
||||||
|
"planetName": "Tau Ceti II",
|
||||||
|
"races": map[string]string{"1": raceID.String()},
|
||||||
|
"ships": map[string]map[string]any{},
|
||||||
|
"protocol": []any{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
client := newRESTClient(t, server)
|
||||||
|
payload, err := transcoder.GameBattleRequestToPayload(&reportmodel.GameBattleRequest{
|
||||||
|
GameID: gameID,
|
||||||
|
Turn: 7,
|
||||||
|
BattleID: battleID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, err := client.ExecuteGameCommand(context.Background(), newAuthCommand(t, reportmodel.MessageTypeUserGamesBattle, payload))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ok", result.ResultCode)
|
||||||
|
|
||||||
|
decoded, err := transcoder.PayloadToBattleReport(result.PayloadBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, decoded)
|
||||||
|
assert.Equal(t, battleID, decoded.ID)
|
||||||
|
assert.Equal(t, uint(42), decoded.Planet)
|
||||||
|
assert.Equal(t, "Tau Ceti II", decoded.PlanetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteUserGamesBattleMapsNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gameID := uuid.MustParse("77777777-8888-9999-aaaa-bbbbbbbbbbbb")
|
||||||
|
battleID := uuid.MustParse("99999999-aaaa-bbbb-cccc-dddddddddddd")
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(t, w, http.StatusNotFound, map[string]any{
|
||||||
|
"error": map[string]any{
|
||||||
|
"code": "not_found",
|
||||||
|
"message": "battle not found",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
client := newRESTClient(t, server)
|
||||||
|
payload, err := transcoder.GameBattleRequestToPayload(&reportmodel.GameBattleRequest{
|
||||||
|
GameID: gameID,
|
||||||
|
Turn: 2,
|
||||||
|
BattleID: battleID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, err := client.ExecuteGameCommand(context.Background(), newAuthCommand(t, reportmodel.MessageTypeUserGamesBattle, payload))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "not_found", result.ResultCode)
|
||||||
|
}
|
||||||
|
|
||||||
// writeJSON copy below mirrors the helper used by other test files
|
// writeJSON copy below mirrors the helper used by other test files
|
||||||
// in this package; keeping it adjacent to its callers avoids
|
// in this package; keeping it adjacent to its callers avoids
|
||||||
// reaching across files in a fresh test.
|
// reaching across files in a fresh test.
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
package backendclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/gateway/internal/downstream"
|
||||||
|
diplomailmodel "galaxy/model/diplomail"
|
||||||
|
commonfbs "galaxy/schema/fbs/common"
|
||||||
|
fbs "galaxy/schema/fbs/diplomail"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecuteMailCommand routes one authenticated `user.games.mail.*`
|
||||||
|
// command into the matching `/api/v1/user/games/{game_id}/mail/...`
|
||||||
|
// backend REST endpoint. Each command decodes a FlatBuffers request
|
||||||
|
// payload, issues the REST call, decodes the JSON response, and
|
||||||
|
// re-encodes the result as a typed FlatBuffers envelope.
|
||||||
|
func (c *RESTClient) ExecuteMailCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||||
|
if c == nil || c.httpClient == nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil client")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil context")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return downstream.UnaryResult{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(command.UserID) == "" {
|
||||||
|
return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: user_id must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch command.MessageType {
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailInbox:
|
||||||
|
return c.executeMailInbox(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailSent:
|
||||||
|
return c.executeMailSent(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailMessageGet:
|
||||||
|
return c.executeMailMessageGet(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailSend:
|
||||||
|
return c.executeMailSend(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailBroadcast:
|
||||||
|
return c.executeMailBroadcast(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailAdmin:
|
||||||
|
return c.executeMailAdmin(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailRead:
|
||||||
|
return c.executeMailRead(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailDelete:
|
||||||
|
return c.executeMailDelete(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
default:
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute mail command: unsupported message type %q", command.MessageType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailMessageJSON mirrors the backend's `UserMailMessageDetail` wire
|
||||||
|
// shape from `backend/openapi.yaml`. Pointer fields are nullable in
|
||||||
|
// the OpenAPI spec; the encoder treats empty strings as "absent".
|
||||||
|
type mailMessageJSON 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"`
|
||||||
|
SenderRaceName *string `json:"sender_race_name,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"`
|
||||||
|
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
||||||
|
TranslatedBody *string `json:"translated_body,omitempty"`
|
||||||
|
TranslationLang *string `json:"translation_lang,omitempty"`
|
||||||
|
Translator *string `json:"translator,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailRecipientStateJSON mirrors `UserMailRecipientState`.
|
||||||
|
type mailRecipientStateJSON struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
ReadAt *string `json:"read_at,omitempty"`
|
||||||
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailBroadcastReceiptJSON mirrors `UserMailBroadcastReceipt`.
|
||||||
|
type mailBroadcastReceiptJSON 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"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
BodyLang string `json:"body_lang"`
|
||||||
|
BroadcastScope string `json:"broadcast_scope"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RecipientCount int `json:"recipient_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailInboxJSON struct {
|
||||||
|
Items []mailMessageJSON `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailInbox(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsInboxRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: game_id is missing")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/inbox"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.inbox: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var resp mailInboxJSON
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail inbox response: %w", err)
|
||||||
|
}
|
||||||
|
out := encodeMailMessageList(resp.Items, fbs.InboxResponseStart, fbs.InboxResponseAddItems, fbs.InboxResponseEnd, fbs.FinishInboxResponseBuffer)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailSent(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsSentRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: game_id is missing")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/sent"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.sent: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var resp mailInboxJSON
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail sent response: %w", err)
|
||||||
|
}
|
||||||
|
out := encodeMailMessageList(resp.Items, fbs.SentResponseStart, fbs.SentResponseAddItems, fbs.SentResponseEnd, fbs.FinishSentResponseBuffer)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailMessageGet(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsMessageGetRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
messageID := readUUID(flat.MessageId(nil))
|
||||||
|
if gameID == uuid.Nil || messageID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: game_id and message_id are required")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String())
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.message.get: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var msg mailMessageJSON
|
||||||
|
if err := json.Unmarshal(respBody, &msg); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail message response: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(512)
|
||||||
|
msgOff := encodeMailMessage(builder, &msg)
|
||||||
|
fbs.MessageGetResponseStart(builder)
|
||||||
|
fbs.MessageGetResponseAddMessage(builder, msgOff)
|
||||||
|
root := fbs.MessageGetResponseEnd(builder)
|
||||||
|
fbs.FinishMessageGetResponseBuffer(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailSend(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsSendRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: game_id is missing")
|
||||||
|
}
|
||||||
|
body := struct {
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
RecipientUserID: string(flat.RecipientUserId()),
|
||||||
|
RecipientRaceName: string(flat.RecipientRaceName()),
|
||||||
|
Subject: string(flat.Subject()),
|
||||||
|
Body: string(flat.Body()),
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.send: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var msg mailMessageJSON
|
||||||
|
if err := json.Unmarshal(respBody, &msg); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail send response: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(512)
|
||||||
|
msgOff := encodeMailMessage(builder, &msg)
|
||||||
|
fbs.SendResponseStart(builder)
|
||||||
|
fbs.SendResponseAddMessage(builder, msgOff)
|
||||||
|
root := fbs.SendResponseEnd(builder)
|
||||||
|
fbs.FinishSendResponseBuffer(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailBroadcast(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsBroadcastRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: game_id is missing")
|
||||||
|
}
|
||||||
|
body := struct {
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
Subject: string(flat.Subject()),
|
||||||
|
Body: string(flat.Body()),
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/broadcast"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.broadcast: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var receipt mailBroadcastReceiptJSON
|
||||||
|
if err := json.Unmarshal(respBody, &receipt); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail broadcast response: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(256)
|
||||||
|
recOff := encodeMailBroadcastReceipt(builder, &receipt)
|
||||||
|
fbs.BroadcastResponseStart(builder)
|
||||||
|
fbs.BroadcastResponseAddReceipt(builder, recOff)
|
||||||
|
root := fbs.BroadcastResponseEnd(builder)
|
||||||
|
fbs.FinishBroadcastResponseBuffer(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailAdmin(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsAdminRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: game_id is missing")
|
||||||
|
}
|
||||||
|
target := string(flat.Target())
|
||||||
|
body := struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
|
Recipients string `json:"recipients,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
Target: target,
|
||||||
|
RecipientUserID: string(flat.RecipientUserId()),
|
||||||
|
RecipientRaceName: string(flat.RecipientRaceName()),
|
||||||
|
Recipients: string(flat.Recipients()),
|
||||||
|
Subject: string(flat.Subject()),
|
||||||
|
Body: string(flat.Body()),
|
||||||
|
}
|
||||||
|
url := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/admin"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, url, userID, body)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.admin: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(512)
|
||||||
|
if target == "all" {
|
||||||
|
var receipt mailBroadcastReceiptJSON
|
||||||
|
if err := json.Unmarshal(respBody, &receipt); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail admin broadcast response: %w", err)
|
||||||
|
}
|
||||||
|
recOff := encodeMailBroadcastReceipt(builder, &receipt)
|
||||||
|
fbs.AdminResponseStart(builder)
|
||||||
|
fbs.AdminResponseAddReceipt(builder, recOff)
|
||||||
|
root := fbs.AdminResponseEnd(builder)
|
||||||
|
fbs.FinishAdminResponseBuffer(builder, root)
|
||||||
|
} else {
|
||||||
|
var msg mailMessageJSON
|
||||||
|
if err := json.Unmarshal(respBody, &msg); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail admin send response: %w", err)
|
||||||
|
}
|
||||||
|
msgOff := encodeMailMessage(builder, &msg)
|
||||||
|
fbs.AdminResponseStart(builder)
|
||||||
|
fbs.AdminResponseAddMessage(builder, msgOff)
|
||||||
|
root := fbs.AdminResponseEnd(builder)
|
||||||
|
fbs.FinishAdminResponseBuffer(builder, root)
|
||||||
|
}
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailRead(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsReadRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
messageID := readUUID(flat.MessageId(nil))
|
||||||
|
if gameID == uuid.Nil || messageID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: game_id and message_id are required")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + "/read"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.read: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
return encodeRecipientStateResponse(respBody, fbs.ReadResponseStart, fbs.ReadResponseAddState, fbs.ReadResponseEnd, fbs.FinishReadResponseBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailDelete(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsDeleteRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
messageID := readUUID(flat.MessageId(nil))
|
||||||
|
if gameID == uuid.Nil || messageID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: game_id and message_id are required")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String())
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodDelete, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.delete: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
return encodeRecipientStateResponse(respBody, fbs.DeleteResponseStart, fbs.DeleteResponseAddState, fbs.DeleteResponseEnd, fbs.FinishDeleteResponseBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeRecipientStateResponse decodes the JSON recipient-state body
|
||||||
|
// and emits the corresponding FlatBuffers Read/Delete envelope. The
|
||||||
|
// caller supplies the trio of envelope start / add-state / end / finish
|
||||||
|
// functions so this helper covers both endpoints with the same shape.
|
||||||
|
func encodeRecipientStateResponse(respBody []byte,
|
||||||
|
startFn func(*flatbuffers.Builder),
|
||||||
|
addStateFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT,
|
||||||
|
finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
) (downstream.UnaryResult, error) {
|
||||||
|
var state mailRecipientStateJSON
|
||||||
|
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail recipient state: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(128)
|
||||||
|
stateOff := encodeMailRecipientState(builder, &state)
|
||||||
|
startFn(builder)
|
||||||
|
addStateFn(builder, stateOff)
|
||||||
|
root := endFn(builder)
|
||||||
|
finishFn(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailMessageList is a shared helper that encodes a slice of
|
||||||
|
// mailMessageJSON items into either an InboxResponse or a
|
||||||
|
// SentResponse FlatBuffers envelope. The two envelopes have the same
|
||||||
|
// shape (just a `items` vector of MailMessage) so the trio of
|
||||||
|
// constructor functions parameterises the helper.
|
||||||
|
func encodeMailMessageList(items []mailMessageJSON,
|
||||||
|
startFn func(*flatbuffers.Builder),
|
||||||
|
addItemsFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT,
|
||||||
|
finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
) []byte {
|
||||||
|
builder := flatbuffers.NewBuilder(1024)
|
||||||
|
offsets := make([]flatbuffers.UOffsetT, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
offsets = append(offsets, encodeMailMessage(builder, &items[i]))
|
||||||
|
}
|
||||||
|
// FlatBuffers vectors are built in reverse: prepend each offset.
|
||||||
|
builder.StartVector(4, len(offsets), 4)
|
||||||
|
for i := len(offsets) - 1; i >= 0; i-- {
|
||||||
|
builder.PrependUOffsetT(offsets[i])
|
||||||
|
}
|
||||||
|
itemsVec := builder.EndVector(len(offsets))
|
||||||
|
startFn(builder)
|
||||||
|
addItemsFn(builder, itemsVec)
|
||||||
|
root := endFn(builder)
|
||||||
|
finishFn(builder, root)
|
||||||
|
return builder.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailMessage builds a MailMessage table inside builder. Returns
|
||||||
|
// the offset of the finished table. Strings are interned through the
|
||||||
|
// builder; missing JSON fields (nil pointers, empty strings) yield
|
||||||
|
// empty FB strings which the readers treat as absent.
|
||||||
|
func encodeMailMessage(builder *flatbuffers.Builder, m *mailMessageJSON) flatbuffers.UOffsetT {
|
||||||
|
messageIDOff := builder.CreateString(m.MessageID)
|
||||||
|
gameIDOff := builder.CreateString(m.GameID)
|
||||||
|
gameNameOff := builder.CreateString(m.GameName)
|
||||||
|
kindOff := builder.CreateString(m.Kind)
|
||||||
|
senderKindOff := builder.CreateString(m.SenderKind)
|
||||||
|
senderUserIDOff := builder.CreateString(stringPtrValue(m.SenderUserID))
|
||||||
|
senderUsernameOff := builder.CreateString(stringPtrValue(m.SenderUsername))
|
||||||
|
senderRaceNameOff := builder.CreateString(stringPtrValue(m.SenderRaceName))
|
||||||
|
subjectOff := builder.CreateString(m.Subject)
|
||||||
|
bodyOff := builder.CreateString(m.Body)
|
||||||
|
bodyLangOff := builder.CreateString(m.BodyLang)
|
||||||
|
broadcastScopeOff := builder.CreateString(m.BroadcastScope)
|
||||||
|
recipientUserIDOff := builder.CreateString(m.RecipientUserID)
|
||||||
|
recipientUserNameOff := builder.CreateString(m.RecipientUserName)
|
||||||
|
recipientRaceNameOff := builder.CreateString(stringPtrValue(m.RecipientRaceName))
|
||||||
|
translatedSubjectOff := builder.CreateString(stringPtrValue(m.TranslatedSubject))
|
||||||
|
translatedBodyOff := builder.CreateString(stringPtrValue(m.TranslatedBody))
|
||||||
|
translationLangOff := builder.CreateString(stringPtrValue(m.TranslationLang))
|
||||||
|
translatorOff := builder.CreateString(stringPtrValue(m.Translator))
|
||||||
|
|
||||||
|
fbs.MailMessageStart(builder)
|
||||||
|
fbs.MailMessageAddMessageId(builder, messageIDOff)
|
||||||
|
fbs.MailMessageAddGameId(builder, gameIDOff)
|
||||||
|
fbs.MailMessageAddGameName(builder, gameNameOff)
|
||||||
|
fbs.MailMessageAddKind(builder, kindOff)
|
||||||
|
fbs.MailMessageAddSenderKind(builder, senderKindOff)
|
||||||
|
fbs.MailMessageAddSenderUserId(builder, senderUserIDOff)
|
||||||
|
fbs.MailMessageAddSenderUsername(builder, senderUsernameOff)
|
||||||
|
fbs.MailMessageAddSenderRaceName(builder, senderRaceNameOff)
|
||||||
|
fbs.MailMessageAddSubject(builder, subjectOff)
|
||||||
|
fbs.MailMessageAddBody(builder, bodyOff)
|
||||||
|
fbs.MailMessageAddBodyLang(builder, bodyLangOff)
|
||||||
|
fbs.MailMessageAddBroadcastScope(builder, broadcastScopeOff)
|
||||||
|
fbs.MailMessageAddCreatedAtMs(builder, parseRFC3339Millis(m.CreatedAt))
|
||||||
|
fbs.MailMessageAddRecipientUserId(builder, recipientUserIDOff)
|
||||||
|
fbs.MailMessageAddRecipientUserName(builder, recipientUserNameOff)
|
||||||
|
fbs.MailMessageAddRecipientRaceName(builder, recipientRaceNameOff)
|
||||||
|
fbs.MailMessageAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(m.ReadAt)))
|
||||||
|
fbs.MailMessageAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(m.DeletedAt)))
|
||||||
|
fbs.MailMessageAddTranslatedSubject(builder, translatedSubjectOff)
|
||||||
|
fbs.MailMessageAddTranslatedBody(builder, translatedBodyOff)
|
||||||
|
fbs.MailMessageAddTranslationLang(builder, translationLangOff)
|
||||||
|
fbs.MailMessageAddTranslator(builder, translatorOff)
|
||||||
|
return fbs.MailMessageEnd(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailRecipientState builds a MailRecipientState table.
|
||||||
|
func encodeMailRecipientState(builder *flatbuffers.Builder, s *mailRecipientStateJSON) flatbuffers.UOffsetT {
|
||||||
|
messageIDOff := builder.CreateString(s.MessageID)
|
||||||
|
fbs.MailRecipientStateStart(builder)
|
||||||
|
fbs.MailRecipientStateAddMessageId(builder, messageIDOff)
|
||||||
|
fbs.MailRecipientStateAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(s.ReadAt)))
|
||||||
|
fbs.MailRecipientStateAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(s.DeletedAt)))
|
||||||
|
return fbs.MailRecipientStateEnd(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailBroadcastReceipt builds a MailBroadcastReceipt table.
|
||||||
|
func encodeMailBroadcastReceipt(builder *flatbuffers.Builder, r *mailBroadcastReceiptJSON) flatbuffers.UOffsetT {
|
||||||
|
messageIDOff := builder.CreateString(r.MessageID)
|
||||||
|
gameIDOff := builder.CreateString(r.GameID)
|
||||||
|
gameNameOff := builder.CreateString(r.GameName)
|
||||||
|
kindOff := builder.CreateString(r.Kind)
|
||||||
|
senderKindOff := builder.CreateString(r.SenderKind)
|
||||||
|
subjectOff := builder.CreateString(r.Subject)
|
||||||
|
bodyOff := builder.CreateString(r.Body)
|
||||||
|
bodyLangOff := builder.CreateString(r.BodyLang)
|
||||||
|
broadcastScopeOff := builder.CreateString(r.BroadcastScope)
|
||||||
|
fbs.MailBroadcastReceiptStart(builder)
|
||||||
|
fbs.MailBroadcastReceiptAddMessageId(builder, messageIDOff)
|
||||||
|
fbs.MailBroadcastReceiptAddGameId(builder, gameIDOff)
|
||||||
|
fbs.MailBroadcastReceiptAddGameName(builder, gameNameOff)
|
||||||
|
fbs.MailBroadcastReceiptAddKind(builder, kindOff)
|
||||||
|
fbs.MailBroadcastReceiptAddSenderKind(builder, senderKindOff)
|
||||||
|
fbs.MailBroadcastReceiptAddSubject(builder, subjectOff)
|
||||||
|
fbs.MailBroadcastReceiptAddBody(builder, bodyOff)
|
||||||
|
fbs.MailBroadcastReceiptAddBodyLang(builder, bodyLangOff)
|
||||||
|
fbs.MailBroadcastReceiptAddBroadcastScope(builder, broadcastScopeOff)
|
||||||
|
fbs.MailBroadcastReceiptAddCreatedAtMs(builder, parseRFC3339Millis(r.CreatedAt))
|
||||||
|
fbs.MailBroadcastReceiptAddRecipientCount(builder, int32(r.RecipientCount))
|
||||||
|
return fbs.MailBroadcastReceiptEnd(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectMailErrorResponse maps a non-2xx response into a UnaryResult
|
||||||
|
// carrying the backend error envelope, reusing the shared user-mail
|
||||||
|
// error-projection. 503 is bubbled as ErrDownstreamUnavailable.
|
||||||
|
func projectMailErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if statusCode == http.StatusServiceUnavailable {
|
||||||
|
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||||
|
}
|
||||||
|
if statusCode >= 400 && statusCode <= 599 {
|
||||||
|
return projectUserBackendError(statusCode, payload)
|
||||||
|
}
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUUID converts the common.UUID struct (or its absence) into a
|
||||||
|
// google/uuid.UUID. Returns uuid.Nil when the input is nil.
|
||||||
|
func readUUID(u *commonfbs.UUID) uuid.UUID {
|
||||||
|
if u == nil {
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
var out uuid.UUID
|
||||||
|
hi := u.Hi()
|
||||||
|
lo := u.Lo()
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
out[i] = byte(hi >> (56 - 8*i))
|
||||||
|
out[i+8] = byte(lo >> (56 - 8*i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringPtrValue returns "" for nil and the dereferenced value
|
||||||
|
// otherwise. Used to flatten nullable JSON strings into the
|
||||||
|
// always-present FlatBuffers string slot.
|
||||||
|
func stringPtrValue(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRFC3339Millis parses an RFC 3339 timestamp string (the format
|
||||||
|
// the backend mail handler emits) into Unix milliseconds. Returns 0
|
||||||
|
// when the input is empty or unparseable, matching the "absent"
|
||||||
|
// convention for the *_at_ms wire fields.
|
||||||
|
func parseRFC3339Millis(s string) int64 {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.UnixMilli()
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package backendclient_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/gateway/internal/backendclient"
|
||||||
|
diplomailmodel "galaxy/model/diplomail"
|
||||||
|
commonfbs "galaxy/schema/fbs/common"
|
||||||
|
fbs "galaxy/schema/fbs/diplomail"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteMailInboxDecodesItems(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gameID := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodGet, r.Method)
|
||||||
|
require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/inbox", r.URL.Path)
|
||||||
|
require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID))
|
||||||
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||||
|
"items": []map[string]any{
|
||||||
|
{
|
||||||
|
"message_id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"game_id": gameID.String(),
|
||||||
|
"kind": "personal",
|
||||||
|
"sender_kind": "player",
|
||||||
|
"sender_user_id": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"sender_username": "alice",
|
||||||
|
"sender_race_name": "AliceRace",
|
||||||
|
"subject": "hi",
|
||||||
|
"body": "hello there",
|
||||||
|
"body_lang": "en",
|
||||||
|
"broadcast_scope": "single",
|
||||||
|
"created_at": "2026-05-15T12:00:00Z",
|
||||||
|
"recipient_user_id": "00000000-0000-0000-0000-000000000020",
|
||||||
|
"recipient_user_name": "bob",
|
||||||
|
"recipient_race_name": "BobRace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
client := newRESTClient(t, server)
|
||||||
|
payload := buildInboxRequest(gameID)
|
||||||
|
cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailInbox, payload)
|
||||||
|
result, err := client.ExecuteMailCommand(context.Background(), cmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ok", result.ResultCode)
|
||||||
|
|
||||||
|
resp := fbs.GetRootAsInboxResponse(result.PayloadBytes, 0)
|
||||||
|
require.Equal(t, 1, resp.ItemsLength())
|
||||||
|
var item fbs.MailMessage
|
||||||
|
require.True(t, resp.Items(&item, 0))
|
||||||
|
assert.Equal(t, "00000000-0000-0000-0000-000000000001", string(item.MessageId()))
|
||||||
|
assert.Equal(t, "AliceRace", string(item.SenderRaceName()))
|
||||||
|
assert.Equal(t, "BobRace", string(item.RecipientRaceName()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteMailSendForwardsRaceName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gameID := uuid.MustParse("22222222-3333-4444-5555-666666666666")
|
||||||
|
var captured struct {
|
||||||
|
Body string
|
||||||
|
RecipientUserID string
|
||||||
|
RecipientRaceName string
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages", r.URL.Path)
|
||||||
|
var req map[string]any
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &req))
|
||||||
|
if v, ok := req["body"].(string); ok {
|
||||||
|
captured.Body = v
|
||||||
|
}
|
||||||
|
if v, ok := req["recipient_user_id"].(string); ok {
|
||||||
|
captured.RecipientUserID = v
|
||||||
|
}
|
||||||
|
if v, ok := req["recipient_race_name"].(string); ok {
|
||||||
|
captured.RecipientRaceName = v
|
||||||
|
}
|
||||||
|
writeJSON(t, w, http.StatusCreated, map[string]any{
|
||||||
|
"message_id": "00000000-0000-0000-0000-000000000099",
|
||||||
|
"game_id": gameID.String(),
|
||||||
|
"kind": "personal",
|
||||||
|
"sender_kind": "player",
|
||||||
|
"sender_user_id": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"sender_race_name": "Senders",
|
||||||
|
"body": captured.Body,
|
||||||
|
"body_lang": "en",
|
||||||
|
"broadcast_scope": "single",
|
||||||
|
"created_at": "2026-05-15T12:00:00Z",
|
||||||
|
"recipient_user_id": "00000000-0000-0000-0000-000000000020",
|
||||||
|
"recipient_race_name": "Receivers",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
client := newRESTClient(t, server)
|
||||||
|
payload := buildSendRequestByRaceName(gameID, "Receivers", "let us talk")
|
||||||
|
cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailSend, payload)
|
||||||
|
result, err := client.ExecuteMailCommand(context.Background(), cmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ok", result.ResultCode)
|
||||||
|
|
||||||
|
resp := fbs.GetRootAsSendResponse(result.PayloadBytes, 0)
|
||||||
|
require.NotNil(t, resp.Message(nil))
|
||||||
|
msg := resp.Message(nil)
|
||||||
|
assert.Equal(t, "let us talk", string(msg.Body()))
|
||||||
|
assert.Equal(t, "Senders", string(msg.SenderRaceName()))
|
||||||
|
assert.Equal(t, "Receivers", string(msg.RecipientRaceName()))
|
||||||
|
|
||||||
|
assert.Empty(t, captured.RecipientUserID)
|
||||||
|
assert.Equal(t, "Receivers", captured.RecipientRaceName)
|
||||||
|
assert.Equal(t, "let us talk", captured.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteMailReadReturnsState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gameID := uuid.MustParse("33333333-4444-5555-6666-777777777777")
|
||||||
|
messageID := uuid.MustParse("00000000-0000-0000-0000-0000000000aa")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages/"+messageID.String()+"/read", r.URL.Path)
|
||||||
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||||
|
"message_id": messageID.String(),
|
||||||
|
"read_at": "2026-05-15T12:34:56Z",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
client := newRESTClient(t, server)
|
||||||
|
payload := buildReadRequest(gameID, messageID)
|
||||||
|
cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailRead, payload)
|
||||||
|
result, err := client.ExecuteMailCommand(context.Background(), cmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := fbs.GetRootAsReadResponse(result.PayloadBytes, 0)
|
||||||
|
state := resp.State(nil)
|
||||||
|
require.NotNil(t, state)
|
||||||
|
assert.Equal(t, messageID.String(), string(state.MessageId()))
|
||||||
|
assert.NotZero(t, state.ReadAtMs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInboxRequest emits a FlatBuffers InboxRequest envelope with
|
||||||
|
// the supplied game_id.
|
||||||
|
func buildInboxRequest(gameID uuid.UUID) []byte {
|
||||||
|
builder := flatbuffers.NewBuilder(64)
|
||||||
|
hi, lo := uuidToHiLo(gameID)
|
||||||
|
fbs.InboxRequestStart(builder)
|
||||||
|
fbs.InboxRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
|
||||||
|
root := fbs.InboxRequestEnd(builder)
|
||||||
|
fbs.FinishInboxRequestBuffer(builder, root)
|
||||||
|
return builder.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSendRequestByRaceName emits a FlatBuffers SendRequest that
|
||||||
|
// addresses the recipient by race name rather than user_id.
|
||||||
|
func buildSendRequestByRaceName(gameID uuid.UUID, raceName, body string) []byte {
|
||||||
|
builder := flatbuffers.NewBuilder(128)
|
||||||
|
raceOff := builder.CreateString(raceName)
|
||||||
|
bodyOff := builder.CreateString(body)
|
||||||
|
hi, lo := uuidToHiLo(gameID)
|
||||||
|
fbs.SendRequestStart(builder)
|
||||||
|
fbs.SendRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
|
||||||
|
fbs.SendRequestAddRecipientRaceName(builder, raceOff)
|
||||||
|
fbs.SendRequestAddBody(builder, bodyOff)
|
||||||
|
root := fbs.SendRequestEnd(builder)
|
||||||
|
fbs.FinishSendRequestBuffer(builder, root)
|
||||||
|
return builder.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReadRequest emits a FlatBuffers ReadRequest envelope.
|
||||||
|
func buildReadRequest(gameID, messageID uuid.UUID) []byte {
|
||||||
|
builder := flatbuffers.NewBuilder(64)
|
||||||
|
gameHi, gameLo := uuidToHiLo(gameID)
|
||||||
|
msgHi, msgLo := uuidToHiLo(messageID)
|
||||||
|
fbs.ReadRequestStart(builder)
|
||||||
|
fbs.ReadRequestAddGameId(builder, commonfbs.CreateUUID(builder, gameHi, gameLo))
|
||||||
|
fbs.ReadRequestAddMessageId(builder, commonfbs.CreateUUID(builder, msgHi, msgLo))
|
||||||
|
root := fbs.ReadRequestEnd(builder)
|
||||||
|
fbs.FinishReadRequestBuffer(builder, root)
|
||||||
|
return builder.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidToHiLo splits a 16-byte UUID into the two big-endian uint64
|
||||||
|
// halves the common.UUID struct uses.
|
||||||
|
func uuidToHiLo(u uuid.UUID) (uint64, uint64) {
|
||||||
|
var hi, lo uint64
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
hi = (hi << 8) | uint64(u[i])
|
||||||
|
lo = (lo << 8) | uint64(u[i+8])
|
||||||
|
}
|
||||||
|
return hi, lo
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"galaxy/gateway/internal/downstream"
|
"galaxy/gateway/internal/downstream"
|
||||||
|
diplomailmodel "galaxy/model/diplomail"
|
||||||
lobbymodel "galaxy/model/lobby"
|
lobbymodel "galaxy/model/lobby"
|
||||||
ordermodel "galaxy/model/order"
|
ordermodel "galaxy/model/order"
|
||||||
reportmodel "galaxy/model/report"
|
reportmodel "galaxy/model/report"
|
||||||
@@ -64,6 +65,28 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client {
|
|||||||
ordermodel.MessageTypeUserGamesOrder: target,
|
ordermodel.MessageTypeUserGamesOrder: target,
|
||||||
ordermodel.MessageTypeUserGamesOrderGet: target,
|
ordermodel.MessageTypeUserGamesOrderGet: target,
|
||||||
reportmodel.MessageTypeUserGamesReport: target,
|
reportmodel.MessageTypeUserGamesReport: target,
|
||||||
|
reportmodel.MessageTypeUserGamesBattle: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailRoutes returns the authenticated `user.games.mail.*` downstream
|
||||||
|
// routes served by backend's diplomail subsystem. When client is nil
|
||||||
|
// every route resolves to a dependency-unavailable client so the
|
||||||
|
// static router still recognises the message types.
|
||||||
|
func MailRoutes(client *RESTClient) map[string]downstream.Client {
|
||||||
|
target := downstream.Client(unavailableClient{})
|
||||||
|
if client != nil {
|
||||||
|
target = mailCommandClient{rest: client}
|
||||||
|
}
|
||||||
|
return map[string]downstream.Client{
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailInbox: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailSent: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailMessageGet: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailSend: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailBroadcast: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailAdmin: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailRead: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailDelete: target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +120,18 @@ func (c gameCommandClient) ExecuteCommand(ctx context.Context, command downstrea
|
|||||||
return c.rest.ExecuteGameCommand(ctx, command)
|
return c.rest.ExecuteGameCommand(ctx, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mailCommandClient struct {
|
||||||
|
rest *RESTClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c mailCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||||
|
return c.rest.ExecuteMailCommand(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ downstream.Client = unavailableClient{}
|
_ downstream.Client = unavailableClient{}
|
||||||
_ downstream.Client = userCommandClient{}
|
_ downstream.Client = userCommandClient{}
|
||||||
_ downstream.Client = lobbyCommandClient{}
|
_ downstream.Client = lobbyCommandClient{}
|
||||||
_ downstream.Client = gameCommandClient{}
|
_ downstream.Client = gameCommandClient{}
|
||||||
|
_ downstream.Client = mailCommandClient{}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) {
|
|||||||
ordermodel.MessageTypeUserGamesOrder,
|
ordermodel.MessageTypeUserGamesOrder,
|
||||||
ordermodel.MessageTypeUserGamesOrderGet,
|
ordermodel.MessageTypeUserGamesOrderGet,
|
||||||
reportmodel.MessageTypeUserGamesReport,
|
reportmodel.MessageTypeUserGamesReport,
|
||||||
|
reportmodel.MessageTypeUserGamesBattle,
|
||||||
},
|
},
|
||||||
actual: backendclient.GameRoutes(nil),
|
actual: backendclient.GameRoutes(nil),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ const (
|
|||||||
// the keep-alive idle timeout for the public REST listener.
|
// the keep-alive idle timeout for the public REST listener.
|
||||||
publicHTTPIdleTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT"
|
publicHTTPIdleTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT"
|
||||||
|
|
||||||
|
// publicHTTPCORSAllowedOriginsEnvVar names the environment variable that
|
||||||
|
// configures the comma-separated list of browser origins permitted to
|
||||||
|
// call the public REST surface. An empty value disables CORS entirely;
|
||||||
|
// requests without an Origin header still pass through normally.
|
||||||
|
publicHTTPCORSAllowedOriginsEnvVar = "GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS"
|
||||||
|
|
||||||
// publicAuthUpstreamTimeoutEnvVar names the environment variable that
|
// publicAuthUpstreamTimeoutEnvVar names the environment variable that
|
||||||
// configures the timeout budget used for public auth upstream calls.
|
// configures the timeout budget used for public auth upstream calls.
|
||||||
publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT"
|
publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT"
|
||||||
@@ -95,6 +101,16 @@ const (
|
|||||||
// the authenticated gRPC listener address.
|
// the authenticated gRPC listener address.
|
||||||
authenticatedGRPCAddrEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ADDR"
|
authenticatedGRPCAddrEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ADDR"
|
||||||
|
|
||||||
|
// authenticatedGRPCCORSAllowedOriginsEnvVar names the environment
|
||||||
|
// variable that configures the comma-separated list of browser
|
||||||
|
// origins permitted to call the authenticated Connect-Web surface.
|
||||||
|
// An empty value disables CORS entirely; the listener then refuses
|
||||||
|
// to send Access-Control-* headers and browsers block cross-origin
|
||||||
|
// fetches. Set this in any deployment that fronts the gateway
|
||||||
|
// behind a different hostname than the SvelteKit bundle (e.g.
|
||||||
|
// `https://www.galaxy.lan` calling `https://api.galaxy.lan`).
|
||||||
|
authenticatedGRPCCORSAllowedOriginsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS"
|
||||||
|
|
||||||
// authenticatedGRPCConnectionTimeoutEnvVar names the environment variable
|
// authenticatedGRPCConnectionTimeoutEnvVar names the environment variable
|
||||||
// that configures the inbound connection handshake timeout for the
|
// that configures the inbound connection handshake timeout for the
|
||||||
// authenticated gRPC listener.
|
// authenticated gRPC listener.
|
||||||
@@ -109,6 +125,14 @@ const (
|
|||||||
// gRPC requests.
|
// gRPC requests.
|
||||||
authenticatedGRPCFreshnessWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_FRESHNESS_WINDOW"
|
authenticatedGRPCFreshnessWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_FRESHNESS_WINDOW"
|
||||||
|
|
||||||
|
// pushHeartbeatIntervalEnvVar names the environment variable that
|
||||||
|
// configures the silence-based heartbeat cadence for authenticated
|
||||||
|
// push streams. The heartbeat keeps idle SubscribeEvents responses
|
||||||
|
// alive across browser fetch-streaming idle timeouts (Safari is
|
||||||
|
// notably aggressive) so push events do not disappear into the
|
||||||
|
// reconnect window. A value of `0s` disables heartbeats entirely.
|
||||||
|
pushHeartbeatIntervalEnvVar = "GATEWAY_PUSH_HEARTBEAT_INTERVAL"
|
||||||
|
|
||||||
// authenticatedGRPCIPRateLimitRequestsEnvVar names the environment
|
// authenticatedGRPCIPRateLimitRequestsEnvVar names the environment
|
||||||
// variable that configures the authenticated gRPC per-IP request budget per
|
// variable that configures the authenticated gRPC per-IP request budget per
|
||||||
// window.
|
// window.
|
||||||
@@ -305,6 +329,13 @@ const (
|
|||||||
defaultAuthenticatedGRPCDownstreamTimeout = 5 * time.Second
|
defaultAuthenticatedGRPCDownstreamTimeout = 5 * time.Second
|
||||||
defaultAuthenticatedGRPCFreshnessWindow = 5 * time.Minute
|
defaultAuthenticatedGRPCFreshnessWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// defaultPushHeartbeatInterval is the silence window the push stream
|
||||||
|
// keeps open before emitting `gateway.heartbeat`. 15s is comfortably
|
||||||
|
// below the empirical Safari fetch-streaming idle threshold
|
||||||
|
// (~15-30s) and well above any realistic per-event rate, so the
|
||||||
|
// timer is almost always reset by a real event in active games.
|
||||||
|
defaultPushHeartbeatInterval = 15 * time.Second
|
||||||
|
|
||||||
defaultAuthenticatedGRPCIPRateLimitRequests = 120
|
defaultAuthenticatedGRPCIPRateLimitRequests = 120
|
||||||
defaultAuthenticatedGRPCIPRateLimitBurst = 40
|
defaultAuthenticatedGRPCIPRateLimitBurst = 40
|
||||||
|
|
||||||
@@ -457,6 +488,12 @@ type PublicHTTPConfig struct {
|
|||||||
|
|
||||||
// AntiAbuse configures the public REST anti-abuse middleware.
|
// AntiAbuse configures the public REST anti-abuse middleware.
|
||||||
AntiAbuse PublicHTTPAntiAbuseConfig
|
AntiAbuse PublicHTTPAntiAbuseConfig
|
||||||
|
|
||||||
|
// CORSAllowedOrigins is the exact-match list of browser origins
|
||||||
|
// permitted to call the public REST surface. Empty disables CORS:
|
||||||
|
// requests without an Origin header continue to work, cross-origin
|
||||||
|
// requests are subject to the browser's default same-origin policy.
|
||||||
|
CORSAllowedOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendConfig describes the consolidated backend service the gateway
|
// BackendConfig describes the consolidated backend service the gateway
|
||||||
@@ -527,9 +564,26 @@ type AuthenticatedGRPCConfig struct {
|
|||||||
// used for client request timestamps.
|
// used for client request timestamps.
|
||||||
FreshnessWindow time.Duration
|
FreshnessWindow time.Duration
|
||||||
|
|
||||||
|
// PushHeartbeatInterval is the silence window after which an open
|
||||||
|
// authenticated SubscribeEvents stream sends an unsigned
|
||||||
|
// `gateway.heartbeat` event. Every real Send resets the window, so
|
||||||
|
// in busy streams the heartbeat fires rarely. A zero or negative
|
||||||
|
// value disables the heartbeat — the stream then relies on
|
||||||
|
// transport-level keepalives only, which Safari's fetch-streaming
|
||||||
|
// layer ignores. See `docs/ARCHITECTURE.md` for the security
|
||||||
|
// rationale of leaving the heartbeat unsigned.
|
||||||
|
PushHeartbeatInterval time.Duration
|
||||||
|
|
||||||
// AntiAbuse configures the authenticated gRPC rate limits enforced after
|
// AntiAbuse configures the authenticated gRPC rate limits enforced after
|
||||||
// the request passes the transport authenticity checks.
|
// the request passes the transport authenticity checks.
|
||||||
AntiAbuse AuthenticatedGRPCAntiAbuseConfig
|
AntiAbuse AuthenticatedGRPCAntiAbuseConfig
|
||||||
|
|
||||||
|
// CORSAllowedOrigins is the exact-match list of browser origins
|
||||||
|
// permitted to call the authenticated Connect-Web surface. Empty
|
||||||
|
// disables CORS — requests without an Access-Control-Allow-Origin
|
||||||
|
// response will be blocked by the browser, which is the production
|
||||||
|
// posture when the UI and the gateway share a single hostname.
|
||||||
|
CORSAllowedOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionCacheConfig describes the bounds of the gateway's in-memory
|
// SessionCacheConfig describes the bounds of the gateway's in-memory
|
||||||
@@ -690,6 +744,7 @@ func DefaultAuthenticatedGRPCConfig() AuthenticatedGRPCConfig {
|
|||||||
ConnectionTimeout: defaultAuthenticatedGRPCConnectionTimeout,
|
ConnectionTimeout: defaultAuthenticatedGRPCConnectionTimeout,
|
||||||
DownstreamTimeout: defaultAuthenticatedGRPCDownstreamTimeout,
|
DownstreamTimeout: defaultAuthenticatedGRPCDownstreamTimeout,
|
||||||
FreshnessWindow: defaultAuthenticatedGRPCFreshnessWindow,
|
FreshnessWindow: defaultAuthenticatedGRPCFreshnessWindow,
|
||||||
|
PushHeartbeatInterval: defaultPushHeartbeatInterval,
|
||||||
AntiAbuse: AuthenticatedGRPCAntiAbuseConfig{
|
AntiAbuse: AuthenticatedGRPCAntiAbuseConfig{
|
||||||
IP: AuthenticatedRateLimitConfig{
|
IP: AuthenticatedRateLimitConfig{
|
||||||
Requests: defaultAuthenticatedGRPCIPRateLimitRequests,
|
Requests: defaultAuthenticatedGRPCIPRateLimitRequests,
|
||||||
@@ -814,6 +869,26 @@ func LoadFromEnv() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.PublicHTTP.AuthUpstreamTimeout = publicAuthUpstreamTimeout
|
cfg.PublicHTTP.AuthUpstreamTimeout = publicAuthUpstreamTimeout
|
||||||
|
|
||||||
|
if v, ok := os.LookupEnv(publicHTTPCORSAllowedOriginsEnvVar); ok {
|
||||||
|
origins := make([]string, 0)
|
||||||
|
for part := range strings.SplitSeq(v, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||||
|
origins = append(origins, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.PublicHTTP.CORSAllowedOrigins = origins
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := os.LookupEnv(authenticatedGRPCCORSAllowedOriginsEnvVar); ok {
|
||||||
|
origins := make([]string, 0)
|
||||||
|
for part := range strings.SplitSeq(v, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||||
|
origins = append(origins, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.AuthenticatedGRPC.CORSAllowedOrigins = origins
|
||||||
|
}
|
||||||
|
|
||||||
if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok {
|
if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok {
|
||||||
cfg.Backend.HTTPBaseURL = v
|
cfg.Backend.HTTPBaseURL = v
|
||||||
}
|
}
|
||||||
@@ -879,6 +954,12 @@ func LoadFromEnv() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.AuthenticatedGRPC.DownstreamTimeout = authenticatedGRPCDownstreamTimeout
|
cfg.AuthenticatedGRPC.DownstreamTimeout = authenticatedGRPCDownstreamTimeout
|
||||||
|
|
||||||
|
pushHeartbeatInterval, err := loadDurationEnvWithDefault(pushHeartbeatIntervalEnvVar, cfg.AuthenticatedGRPC.PushHeartbeatInterval)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cfg.AuthenticatedGRPC.PushHeartbeatInterval = pushHeartbeatInterval
|
||||||
|
|
||||||
authenticatedGRPCFreshnessWindow, err := loadDurationEnvWithDefault(authenticatedGRPCFreshnessWindowEnvVar, cfg.AuthenticatedGRPC.FreshnessWindow)
|
authenticatedGRPCFreshnessWindow, err := loadDurationEnvWithDefault(authenticatedGRPCFreshnessWindowEnvVar, cfg.AuthenticatedGRPC.FreshnessWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -1107,6 +1188,9 @@ func LoadFromEnv() (Config, error) {
|
|||||||
if cfg.AuthenticatedGRPC.FreshnessWindow <= 0 {
|
if cfg.AuthenticatedGRPC.FreshnessWindow <= 0 {
|
||||||
return Config{}, fmt.Errorf("load gateway config: %s must be positive", authenticatedGRPCFreshnessWindowEnvVar)
|
return Config{}, fmt.Errorf("load gateway config: %s must be positive", authenticatedGRPCFreshnessWindowEnvVar)
|
||||||
}
|
}
|
||||||
|
if cfg.AuthenticatedGRPC.PushHeartbeatInterval < 0 {
|
||||||
|
return Config{}, fmt.Errorf("load gateway config: %s must not be negative", pushHeartbeatIntervalEnvVar)
|
||||||
|
}
|
||||||
if err := validateRateLimitConfig(
|
if err := validateRateLimitConfig(
|
||||||
cfg.AuthenticatedGRPC.AntiAbuse.IP,
|
cfg.AuthenticatedGRPC.AntiAbuse.IP,
|
||||||
authenticatedGRPCIPRateLimitRequestsEnvVar,
|
authenticatedGRPCIPRateLimitRequestsEnvVar,
|
||||||
|
|||||||
@@ -158,11 +158,52 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) {
|
|||||||
assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout)
|
assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout)
|
||||||
assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout)
|
assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout)
|
||||||
assert.Equal(t, defaultPublicAuthUpstreamTimeout, cfg.PublicHTTP.AuthUpstreamTimeout)
|
assert.Equal(t, defaultPublicAuthUpstreamTimeout, cfg.PublicHTTP.AuthUpstreamTimeout)
|
||||||
|
assert.Empty(t, cfg.PublicHTTP.CORSAllowedOrigins, "default disables CORS")
|
||||||
|
|
||||||
assert.Equal(t, defaultAuthenticatedGRPCAddr, cfg.AuthenticatedGRPC.Addr)
|
assert.Equal(t, defaultAuthenticatedGRPCAddr, cfg.AuthenticatedGRPC.Addr)
|
||||||
assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout)
|
assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout)
|
||||||
assert.Equal(t, defaultAuthenticatedGRPCDownstreamTimeout, cfg.AuthenticatedGRPC.DownstreamTimeout)
|
assert.Equal(t, defaultAuthenticatedGRPCDownstreamTimeout, cfg.AuthenticatedGRPC.DownstreamTimeout)
|
||||||
assert.Equal(t, defaultAuthenticatedGRPCFreshnessWindow, cfg.AuthenticatedGRPC.FreshnessWindow)
|
assert.Equal(t, defaultAuthenticatedGRPCFreshnessWindow, cfg.AuthenticatedGRPC.FreshnessWindow)
|
||||||
|
assert.Equal(t, defaultPushHeartbeatInterval, cfg.AuthenticatedGRPC.PushHeartbeatInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFromEnvParsesPushHeartbeatInterval(t *testing.T) {
|
||||||
|
configEnvMu.Lock()
|
||||||
|
defer configEnvMu.Unlock()
|
||||||
|
|
||||||
|
resetEnv(t)
|
||||||
|
setBaseRequiredEnv(t)
|
||||||
|
t.Setenv(pushHeartbeatIntervalEnvVar, "0s")
|
||||||
|
|
||||||
|
cfg, err := LoadFromEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, time.Duration(0), cfg.AuthenticatedGRPC.PushHeartbeatInterval, "0s explicitly disables the heartbeat")
|
||||||
|
|
||||||
|
t.Setenv(pushHeartbeatIntervalEnvVar, "25s")
|
||||||
|
cfg, err = LoadFromEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 25*time.Second, cfg.AuthenticatedGRPC.PushHeartbeatInterval)
|
||||||
|
|
||||||
|
t.Setenv(pushHeartbeatIntervalEnvVar, "-1s")
|
||||||
|
_, err = LoadFromEnv()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), pushHeartbeatIntervalEnvVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFromEnvParsesCORSAllowedOrigins(t *testing.T) {
|
||||||
|
configEnvMu.Lock()
|
||||||
|
defer configEnvMu.Unlock()
|
||||||
|
|
||||||
|
resetEnv(t)
|
||||||
|
setBaseRequiredEnv(t)
|
||||||
|
t.Setenv(publicHTTPCORSAllowedOriginsEnvVar, "https://www.galaxy.lan, , https://staging.galaxy.lan")
|
||||||
|
|
||||||
|
cfg, err := LoadFromEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"https://www.galaxy.lan", "https://staging.galaxy.lan"},
|
||||||
|
cfg.PublicHTTP.CORSAllowedOrigins,
|
||||||
|
"comma-separated list is split, whitespace-trimmed, and empty segments dropped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetEnv clears every env var the gateway config might read so that
|
// resetEnv clears every env var the gateway config might read so that
|
||||||
@@ -179,6 +220,7 @@ func resetEnv(t *testing.T) {
|
|||||||
publicHTTPReadTimeoutEnvVar,
|
publicHTTPReadTimeoutEnvVar,
|
||||||
publicHTTPIdleTimeoutEnvVar,
|
publicHTTPIdleTimeoutEnvVar,
|
||||||
publicAuthUpstreamTimeoutEnvVar,
|
publicAuthUpstreamTimeoutEnvVar,
|
||||||
|
publicHTTPCORSAllowedOriginsEnvVar,
|
||||||
backendHTTPURLEnvVar,
|
backendHTTPURLEnvVar,
|
||||||
backendGRPCPushURLEnvVar,
|
backendGRPCPushURLEnvVar,
|
||||||
backendGatewayClientIDEnvVar,
|
backendGatewayClientIDEnvVar,
|
||||||
@@ -193,6 +235,7 @@ func resetEnv(t *testing.T) {
|
|||||||
authenticatedGRPCConnectionTimeoutEnvVar,
|
authenticatedGRPCConnectionTimeoutEnvVar,
|
||||||
authenticatedGRPCDownstreamTimeoutEnvVar,
|
authenticatedGRPCDownstreamTimeoutEnvVar,
|
||||||
authenticatedGRPCFreshnessWindowEnvVar,
|
authenticatedGRPCFreshnessWindowEnvVar,
|
||||||
|
pushHeartbeatIntervalEnvVar,
|
||||||
gatewayRedisMasterAddrEnvVar,
|
gatewayRedisMasterAddrEnvVar,
|
||||||
gatewayRedisPasswordEnvVar,
|
gatewayRedisPasswordEnvVar,
|
||||||
replayRedisKeyPrefixEnvVar,
|
replayRedisKeyPrefixEnvVar,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package grpcapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withCORS wraps next so that CORS preflight (OPTIONS) requests with an
|
||||||
|
// allow-listed Origin receive 204 plus the `Access-Control-Allow-*`
|
||||||
|
// headers Connect-Web needs, and actual requests get the matching
|
||||||
|
// `Access-Control-Allow-Origin` header echoed back. Origins are
|
||||||
|
// compared exactly: scheme, host, and port must match. An empty
|
||||||
|
// allow-list passes through untouched — the production posture when
|
||||||
|
// the UI and the gateway share one hostname.
|
||||||
|
//
|
||||||
|
// The wrapper mirrors `restapi.withCORS` but speaks plain `net/http`
|
||||||
|
// because the Connect handler is mounted on a `http.ServeMux`, not a
|
||||||
|
// gin engine. Connect-Web POSTs use `Content-Type: application/connect+json`
|
||||||
|
// which triggers a browser preflight; without these headers the
|
||||||
|
// browser surfaces "Load failed" before the Connect handler even sees
|
||||||
|
// the request.
|
||||||
|
func withCORS(allowedOrigins []string, next http.Handler) http.Handler {
|
||||||
|
allowed := make(map[string]struct{}, len(allowedOrigins))
|
||||||
|
for _, origin := range allowedOrigins {
|
||||||
|
allowed[origin] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := allowed[origin]; !ok {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Add("Vary", "Origin")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
|
||||||
|
} else {
|
||||||
|
// Defaults cover the Connect-Web preflight set: protocol
|
||||||
|
// version, content type, timeout, and the signed-request
|
||||||
|
// metadata the gateway interceptor expects.
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers",
|
||||||
|
"Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Authorization")
|
||||||
|
}
|
||||||
|
// Expose the response headers Connect-Web needs to read on
|
||||||
|
// the client (e.g. trailers folded into headers for unary).
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Expose the same response headers on the actual call.
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package grpcapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/gateway/internal/telemetry"
|
||||||
|
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// heartbeatingStream wraps a server-streaming response so the inner
|
||||||
|
// stream stays alive across browser fetch-streaming idle timeouts.
|
||||||
|
// Every call to Send (a real event from a tail service) resets a
|
||||||
|
// silence timer; when the timer fires, Run emits an unsigned
|
||||||
|
// `gateway.heartbeat` event on its own. Send and the heartbeat
|
||||||
|
// goroutine serialise on the same mutex because grpc.ServerStream.Send
|
||||||
|
// is documented as not goroutine-safe.
|
||||||
|
//
|
||||||
|
// Wire-cost budgeting: each heartbeat is one GatewayEvent with only
|
||||||
|
// EventType populated (~17 bytes + protobuf tag), framed by Connect
|
||||||
|
// (~5 bytes) and HTTP/2 plus TLS overhead (~50 bytes). At the
|
||||||
|
// 15-second default a fully-idle stream costs ~840 KB/day per client;
|
||||||
|
// see `docs/ARCHITECTURE.md` for the per-scale projection.
|
||||||
|
type heartbeatingStream struct {
|
||||||
|
grpc.ServerStreamingServer[gatewayv1.GatewayEvent]
|
||||||
|
|
||||||
|
interval time.Duration
|
||||||
|
metrics *telemetry.Runtime
|
||||||
|
|
||||||
|
sendMu sync.Mutex
|
||||||
|
timer *time.Timer
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHeartbeatingStream wraps inner with a silence-based heartbeat
|
||||||
|
// emitter. A non-positive interval returns nil so the caller can skip
|
||||||
|
// the wrapping entirely; non-nil returns must have `Stop()` called once
|
||||||
|
// the stream lifecycle ends.
|
||||||
|
func newHeartbeatingStream(
|
||||||
|
inner grpc.ServerStreamingServer[gatewayv1.GatewayEvent],
|
||||||
|
interval time.Duration,
|
||||||
|
metrics *telemetry.Runtime,
|
||||||
|
) *heartbeatingStream {
|
||||||
|
if interval <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &heartbeatingStream{
|
||||||
|
ServerStreamingServer: inner,
|
||||||
|
interval: interval,
|
||||||
|
metrics: metrics,
|
||||||
|
timer: time.NewTimer(interval),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send forwards event to the inner stream and resets the silence timer
|
||||||
|
// so the heartbeat goroutine waits a fresh interval before firing
|
||||||
|
// again. A Send that succeeds means the transport just delivered real
|
||||||
|
// bytes; the silence window restarts from "now".
|
||||||
|
func (s *heartbeatingStream) Send(event *gatewayv1.GatewayEvent) error {
|
||||||
|
s.sendMu.Lock()
|
||||||
|
defer s.sendMu.Unlock()
|
||||||
|
if err := s.ServerStreamingServer.Send(event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.resetTimerLocked()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks until ctx is canceled or Stop is called, emitting one
|
||||||
|
// `gateway.heartbeat` event whenever the silence timer fires. Intended
|
||||||
|
// to run in its own goroutine alongside the tail service that owns the
|
||||||
|
// stream. A Send failure from the heartbeat path is recorded in
|
||||||
|
// telemetry and returned to the caller; production wiring discards it
|
||||||
|
// because the tail service will see the same transport failure on its
|
||||||
|
// next Send and propagate the real error to the gateway frame
|
||||||
|
// observability layer.
|
||||||
|
func (s *heartbeatingStream) Run(ctx context.Context) error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-s.done:
|
||||||
|
return nil
|
||||||
|
case <-s.timer.C:
|
||||||
|
err := s.sendHeartbeat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts the heartbeat goroutine and drains the silence timer.
|
||||||
|
// Safe to call multiple times; subsequent calls are no-ops.
|
||||||
|
func (s *heartbeatingStream) Stop() {
|
||||||
|
s.stopOnce.Do(func() {
|
||||||
|
close(s.done)
|
||||||
|
if !s.timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-s.timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendHeartbeat emits one heartbeat event, records the outcome in
|
||||||
|
// telemetry, and re-arms the silence timer. The outcome attribute
|
||||||
|
// makes a sudden bump of `error` easy to spot in dashboards — it
|
||||||
|
// usually means the upstream connection is failing before the gateway
|
||||||
|
// can flush, while a steady `sent` rate is the normal idle baseline
|
||||||
|
// the deployment operator budgets bandwidth against.
|
||||||
|
func (s *heartbeatingStream) sendHeartbeat() error {
|
||||||
|
s.sendMu.Lock()
|
||||||
|
defer s.sendMu.Unlock()
|
||||||
|
|
||||||
|
err := s.ServerStreamingServer.Send(buildHeartbeatEvent())
|
||||||
|
outcome := attribute.String("outcome", "sent")
|
||||||
|
if err != nil {
|
||||||
|
outcome = attribute.String("outcome", "error")
|
||||||
|
}
|
||||||
|
s.metrics.RecordPushHeartbeat(context.Background(), outcome)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.resetTimerLocked()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetTimerLocked re-arms the silence timer. Caller must hold sendMu.
|
||||||
|
// The drain follows the canonical pattern from the time.Timer
|
||||||
|
// docstring: Stop may report `false` either because the timer already
|
||||||
|
// fired or because nothing was queued, so the non-blocking drain
|
||||||
|
// handles both states without deadlocking when the channel was already
|
||||||
|
// emptied by Run.
|
||||||
|
func (s *heartbeatingStream) resetTimerLocked() {
|
||||||
|
if !s.timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-s.timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.timer.Reset(s.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHeartbeatEvent returns the minimal `gateway.heartbeat`
|
||||||
|
// GatewayEvent emitted into the push stream. Every field except
|
||||||
|
// EventType is left at its proto3 default so the wire frame stays as
|
||||||
|
// small as Connect framing allows. See `gatewayHeartbeatEventType` for
|
||||||
|
// the security rationale of leaving the event unsigned.
|
||||||
|
func buildHeartbeatEvent() *gatewayv1.GatewayEvent {
|
||||||
|
return &gatewayv1.GatewayEvent{EventType: gatewayHeartbeatEventType}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package grpcapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewHeartbeatingStreamZeroIntervalReturnsNil(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stream := newHeartbeatingStream(newCapturingStream(t), 0, nil)
|
||||||
|
assert.Nil(t, stream, "zero interval must not allocate a wrapper")
|
||||||
|
|
||||||
|
negative := newHeartbeatingStream(newCapturingStream(t), -time.Second, nil)
|
||||||
|
assert.Nil(t, negative, "negative interval must not allocate a wrapper")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatingStreamSendsHeartbeatAfterSilence(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inner := newCapturingStream(t)
|
||||||
|
hb := newHeartbeatingStream(inner, 30*time.Millisecond, nil)
|
||||||
|
require.NotNil(t, hb)
|
||||||
|
defer hb.Stop()
|
||||||
|
|
||||||
|
go func() { _ = hb.Run(t.Context()) }()
|
||||||
|
|
||||||
|
event := inner.recv(t, 200*time.Millisecond)
|
||||||
|
assert.Equal(t, gatewayHeartbeatEventType, event.GetEventType())
|
||||||
|
// Heartbeat envelope: only the event type travels. Every other
|
||||||
|
// field stays at proto3 default so the wire frame stays minimal.
|
||||||
|
assert.Empty(t, event.GetEventId())
|
||||||
|
assert.Zero(t, event.GetTimestampMs())
|
||||||
|
assert.Empty(t, event.GetPayloadBytes())
|
||||||
|
assert.Empty(t, event.GetPayloadHash())
|
||||||
|
assert.Empty(t, event.GetSignature())
|
||||||
|
assert.Empty(t, event.GetRequestId())
|
||||||
|
assert.Empty(t, event.GetTraceId())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatingStreamRealSendResetsSilenceTimer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inner := newCapturingStream(t)
|
||||||
|
hb := newHeartbeatingStream(inner, 50*time.Millisecond, nil)
|
||||||
|
require.NotNil(t, hb)
|
||||||
|
defer hb.Stop()
|
||||||
|
|
||||||
|
go func() { _ = hb.Run(t.Context()) }()
|
||||||
|
|
||||||
|
// Reset the timer every 20ms for 120ms — the silence window never
|
||||||
|
// elapses, so the heartbeat goroutine must stay quiet and the
|
||||||
|
// channel must only carry the manual real-event Sends.
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range 6 {
|
||||||
|
<-ticker.C
|
||||||
|
if err := hb.Send(&gatewayv1.GatewayEvent{EventType: "real.event"}); err != nil {
|
||||||
|
t.Errorf("real Send failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for range 6 {
|
||||||
|
event := inner.recv(t, 100*time.Millisecond)
|
||||||
|
assert.Equal(t, "real.event", event.GetEventType(), "only real events should appear while Send keeps resetting the silence window")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatingStreamStopHaltsRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inner := newCapturingStream(t)
|
||||||
|
hb := newHeartbeatingStream(inner, 20*time.Millisecond, nil)
|
||||||
|
require.NotNil(t, hb)
|
||||||
|
|
||||||
|
runDone := make(chan error, 1)
|
||||||
|
go func() { runDone <- hb.Run(context.Background()) }()
|
||||||
|
|
||||||
|
hb.Stop()
|
||||||
|
select {
|
||||||
|
case err := <-runDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("Run did not exit after Stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop is idempotent; the second call must not panic on the
|
||||||
|
// already-closed done channel.
|
||||||
|
assert.NotPanics(t, hb.Stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatingStreamContextCancelHaltsRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inner := newCapturingStream(t)
|
||||||
|
hb := newHeartbeatingStream(inner, 20*time.Millisecond, nil)
|
||||||
|
require.NotNil(t, hb)
|
||||||
|
defer hb.Stop()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
runDone := make(chan error, 1)
|
||||||
|
go func() { runDone <- hb.Run(ctx) }()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-runDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("Run did not exit after context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatingStreamSendErrorPropagates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
wantErr := errors.New("send failed")
|
||||||
|
inner := newCapturingStream(t)
|
||||||
|
inner.sendErr.Store(&errorBox{err: wantErr})
|
||||||
|
|
||||||
|
hb := newHeartbeatingStream(inner, time.Minute, nil)
|
||||||
|
require.NotNil(t, hb)
|
||||||
|
defer hb.Stop()
|
||||||
|
|
||||||
|
err := hb.Send(&gatewayv1.GatewayEvent{EventType: "real.event"})
|
||||||
|
require.ErrorIs(t, err, wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// capturingStream is a minimal grpc.ServerStreamingServer that pushes
|
||||||
|
// every Send into a channel so tests can assert on the wire frame.
|
||||||
|
type capturingStream struct {
|
||||||
|
grpc.ServerStreamingServer[gatewayv1.GatewayEvent]
|
||||||
|
|
||||||
|
events chan *gatewayv1.GatewayEvent
|
||||||
|
sendErr atomic.Pointer[errorBox]
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorBox struct{ err error }
|
||||||
|
|
||||||
|
func newCapturingStream(t *testing.T) *capturingStream {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return &capturingStream{events: make(chan *gatewayv1.GatewayEvent, 16)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *capturingStream) Send(event *gatewayv1.GatewayEvent) error {
|
||||||
|
if box := s.sendErr.Load(); box != nil {
|
||||||
|
return box.err
|
||||||
|
}
|
||||||
|
s.events <- event
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *capturingStream) Context() context.Context { return context.Background() }
|
||||||
|
|
||||||
|
func (s *capturingStream) SetHeader(metadata.MD) error { return nil }
|
||||||
|
func (s *capturingStream) SendHeader(metadata.MD) error { return nil }
|
||||||
|
func (s *capturingStream) SetTrailer(metadata.MD) {}
|
||||||
|
func (s *capturingStream) SendMsg(any) error { return errors.New("capturingStream.SendMsg: unused") }
|
||||||
|
func (s *capturingStream) RecvMsg(any) error { return errors.New("capturingStream.RecvMsg: unused") }
|
||||||
|
|
||||||
|
func (s *capturingStream) recv(t *testing.T, timeout time.Duration) *gatewayv1.GatewayEvent {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case event := <-s.events:
|
||||||
|
return event
|
||||||
|
case <-time.After(timeout):
|
||||||
|
t.Fatalf("no event captured within %s", timeout)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"time"
|
||||||
|
|
||||||
"galaxy/gateway/authn"
|
"galaxy/gateway/authn"
|
||||||
"galaxy/gateway/internal/clock"
|
"galaxy/gateway/internal/clock"
|
||||||
|
"galaxy/gateway/internal/telemetry"
|
||||||
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||||
gatewayfbs "galaxy/schema/fbs/gateway"
|
gatewayfbs "galaxy/schema/fbs/gateway"
|
||||||
|
|
||||||
@@ -16,7 +18,31 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
const serverTimeEventType = "gateway.server_time"
|
const (
|
||||||
|
serverTimeEventType = "gateway.server_time"
|
||||||
|
|
||||||
|
// gatewayHeartbeatEventType labels the silence-filling event the
|
||||||
|
// authenticated push stream emits when no real event has been Send'd
|
||||||
|
// within `AuthenticatedGRPCConfig.PushHeartbeatInterval`. Browser
|
||||||
|
// fetch-streaming layers (notably Safari) close response bodies they
|
||||||
|
// consider idle; the heartbeat keeps the body active so push events
|
||||||
|
// land on the live stream instead of disappearing into the
|
||||||
|
// client-side reconnect window.
|
||||||
|
//
|
||||||
|
// Heartbeat events are sent UNSIGNED — `EventID`, `RequestID`,
|
||||||
|
// `TraceID`, `PayloadBytes`, `PayloadHash`, `Signature`, and
|
||||||
|
// `TimestampMs` are all left at their proto3 defaults so the wire
|
||||||
|
// frame stays under ~50 bytes. The UI's EventStream short-circuits
|
||||||
|
// on this event type before signature verification (see
|
||||||
|
// `ui/frontend/src/api/events.svelte.ts`) and never dispatches it to
|
||||||
|
// handlers. The security implication is intentional and documented
|
||||||
|
// in `docs/ARCHITECTURE.md` (§ authenticated edge): an attacker who
|
||||||
|
// could inject heartbeats gains nothing — they carry no payload and
|
||||||
|
// trigger no UI behaviour, the only practical effect is keeping a
|
||||||
|
// stream marginally more alive than transport-level keepalives
|
||||||
|
// would. Real events keep the signed envelope unchanged.
|
||||||
|
gatewayHeartbeatEventType = "gateway.heartbeat"
|
||||||
|
)
|
||||||
|
|
||||||
// authenticatedStreamBinding captures the verified identity bound to one
|
// authenticatedStreamBinding captures the verified identity bound to one
|
||||||
// authenticated SubscribeEvents stream after the full ingress pipeline
|
// authenticated SubscribeEvents stream after the full ingress pipeline
|
||||||
@@ -47,12 +73,21 @@ func authenticatedStreamBindingFromContext(ctx context.Context) (authenticatedSt
|
|||||||
// authenticatedPushStreamService owns SubscribeEvents bootstrap behavior:
|
// authenticatedPushStreamService owns SubscribeEvents bootstrap behavior:
|
||||||
// bind the authenticated stream, send the initial signed server-time event,
|
// bind the authenticated stream, send the initial signed server-time event,
|
||||||
// and then hand the stream lifecycle to the configured tail delegate.
|
// and then hand the stream lifecycle to the configured tail delegate.
|
||||||
|
//
|
||||||
|
// A positive `heartbeatInterval` wraps the bound stream with
|
||||||
|
// `heartbeatingStream` before delegating, so any tail implementation
|
||||||
|
// (fan-out, hold-open, future variants) gets the silence-based
|
||||||
|
// `gateway.heartbeat` for free. The wrapper observes every real Send
|
||||||
|
// the tail performs and only emits a heartbeat when the silence window
|
||||||
|
// elapses; tails remain heartbeat-unaware.
|
||||||
type authenticatedPushStreamService struct {
|
type authenticatedPushStreamService struct {
|
||||||
gatewayv1.UnimplementedEdgeGatewayServer
|
gatewayv1.UnimplementedEdgeGatewayServer
|
||||||
|
|
||||||
tailDelegate gatewayv1.EdgeGatewayServer
|
tailDelegate gatewayv1.EdgeGatewayServer
|
||||||
responseSigner authn.ResponseSigner
|
responseSigner authn.ResponseSigner
|
||||||
clock clock.Clock
|
clock clock.Clock
|
||||||
|
heartbeatInterval time.Duration
|
||||||
|
metrics *telemetry.Runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeEvents binds the verified stream identity, sends the initial signed
|
// SubscribeEvents binds the verified stream identity, sends the initial signed
|
||||||
@@ -112,18 +147,40 @@ func (s authenticatedPushStreamService) SubscribeEvents(req *gatewayv1.Subscribe
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.tailDelegate.SubscribeEvents(req, boundStream)
|
var streamForTail grpc.ServerStreamingServer[gatewayv1.GatewayEvent] = boundStream
|
||||||
|
if hbStream := newHeartbeatingStream(boundStream, s.heartbeatInterval, s.metrics); hbStream != nil {
|
||||||
|
defer hbStream.Stop()
|
||||||
|
go func() {
|
||||||
|
// Heartbeat Send failures imply the transport is already
|
||||||
|
// dead — the tail's next Send will hit the same error and
|
||||||
|
// surface through the gateway observability layer, so we
|
||||||
|
// discard the returned error here and rely on that path
|
||||||
|
// for the canonical failure record.
|
||||||
|
_ = hbStream.Run(stream.Context())
|
||||||
|
}()
|
||||||
|
streamForTail = hbStream
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.tailDelegate.SubscribeEvents(req, streamForTail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuthenticatedPushStreamService(tailDelegate gatewayv1.EdgeGatewayServer, responseSigner authn.ResponseSigner, clk clock.Clock) gatewayv1.EdgeGatewayServer {
|
func newAuthenticatedPushStreamService(
|
||||||
|
tailDelegate gatewayv1.EdgeGatewayServer,
|
||||||
|
responseSigner authn.ResponseSigner,
|
||||||
|
clk clock.Clock,
|
||||||
|
heartbeatInterval time.Duration,
|
||||||
|
metrics *telemetry.Runtime,
|
||||||
|
) gatewayv1.EdgeGatewayServer {
|
||||||
if tailDelegate == nil {
|
if tailDelegate == nil {
|
||||||
tailDelegate = holdOpenSubscribeEventsService{}
|
tailDelegate = holdOpenSubscribeEventsService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticatedPushStreamService{
|
return authenticatedPushStreamService{
|
||||||
tailDelegate: tailDelegate,
|
tailDelegate: tailDelegate,
|
||||||
responseSigner: responseSigner,
|
responseSigner: responseSigner,
|
||||||
clock: clk,
|
clock: clk,
|
||||||
|
heartbeatInterval: heartbeatInterval,
|
||||||
|
metrics: metrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,13 @@ func NewServer(cfg config.AuthenticatedGRPCConfig, deps ServerDependencies) *Ser
|
|||||||
deps = normalizeServerDependencies(deps)
|
deps = normalizeServerDependencies(deps)
|
||||||
|
|
||||||
finalService := newCommandRoutingService(
|
finalService := newCommandRoutingService(
|
||||||
newAuthenticatedPushStreamService(deps.Service, deps.ResponseSigner, deps.Clock),
|
newAuthenticatedPushStreamService(
|
||||||
|
deps.Service,
|
||||||
|
deps.ResponseSigner,
|
||||||
|
deps.Clock,
|
||||||
|
cfg.PushHeartbeatInterval,
|
||||||
|
deps.Telemetry,
|
||||||
|
),
|
||||||
deps.Router,
|
deps.Router,
|
||||||
deps.ResponseSigner,
|
deps.ResponseSigner,
|
||||||
deps.Clock,
|
deps.Clock,
|
||||||
@@ -169,7 +175,10 @@ func (s *Server) Run(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
mux.Handle(path, handler)
|
mux.Handle(path, handler)
|
||||||
|
|
||||||
tracedHandler := otelhttp.NewHandler(mux, "authenticated_edge")
|
// CORS runs OUTSIDE the otelhttp wrapper so preflight OPTIONS calls
|
||||||
|
// answer with 204 immediately and never enter the trace path.
|
||||||
|
corsMux := withCORS(s.cfg.CORSAllowedOrigins, mux)
|
||||||
|
tracedHandler := otelhttp.NewHandler(corsMux, "authenticated_edge")
|
||||||
http2Server := &http2.Server{IdleTimeout: s.cfg.ConnectionTimeout}
|
http2Server := &http2.Server{IdleTimeout: s.cfg.ConnectionTimeout}
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Handler: h2c.NewHandler(tracedHandler, http2Server),
|
Handler: h2c.NewHandler(tracedHandler, http2Server),
|
||||||
|
|||||||
@@ -174,6 +174,102 @@ func TestSubscribeEventsValidEnvelopeSendsBootstrapEventAndWaitsForCancellation(
|
|||||||
assert.Equal(t, connect.CodeCanceled, connect.CodeOf(recvErr))
|
assert.Equal(t, connect.CodeCanceled, connect.CodeOf(recvErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubscribeEventsEmitsHeartbeatAfterSilenceWindow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
grpcCfg := config.DefaultAuthenticatedGRPCConfig()
|
||||||
|
grpcCfg.Addr = "127.0.0.1:0"
|
||||||
|
grpcCfg.FreshnessWindow = testFreshnessWindow
|
||||||
|
// 30 ms keeps the test inside the standard 60-second go test
|
||||||
|
// timeout while still giving the heartbeat goroutine enough
|
||||||
|
// headroom to fire after the bootstrap server-time event lands.
|
||||||
|
grpcCfg.PushHeartbeatInterval = 30 * time.Millisecond
|
||||||
|
|
||||||
|
server, runGateway := newTestGatewayWithGRPCConfig(t, grpcCfg, ServerDependencies{
|
||||||
|
SessionCache: staticSessionCache{
|
||||||
|
lookupFunc: func(context.Context, string) (session.Record, error) {
|
||||||
|
return newActiveSessionRecord(), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ReplayStore: staticReplayStore{},
|
||||||
|
})
|
||||||
|
defer runGateway.stop(t)
|
||||||
|
|
||||||
|
addr := waitForListenAddr(t, server)
|
||||||
|
client := newEdgeClient(t, addr)
|
||||||
|
|
||||||
|
stream, err := client.SubscribeEvents(t.Context(), connect.NewRequest(newValidSubscribeEventsRequest()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = stream.Close() })
|
||||||
|
|
||||||
|
bootstrap := recvBootstrapEvent(t, stream)
|
||||||
|
assertServerTimeBootstrapEvent(t, bootstrap, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
|
||||||
|
|
||||||
|
// The next frame must be the unsigned heartbeat. Every field
|
||||||
|
// except EventType is left at its proto3 default — the UI side
|
||||||
|
// short-circuits on EventType BEFORE signature verification, so
|
||||||
|
// any non-empty signature would be wasted bytes on the wire.
|
||||||
|
require.True(t, stream.Receive(), "stream did not deliver a heartbeat: %v", stream.Err())
|
||||||
|
heartbeat := stream.Msg()
|
||||||
|
assert.Equal(t, gatewayHeartbeatEventType, heartbeat.GetEventType())
|
||||||
|
assert.Empty(t, heartbeat.GetEventId())
|
||||||
|
assert.Zero(t, heartbeat.GetTimestampMs())
|
||||||
|
assert.Empty(t, heartbeat.GetPayloadBytes())
|
||||||
|
assert.Empty(t, heartbeat.GetPayloadHash())
|
||||||
|
assert.Empty(t, heartbeat.GetSignature())
|
||||||
|
assert.Empty(t, heartbeat.GetRequestId())
|
||||||
|
assert.Empty(t, heartbeat.GetTraceId())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeEventsZeroHeartbeatIntervalDisablesEmission(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
grpcCfg := config.DefaultAuthenticatedGRPCConfig()
|
||||||
|
grpcCfg.Addr = "127.0.0.1:0"
|
||||||
|
grpcCfg.FreshnessWindow = testFreshnessWindow
|
||||||
|
grpcCfg.PushHeartbeatInterval = 0
|
||||||
|
|
||||||
|
server, runGateway := newTestGatewayWithGRPCConfig(t, grpcCfg, ServerDependencies{
|
||||||
|
SessionCache: staticSessionCache{
|
||||||
|
lookupFunc: func(context.Context, string) (session.Record, error) {
|
||||||
|
return newActiveSessionRecord(), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ReplayStore: staticReplayStore{},
|
||||||
|
})
|
||||||
|
defer runGateway.stop(t)
|
||||||
|
|
||||||
|
addr := waitForListenAddr(t, server)
|
||||||
|
client := newEdgeClient(t, addr)
|
||||||
|
|
||||||
|
stream, err := client.SubscribeEvents(t.Context(), connect.NewRequest(newValidSubscribeEventsRequest()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = stream.Close() })
|
||||||
|
|
||||||
|
bootstrap := recvBootstrapEvent(t, stream)
|
||||||
|
assertServerTimeBootstrapEvent(t, bootstrap, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
|
||||||
|
|
||||||
|
// No heartbeat is expected — the stream must stay silent. A
|
||||||
|
// background Receive races a deadline check so the test fails
|
||||||
|
// fast if the gateway ever sends a second frame on this stream.
|
||||||
|
recvResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if stream.Receive() {
|
||||||
|
recvResult <- errors.New("stream produced unexpected event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recvResult <- stream.Err()
|
||||||
|
}()
|
||||||
|
require.Never(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-recvResult:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 200*time.Millisecond, 20*time.Millisecond, "heartbeat fired despite zero interval")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubscribeEventsMissingReplayStoreFailsClosed(t *testing.T) {
|
func TestSubscribeEventsMissingReplayStoreFailsClosed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withCORS returns a gin middleware that handles browser CORS preflight and
|
||||||
|
// attaches Access-Control-Allow-* response headers when the request's Origin
|
||||||
|
// is on the configured allow-list. Origins are compared exactly: scheme,
|
||||||
|
// host, and port must match. An empty allow-list disables the middleware —
|
||||||
|
// requests pass through untouched. Requests without an Origin header always
|
||||||
|
// pass through, the middleware only acts when a browser actually asks.
|
||||||
|
//
|
||||||
|
// The middleware mounts before the anti-abuse layer so OPTIONS preflights
|
||||||
|
// do not count against the rate-limit buckets for the eventual real call.
|
||||||
|
func withCORS(allowedOrigins []string) gin.HandlerFunc {
|
||||||
|
allowed := make(map[string]struct{}, len(allowedOrigins))
|
||||||
|
for _, origin := range allowedOrigins {
|
||||||
|
allowed[origin] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return func(c *gin.Context) { c.Next() }
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := allowed[origin]; !ok {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
if reqHeaders := c.GetHeader("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||||
|
c.Header("Access-Control-Allow-Headers", reqHeaders)
|
||||||
|
} else {
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Max-Age", "3600")
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCORSRouter(allowedOrigins []string) *gin.Engine {
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(withCORS(allowedOrigins))
|
||||||
|
router.GET("/api/v1/public/probe", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithCORSAllowsListedOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||||
|
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.Equal(t, "https://www.galaxy.lan", recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
assert.Equal(t, "Origin", recorder.Header().Get("Vary"))
|
||||||
|
assert.Equal(t, "true", recorder.Header().Get("Access-Control-Allow-Credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithCORSPreflightShortCircuits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/public/probe", nil)
|
||||||
|
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Galaxy-Trace")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||||
|
assert.Equal(t, "https://www.galaxy.lan", recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
assert.Contains(t, recorder.Header().Get("Access-Control-Allow-Methods"), "POST")
|
||||||
|
assert.Equal(t, "Content-Type, X-Galaxy-Trace", recorder.Header().Get("Access-Control-Allow-Headers"))
|
||||||
|
assert.Equal(t, "3600", recorder.Header().Get("Access-Control-Max-Age"))
|
||||||
|
assert.Empty(t, recorder.Body.String(), "preflight must not return a body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithCORSPreflightFallbackHeadersWhenRequestHeadersMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/public/probe", nil)
|
||||||
|
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||||
|
assert.Equal(t, "Content-Type, Authorization", recorder.Header().Get("Access-Control-Allow-Headers"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithCORSRejectsUnknownOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example.com")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code, "real call must still succeed; the browser is the one that blocks the response")
|
||||||
|
assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"), "no allow-origin header for rejected origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithCORSPassThroughWithoutOriginHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithCORSDisabledByEmptyConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newCORSRouter(nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||||
|
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
@@ -278,6 +278,10 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen
|
|||||||
}))
|
}))
|
||||||
router.Use(otelgin.Middleware("galaxy-edge-gateway-public"))
|
router.Use(otelgin.Middleware("galaxy-edge-gateway-public"))
|
||||||
router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry))
|
router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry))
|
||||||
|
// CORS runs before the route classifier and anti-abuse layers so
|
||||||
|
// preflight OPTIONS calls answer with 204 immediately and never
|
||||||
|
// count against any rate-limit bucket.
|
||||||
|
router.Use(withCORS(cfg.CORSAllowedOrigins))
|
||||||
router.Use(withPublicRouteClass(deps.Classifier))
|
router.Use(withPublicRouteClass(deps.Classifier))
|
||||||
router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer))
|
router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer))
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type Runtime struct {
|
|||||||
// Push instruments.
|
// Push instruments.
|
||||||
pushActiveStreams metric.Int64UpDownCounter
|
pushActiveStreams metric.Int64UpDownCounter
|
||||||
pushStreamClosers metric.Int64Counter
|
pushStreamClosers metric.Int64Counter
|
||||||
|
pushHeartbeats metric.Int64Counter
|
||||||
|
|
||||||
// Internal event consumer instruments.
|
// Internal event consumer instruments.
|
||||||
internalEventDrops metric.Int64Counter
|
internalEventDrops metric.Int64Counter
|
||||||
@@ -120,6 +121,10 @@ func New(ctx context.Context, logger *zap.Logger) (*Runtime, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pushHeartbeats, err := meter.Int64Counter("gateway.push.heartbeats_sent")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
internalEventDrops, err := meter.Int64Counter("gateway.internal_event_drops")
|
internalEventDrops, err := meter.Int64Counter("gateway.internal_event_drops")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -136,6 +141,7 @@ func New(ctx context.Context, logger *zap.Logger) (*Runtime, error) {
|
|||||||
grpcDuration: grpcDuration,
|
grpcDuration: grpcDuration,
|
||||||
pushActiveStreams: pushActiveStreams,
|
pushActiveStreams: pushActiveStreams,
|
||||||
pushStreamClosers: pushStreamClosers,
|
pushStreamClosers: pushStreamClosers,
|
||||||
|
pushHeartbeats: pushHeartbeats,
|
||||||
internalEventDrops: internalEventDrops,
|
internalEventDrops: internalEventDrops,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -228,6 +234,19 @@ func (r *Runtime) RecordPushStreamClosure(ctx context.Context, attrs ...attribut
|
|||||||
r.pushStreamClosers.Add(ctx, 1, metric.WithAttributes(attrs...))
|
r.pushStreamClosers.Add(ctx, 1, metric.WithAttributes(attrs...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordPushHeartbeat records one outbound push-stream heartbeat event.
|
||||||
|
// The `outcome` attribute should distinguish a successful Send from a
|
||||||
|
// transport-level failure so the metric stays useful for bandwidth
|
||||||
|
// budgeting (most heartbeats are `sent`; a sudden bump of `error` means
|
||||||
|
// the upstream connection is failing before the gateway can flush).
|
||||||
|
func (r *Runtime) RecordPushHeartbeat(ctx context.Context, attrs ...attribute.KeyValue) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.pushHeartbeats.Add(ctx, 1, metric.WithAttributes(attrs...))
|
||||||
|
}
|
||||||
|
|
||||||
// RecordInternalEventDrop records one malformed or rejected internal event.
|
// RecordInternalEventDrop records one malformed or rejected internal event.
|
||||||
func (r *Runtime) RecordInternalEventDrop(ctx context.Context, attrs ...attribute.KeyValue) {
|
func (r *Runtime) RecordInternalEventDrop(ctx context.Context, attrs ...attribute.KeyValue) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
|
|||||||
@@ -7,11 +7,15 @@
|
|||||||
# 1. Containers labelled `org.testcontainers=true` — every container
|
# 1. Containers labelled `org.testcontainers=true` — every container
|
||||||
# brought up by testcontainers-go (our backend/gateway/game plus
|
# brought up by testcontainers-go (our backend/gateway/game plus
|
||||||
# postgres/redis/mailpit/ryuk service containers).
|
# postgres/redis/mailpit/ryuk service containers).
|
||||||
# 2. Containers labelled `galaxy.backend=1` — engine instances spawned
|
# 2. Containers labelled `galaxy.backend=1` AND
|
||||||
# by backend's runtime adapter on the host Docker daemon (see
|
# `galaxy.stack=integration` — engine instances spawned by the
|
||||||
# `backend/internal/dockerclient/types.go`). These do not carry
|
# backend-under-test on the host Docker daemon (see
|
||||||
# the testcontainers label because backend, not testcontainers,
|
# `backend/internal/dockerclient/types.go` and the
|
||||||
# creates them.
|
# `BACKEND_STACK_LABEL=integration` env in
|
||||||
|
# `integration/testenv/backend.go`). The stack-label filter is
|
||||||
|
# what keeps dev-deploy / local-dev engines on the same host
|
||||||
|
# safe — they carry `galaxy.backend=1` too but a different
|
||||||
|
# `galaxy.stack` value, so the AND match leaves them alone.
|
||||||
# 3. Networks labelled `org.testcontainers=true` — networks created
|
# 3. Networks labelled `org.testcontainers=true` — networks created
|
||||||
# by testcontainers-go for cross-container wiring.
|
# by testcontainers-go for cross-container wiring.
|
||||||
# 4. Images labelled `galaxy.test.kind=integration-image` — local
|
# 4. Images labelled `galaxy.test.kind=integration-image` — local
|
||||||
@@ -22,14 +26,21 @@
|
|||||||
# What we never touch:
|
# What we never touch:
|
||||||
# - Containers / images without one of the labels above.
|
# - Containers / images without one of the labels above.
|
||||||
# - User-managed images and volumes.
|
# - User-managed images and volumes.
|
||||||
|
# - dev-deploy / local-dev engines (they share the `galaxy.backend=1`
|
||||||
|
# label, but their `galaxy.stack` value differs from `integration`).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
remove_containers_with_label() {
|
remove_containers_with_label() {
|
||||||
local label="$1"
|
local description="${!#}"
|
||||||
local description="$2"
|
local labels=("${@:1:$#-1}")
|
||||||
|
local filter_args=()
|
||||||
|
local label
|
||||||
|
for label in "${labels[@]}"; do
|
||||||
|
filter_args+=("--filter" "label=$label")
|
||||||
|
done
|
||||||
local ids
|
local ids
|
||||||
ids=$(docker ps -aq --filter "label=$label" 2>/dev/null || true)
|
ids=$(docker ps -aq "${filter_args[@]}" 2>/dev/null || true)
|
||||||
if [ -z "$ids" ]; then
|
if [ -z "$ids" ]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -81,7 +92,7 @@ if ! docker info >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
remove_containers_with_label "org.testcontainers=true" "testcontainers-managed containers"
|
remove_containers_with_label "org.testcontainers=true" "testcontainers-managed containers"
|
||||||
remove_containers_with_label "galaxy.backend=1" "backend-managed engine containers"
|
remove_containers_with_label "galaxy.backend=1" "galaxy.stack=integration" "integration-owned engine containers"
|
||||||
remove_networks_with_label "org.testcontainers=true" "testcontainers-managed networks"
|
remove_networks_with_label "org.testcontainers=true" "testcontainers-managed networks"
|
||||||
remove_images_with_label "galaxy.test.kind=integration-image" "integration-built images"
|
remove_images_with_label "galaxy.test.kind=integration-image" "integration-built images"
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ func StartBackend(t *testing.T, opts BackendOptions) *BackendContainer {
|
|||||||
"BACKEND_AUTH_CHALLENGE_THROTTLE_MAX": "100",
|
"BACKEND_AUTH_CHALLENGE_THROTTLE_MAX": "100",
|
||||||
"BACKEND_MAIL_WORKER_INTERVAL": "500ms",
|
"BACKEND_MAIL_WORKER_INTERVAL": "500ms",
|
||||||
"BACKEND_NOTIFICATION_WORKER_INTERVAL": "500ms",
|
"BACKEND_NOTIFICATION_WORKER_INTERVAL": "500ms",
|
||||||
|
// Stamp galaxy.stack=integration on every engine container the
|
||||||
|
// backend-under-test spawns so the post-run preclean.sh can
|
||||||
|
// scope its cleanup to integration-owned engines and leave
|
||||||
|
// dev-deploy / local-dev stacks running on the same daemon
|
||||||
|
// untouched. See `integration/scripts/preclean.sh` and the
|
||||||
|
// "Container labels" section in `docs/ARCHITECTURE.md`.
|
||||||
|
"BACKEND_STACK_LABEL": "integration",
|
||||||
}
|
}
|
||||||
for k, v := range opts.Extra {
|
for k, v := range opts.Extra {
|
||||||
env[k] = v
|
env[k] = v
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package calc
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// Ceil3 rounds num UP to three decimal places. The ship-class
|
||||||
|
// calculator displays every computed value (and every goal-seek
|
||||||
|
// back-solved input) through this so a result is never shown lower than
|
||||||
|
// it really is — e.g. a speed of 5.0003 reads as 5.001, not 5.000, which
|
||||||
|
// matters when a fraction of a light-year decides whether a ship clears
|
||||||
|
// the gap to a planet. It is display-only and lives here (rather than in
|
||||||
|
// the engine's round-to-nearest util.Fixed*) so the UI bridge can reach
|
||||||
|
// the one implementation through WASM.
|
||||||
|
//
|
||||||
|
// num is pre-rounded to nine decimals before the ceil so float64
|
||||||
|
// representation noise does not push an exact value up a step (e.g. a
|
||||||
|
// computed 5.0 stored as 5.0000000002 stays 5.0).
|
||||||
|
func Ceil3(num float64) float64 {
|
||||||
|
return math.Ceil(math.Round(num*1e9)/1e6) / 1000
|
||||||
|
}
|
||||||
+41
-4
@@ -22,10 +22,11 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 {
|
|||||||
// resources is expected to be positive in normal play; the helper
|
// resources is expected to be positive in normal play; the helper
|
||||||
// guards against a non-positive value by collapsing the material-
|
// guards against a non-positive value by collapsing the material-
|
||||||
// farming term to zero, which keeps callers numerically stable on
|
// farming term to zero, which keeps callers numerically stable on
|
||||||
// pathological synthetic data. Mirrors the per-iteration math inside
|
// pathological synthetic data. [ProduceShipsInTurn] composes this cost
|
||||||
// the engine's controller.ProduceShip so both surfaces — and the
|
// into the per-turn build loop that the engine's controller.ProduceShip
|
||||||
// legacy-report-to-json dev tool that needs to derive prod_used from
|
// delegates to, so the engine, the calculator, and the
|
||||||
// percent — share the same formula.
|
// legacy-report-to-json dev tool (which derives prod_used from percent)
|
||||||
|
// all share one formula.
|
||||||
func ShipBuildCost(shipMass, material, resources float64) float64 {
|
func ShipBuildCost(shipMass, material, resources float64) float64 {
|
||||||
matNeed := shipMass - material
|
matNeed := shipMass - material
|
||||||
if matNeed < 0 {
|
if matNeed < 0 {
|
||||||
@@ -37,3 +38,39 @@ func ShipBuildCost(shipMass, material, resources float64) float64 {
|
|||||||
}
|
}
|
||||||
return ShipProductionCost(shipMass) + matFarm
|
return ShipProductionCost(shipMass) + matFarm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProduceShipsInTurn simulates one turn of ship production on a planet
|
||||||
|
// that has productionAvailable production units to spend, a material
|
||||||
|
// stockpile, a resources rating, building ships of empty mass shipMass.
|
||||||
|
// It returns the number of whole ships completed this turn, the material
|
||||||
|
// left afterwards, the production units spent on the next (still
|
||||||
|
// incomplete) ship, and that ship's progress fraction in [0, 1).
|
||||||
|
//
|
||||||
|
// Each ship consumes shipMass units of material; any shortfall is farmed
|
||||||
|
// through [ShipBuildCost] at the planet's resources rating, draining the
|
||||||
|
// stockpile to zero before farming. The loop mirrors the engine's
|
||||||
|
// per-turn build step so the calculator and the turn generator agree on
|
||||||
|
// how many ships a planet yields. productionAvailable or shipMass that is
|
||||||
|
// non-positive yields no ships and leaves the stockpile untouched.
|
||||||
|
func ProduceShipsInTurn(
|
||||||
|
productionAvailable, material, resources, shipMass float64,
|
||||||
|
) (ships uint, materialLeft, productionUsed, progress float64) {
|
||||||
|
if productionAvailable <= 0 || shipMass <= 0 {
|
||||||
|
return 0, material, 0, 0
|
||||||
|
}
|
||||||
|
pa := productionAvailable
|
||||||
|
mat := material
|
||||||
|
for {
|
||||||
|
matNeed := shipMass - mat
|
||||||
|
if matNeed < 0 {
|
||||||
|
matNeed = 0
|
||||||
|
}
|
||||||
|
totalCost := ShipBuildCost(shipMass, mat, resources)
|
||||||
|
if pa < totalCost {
|
||||||
|
return ships, mat, pa, pa / totalCost
|
||||||
|
}
|
||||||
|
pa -= totalCost
|
||||||
|
mat = mat - shipMass + matNeed
|
||||||
|
ships++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,3 +61,52 @@ func TestShipBuildCost(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProduceShipsInTurn(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
productionAvailable, material, resources, shipMass float64
|
||||||
|
wantShips uint
|
||||||
|
wantMaterialLeft, wantProductionUsed, wantProgress float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ample material: ten ships, no farming",
|
||||||
|
productionAvailable: 100, material: 100, resources: 10, shipMass: 1,
|
||||||
|
wantShips: 10, wantMaterialLeft: 90, wantProductionUsed: 0, wantProgress: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no material: partial progress on a farmed ship",
|
||||||
|
productionAvailable: 114, material: 0, resources: 0.5, shipMass: 10,
|
||||||
|
// ShipBuildCost(10,0,0.5) = 100 + 10/0.5 = 120; 114/120 = 0.95.
|
||||||
|
wantShips: 0, wantMaterialLeft: 0, wantProductionUsed: 114, wantProgress: 0.95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no production available leaves the stockpile",
|
||||||
|
productionAvailable: 0, material: 50, resources: 10, shipMass: 5,
|
||||||
|
wantShips: 0, wantMaterialLeft: 50, wantProductionUsed: 0, wantProgress: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero ship mass is guarded against an endless loop",
|
||||||
|
productionAvailable: 100, material: 50, resources: 10, shipMass: 0,
|
||||||
|
wantShips: 0, wantMaterialLeft: 50, wantProductionUsed: 0, wantProgress: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
|
||||||
|
tc.productionAvailable, tc.material, tc.resources, tc.shipMass)
|
||||||
|
if ships != tc.wantShips {
|
||||||
|
t.Errorf("ships = %d, want %d", ships, tc.wantShips)
|
||||||
|
}
|
||||||
|
if math.Abs(materialLeft-tc.wantMaterialLeft) > 1e-9 {
|
||||||
|
t.Errorf("materialLeft = %v, want %v", materialLeft, tc.wantMaterialLeft)
|
||||||
|
}
|
||||||
|
if math.Abs(productionUsed-tc.wantProductionUsed) > 1e-9 {
|
||||||
|
t.Errorf("productionUsed = %v, want %v", productionUsed, tc.wantProductionUsed)
|
||||||
|
}
|
||||||
|
if math.Abs(progress-tc.wantProgress) > 1e-9 {
|
||||||
|
t.Errorf("progress = %v, want %v", progress, tc.wantProgress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ package calc
|
|||||||
|
|
||||||
// max flight distance for race's driveTech level.
|
// max flight distance for race's driveTech level.
|
||||||
// applies for sending ships and setting routes.
|
// applies for sending ships and setting routes.
|
||||||
func FligthDistance(driveTech float64) float64 {
|
func FlightDistance(driveTech float64) float64 {
|
||||||
return driveTech * 40
|
return driveTech * 40
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,3 +94,14 @@ func EffectiveDefence(
|
|||||||
}
|
}
|
||||||
return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.)
|
return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BombingPower returns the bombing power of number ships whose weapons
|
||||||
|
// block is weapons, built at weapons tech level weaponsTech and carrying
|
||||||
|
// armament weapon mounts. The leading factor sqrt(weapons*weaponsTech)/10
|
||||||
|
// + 1 makes the power grow super-linearly with effective weapon strength,
|
||||||
|
// which then scales linearly with weapons, weaponsTech, armament, and
|
||||||
|
// number. With zero armament or zero weapons the power is zero.
|
||||||
|
func BombingPower(weapons, weaponsTech, armament, number float64) float64 {
|
||||||
|
return (math.Sqrt(weapons*weaponsTech)/10. + 1.) *
|
||||||
|
weapons * weaponsTech * armament * number
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,3 +32,27 @@ func TestBlockUpgradeCost(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBombingPower(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
weapons, weaponsTech, armament, number float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
// Parity with the engine's Battle_Station fixture
|
||||||
|
// (game/internal/model/game/group_test.go): (sqrt(30)/10+1)*30*3.
|
||||||
|
{"battle station, single ship", 30, 1, 3, 1, 139.29503},
|
||||||
|
{"battle station, two ships scale linearly", 30, 1, 3, 2, 278.59006},
|
||||||
|
{"no armament: zero power", 30, 1, 0, 5, 0},
|
||||||
|
{"no weapons: zero power", 0, 1, 3, 5, 0},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := calc.BombingPower(tc.weapons, tc.weaponsTech, tc.armament, tc.number)
|
||||||
|
if math.Abs(got-tc.want) > 1e-3 {
|
||||||
|
t.Errorf("BombingPower(%v, %v, %v, %v) = %v, want %v",
|
||||||
|
tc.weapons, tc.weaponsTech, tc.armament, tc.number, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package calc
|
||||||
|
|
||||||
|
// This file holds the inverse ("goal-seek") counterparts of the forward
|
||||||
|
// ship formulas. The ship-class calculator lets a player pin one derived
|
||||||
|
// result and back-solve the single input it claims; each solver inverts
|
||||||
|
// exactly one forward function so the math stays in this package rather
|
||||||
|
// than leaking into the UI bridge. Every solver reports ok == false when
|
||||||
|
// the request is infeasible (e.g. an unreachable target or a division by
|
||||||
|
// a non-positive tech level), leaving the returned value undefined.
|
||||||
|
|
||||||
|
// WeaponsForAttack returns the weapons block that yields targetAttack at
|
||||||
|
// weapons tech level weaponsTech, inverting [EffectiveAttack]. It is
|
||||||
|
// infeasible when weaponsTech is non-positive or targetAttack is
|
||||||
|
// negative.
|
||||||
|
func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
|
||||||
|
if weaponsTech <= 0 || targetAttack < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return targetAttack / weaponsTech, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriveForSpeed returns the drive block that yields targetSpeed for a
|
||||||
|
// ship whose mass excluding the drive block is restMass, at drive tech
|
||||||
|
// level driveTech, inverting [Speed] composed with [DriveEffective].
|
||||||
|
// Speed approaches but never reaches the stripped-hull ceiling
|
||||||
|
// 20*driveTech, so a target at or above the ceiling (or a non-positive
|
||||||
|
// target or tech level) is infeasible.
|
||||||
|
func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
|
||||||
|
ceiling := 20 * driveTech
|
||||||
|
if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return targetSpeed * restMass / (ceiling - targetSpeed), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShieldsForDefence returns the shields block that yields targetDefence
|
||||||
|
// for a ship whose mass excluding the shields block is restMass, at
|
||||||
|
// shields tech level shieldsTech, inverting [EffectiveDefence]. Defence
|
||||||
|
// rises monotonically with shields (the block adds mass to its own
|
||||||
|
// denominator), so the block is found by bisection. It is infeasible when
|
||||||
|
// targetDefence or shieldsTech is non-positive.
|
||||||
|
func ShieldsForDefence(targetDefence, shieldsTech, restMass float64) (float64, bool) {
|
||||||
|
if targetDefence <= 0 || shieldsTech <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
lo, hi := 0.0, 1.0
|
||||||
|
for EffectiveDefence(hi, shieldsTech, hi+restMass) < targetDefence {
|
||||||
|
hi *= 2
|
||||||
|
if hi > 1e12 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range 100 {
|
||||||
|
mid := (lo + hi) / 2
|
||||||
|
if EffectiveDefence(mid, shieldsTech, mid+restMass) < targetDefence {
|
||||||
|
lo = mid
|
||||||
|
} else {
|
||||||
|
hi = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (lo + hi) / 2, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CargoForEmptyMass returns the cargo block that brings a ship's empty
|
||||||
|
// mass to targetEmptyMass, given restMass — the combined mass of the
|
||||||
|
// other blocks (drive, shields, and the weapons block) — inverting the
|
||||||
|
// cargo term of [EmptyMass]. It is infeasible when targetEmptyMass is
|
||||||
|
// below restMass, which would require a negative cargo block.
|
||||||
|
func CargoForEmptyMass(targetEmptyMass, restMass float64) (float64, bool) {
|
||||||
|
cargo := targetEmptyMass - restMass
|
||||||
|
if cargo < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return cargo, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadForFullMass returns the cargo load that brings a ship's full mass
|
||||||
|
// to targetFullMass, given its empty mass and cargo tech level, inverting
|
||||||
|
// [CarryingMass] inside [FullMass]. It is infeasible when targetFullMass
|
||||||
|
// is below emptyMass or cargoTech is non-positive.
|
||||||
|
func LoadForFullMass(targetFullMass, emptyMass, cargoTech float64) (float64, bool) {
|
||||||
|
if cargoTech <= 0 || targetFullMass < emptyMass {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return (targetFullMass - emptyMass) * cargoTech, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package calc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/calc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWeaponsForAttack(t *testing.T) {
|
||||||
|
got, ok := calc.WeaponsForAttack(calc.EffectiveAttack(12, 1.5), 1.5)
|
||||||
|
if !ok || math.Abs(got-12) > 1e-9 {
|
||||||
|
t.Errorf("WeaponsForAttack round-trip = %v (ok=%v), want 12", got, ok)
|
||||||
|
}
|
||||||
|
if _, ok := calc.WeaponsForAttack(10, 0); ok {
|
||||||
|
t.Error("WeaponsForAttack with zero tech should be infeasible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveForSpeed(t *testing.T) {
|
||||||
|
const drive, driveTech, restMass = 10.0, 1.2, 35.0
|
||||||
|
speed := calc.Speed(calc.DriveEffective(drive, driveTech), drive+restMass)
|
||||||
|
got, ok := calc.DriveForSpeed(speed, driveTech, restMass)
|
||||||
|
if !ok || math.Abs(got-drive) > 1e-9 {
|
||||||
|
t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive)
|
||||||
|
}
|
||||||
|
// Speed can never reach the stripped-hull ceiling 20*driveTech.
|
||||||
|
if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
|
||||||
|
t.Error("DriveForSpeed at the speed ceiling should be infeasible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShieldsForDefence(t *testing.T) {
|
||||||
|
const shields, shieldsTech, restMass = 5.75, 1.0, 40.0
|
||||||
|
defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass)
|
||||||
|
got, ok := calc.ShieldsForDefence(defence, shieldsTech, restMass)
|
||||||
|
if !ok || math.Abs(got-shields) > 1e-6 {
|
||||||
|
t.Errorf("ShieldsForDefence round-trip = %v (ok=%v), want %v", got, ok, shields)
|
||||||
|
}
|
||||||
|
if _, ok := calc.ShieldsForDefence(0, shieldsTech, restMass); ok {
|
||||||
|
t.Error("ShieldsForDefence at a zero target should be infeasible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCargoForEmptyMass(t *testing.T) {
|
||||||
|
const restMass, cargo = 30.0, 12.0
|
||||||
|
got, ok := calc.CargoForEmptyMass(restMass+cargo, restMass)
|
||||||
|
if !ok || math.Abs(got-cargo) > 1e-9 {
|
||||||
|
t.Errorf("CargoForEmptyMass round-trip = %v (ok=%v), want %v", got, ok, cargo)
|
||||||
|
}
|
||||||
|
if _, ok := calc.CargoForEmptyMass(restMass-1, restMass); ok {
|
||||||
|
t.Error("CargoForEmptyMass below the fixed block mass should be infeasible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadForFullMass(t *testing.T) {
|
||||||
|
const emptyMass, cargoTech, load = 45.0, 1.0, 20.0
|
||||||
|
full := calc.FullMass(emptyMass, calc.CarryingMass(load, cargoTech))
|
||||||
|
got, ok := calc.LoadForFullMass(full, emptyMass, cargoTech)
|
||||||
|
if !ok || math.Abs(got-load) > 1e-9 {
|
||||||
|
t.Errorf("LoadForFullMass round-trip = %v (ok=%v), want %v", got, ok, load)
|
||||||
|
}
|
||||||
|
if _, ok := calc.LoadForFullMass(emptyMass-1, emptyMass, cargoTech); ok {
|
||||||
|
t.Error("LoadForFullMass below empty mass should be infeasible")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package diplomail defines the public typed command identifiers
|
||||||
|
// exposed at the authenticated Gateway -> Diplomatic Mail boundary.
|
||||||
|
//
|
||||||
|
// The gateway routes each `user.games.mail.*` ExecuteCommand into the
|
||||||
|
// matching `/api/v1/user/games/{game_id}/mail/*` REST endpoint on the
|
||||||
|
// backend; the wire envelopes and payload tables live in
|
||||||
|
// `pkg/schema/fbs/diplomail.fbs`.
|
||||||
|
package diplomail
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MessageTypeUserGamesMailInbox is the authenticated gateway
|
||||||
|
// message type used to read the caller's diplomatic-mail inbox
|
||||||
|
// for one game. Backend filters out rows whose `available_at` is
|
||||||
|
// still nil (translation in flight).
|
||||||
|
MessageTypeUserGamesMailInbox = "user.games.mail.inbox"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailSent is the authenticated gateway
|
||||||
|
// message type used to read the caller's outgoing personal
|
||||||
|
// messages for one game. Admin and system rows are not included.
|
||||||
|
MessageTypeUserGamesMailSent = "user.games.mail.sent"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailMessageGet is the authenticated
|
||||||
|
// gateway message type used to read a single message detail
|
||||||
|
// addressed to the caller. The response carries the translation
|
||||||
|
// rendering when one is cached for the caller's preferred
|
||||||
|
// language.
|
||||||
|
MessageTypeUserGamesMailMessageGet = "user.games.mail.message.get"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailSend is the authenticated gateway
|
||||||
|
// message type used to send a single-recipient personal message.
|
||||||
|
// Exactly one of `recipient_user_id` and `recipient_race_name`
|
||||||
|
// must be supplied; the backend resolves the race-name shortcut
|
||||||
|
// through `Memberships.ListMembers(gameID, "active")`.
|
||||||
|
MessageTypeUserGamesMailSend = "user.games.mail.send"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailBroadcast is the authenticated gateway
|
||||||
|
// message type used by paid-tier callers to broadcast a personal
|
||||||
|
// message to every other active member of the game.
|
||||||
|
MessageTypeUserGamesMailBroadcast = "user.games.mail.broadcast"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailAdmin is the authenticated gateway
|
||||||
|
// message type used by the game owner to compose an admin-kind
|
||||||
|
// notification. The wire shape is target-discriminated: `user`
|
||||||
|
// addresses a single recipient (by id or race name); `all`
|
||||||
|
// broadcasts to every member matching the requested scope.
|
||||||
|
MessageTypeUserGamesMailAdmin = "user.games.mail.admin"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailRead is the authenticated gateway
|
||||||
|
// message type used to mark a single message as read. Idempotent.
|
||||||
|
MessageTypeUserGamesMailRead = "user.games.mail.read"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesMailDelete is the authenticated gateway
|
||||||
|
// message type used to soft-delete a single message. The
|
||||||
|
// recipient row must already be marked read.
|
||||||
|
MessageTypeUserGamesMailDelete = "user.games.mail.delete"
|
||||||
|
)
|
||||||
@@ -9,6 +9,13 @@ import "github.com/google/uuid"
|
|||||||
// `Report`.
|
// `Report`.
|
||||||
const MessageTypeUserGamesReport = "user.games.report"
|
const MessageTypeUserGamesReport = "user.games.report"
|
||||||
|
|
||||||
|
// MessageTypeUserGamesBattle is the authenticated gateway message type
|
||||||
|
// used to fetch one battle report through
|
||||||
|
// `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`. The
|
||||||
|
// signed payload is a FlatBuffers `GameBattleRequest`; the response is
|
||||||
|
// a FlatBuffers `BattleReport`.
|
||||||
|
const MessageTypeUserGamesBattle = "user.games.battle"
|
||||||
|
|
||||||
// GameReportRequest is the typed payload of MessageTypeUserGamesReport.
|
// GameReportRequest is the typed payload of MessageTypeUserGamesReport.
|
||||||
// `GameID` selects the target game (the message_type alone is not
|
// `GameID` selects the target game (the message_type alone is not
|
||||||
// enough; this scope is per-game) and `Turn` selects the requested
|
// enough; this scope is per-game) and `Turn` selects the requested
|
||||||
@@ -20,3 +27,19 @@ type GameReportRequest struct {
|
|||||||
// Turn is the zero-based turn number whose report is requested.
|
// Turn is the zero-based turn number whose report is requested.
|
||||||
Turn uint `json:"turn"`
|
Turn uint `json:"turn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameBattleRequest is the typed payload of MessageTypeUserGamesBattle.
|
||||||
|
// `GameID` selects the target game; `Turn` is the turn the battle
|
||||||
|
// happened at (the engine partitions battles by turn for cheap lookup);
|
||||||
|
// `BattleID` is the in-game identifier returned in the report's
|
||||||
|
// battle-summary list. All three fields are required.
|
||||||
|
type GameBattleRequest struct {
|
||||||
|
// GameID identifies the game the battle belongs to.
|
||||||
|
GameID uuid.UUID `json:"game_id"`
|
||||||
|
|
||||||
|
// Turn is the turn number the battle happened at.
|
||||||
|
Turn uint `json:"turn"`
|
||||||
|
|
||||||
|
// BattleID is the engine-assigned id of the battle to fetch.
|
||||||
|
BattleID uuid.UUID `json:"battle_id"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,4 +49,14 @@ table BattleReport {
|
|||||||
protocol:[BattleActionReport];
|
protocol:[BattleActionReport];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameBattleRequest is the signed-gRPC request payload for
|
||||||
|
// `MessageTypeUserGamesBattle`. Gateway forwards this into the
|
||||||
|
// backend's `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
|
||||||
|
// endpoint after resolving the caller's runtime player mapping.
|
||||||
|
table GameBattleRequest {
|
||||||
|
game_id:UUID (required);
|
||||||
|
turn:uint32;
|
||||||
|
battle_id:UUID (required);
|
||||||
|
}
|
||||||
|
|
||||||
root_type BattleReport;
|
root_type BattleReport;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package battle
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameBattleRequest struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsGameBattleRequest(buf []byte, offset flatbuffers.UOffsetT) *GameBattleRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &GameBattleRequest{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishGameBattleRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsGameBattleRequest(buf []byte, offset flatbuffers.UOffsetT) *GameBattleRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &GameBattleRequest{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedGameBattleRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameBattleRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameBattleRequest) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameBattleRequest) GameId(obj *UUID) *UUID {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
x := o + rcv._tab.Pos
|
||||||
|
if obj == nil {
|
||||||
|
obj = new(UUID)
|
||||||
|
}
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameBattleRequest) Turn() uint32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameBattleRequest) MutateTurn(n uint32) bool {
|
||||||
|
return rcv._tab.MutateUint32Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameBattleRequest) BattleId(obj *UUID) *UUID {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
x := o + rcv._tab.Pos
|
||||||
|
if obj == nil {
|
||||||
|
obj = new(UUID)
|
||||||
|
}
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameBattleRequestStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func GameBattleRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||||
|
}
|
||||||
|
func GameBattleRequestAddTurn(builder *flatbuffers.Builder, turn uint32) {
|
||||||
|
builder.PrependUint32Slot(1, turn, 0)
|
||||||
|
}
|
||||||
|
func GameBattleRequestAddBattleId(builder *flatbuffers.Builder, battleId flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependStructSlot(2, flatbuffers.UOffsetT(battleId), 0)
|
||||||
|
}
|
||||||
|
func GameBattleRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// diplomail contains FlatBuffers payloads used by the authenticated
|
||||||
|
// gateway boundary for the in-game diplomatic-mail subsystem. The
|
||||||
|
// wire shapes here mirror the trusted internal
|
||||||
|
// `/api/v1/user/games/{game_id}/mail/*` REST surface; gateway derives
|
||||||
|
// the calling `user_id` from the verified session and forwards it as
|
||||||
|
// `X-User-Id` to backend.
|
||||||
|
|
||||||
|
include "common.fbs";
|
||||||
|
|
||||||
|
namespace diplomail;
|
||||||
|
|
||||||
|
// MailMessage stores one inbox / sent-list / message-detail row. The
|
||||||
|
// fields mirror `UserMailMessageDetail` in `backend/openapi.yaml`
|
||||||
|
// with the following encoding rules:
|
||||||
|
//
|
||||||
|
// - `*_user_id` fields are RFC 4122 string UUIDs ("" means absent
|
||||||
|
// for nullable fields such as `sender_user_id`).
|
||||||
|
// - `*_at_ms` fields carry Unix milliseconds; `0` means the
|
||||||
|
// timestamp is absent (e.g. an unread message has
|
||||||
|
// `read_at_ms == 0`).
|
||||||
|
// - `translated_*`, `translation_lang`, and `translator` are set
|
||||||
|
// when the backend served a cached rendering into the caller's
|
||||||
|
// preferred language; empty otherwise.
|
||||||
|
// - `sender_race_name` is the snapshot of the sender's race name
|
||||||
|
// in this game at send time. Present for `sender_kind="player"`
|
||||||
|
// messages when the sender had an active membership; absent for
|
||||||
|
// admin and system messages. The in-game UI keys per-race
|
||||||
|
// threading on this field.
|
||||||
|
table MailMessage {
|
||||||
|
message_id:string;
|
||||||
|
game_id:string;
|
||||||
|
game_name:string;
|
||||||
|
kind:string;
|
||||||
|
sender_kind:string;
|
||||||
|
sender_user_id:string;
|
||||||
|
sender_username:string;
|
||||||
|
sender_race_name:string;
|
||||||
|
subject:string;
|
||||||
|
body:string;
|
||||||
|
body_lang:string;
|
||||||
|
broadcast_scope:string;
|
||||||
|
created_at_ms:int64;
|
||||||
|
recipient_user_id:string;
|
||||||
|
recipient_user_name:string;
|
||||||
|
recipient_race_name:string;
|
||||||
|
read_at_ms:int64;
|
||||||
|
deleted_at_ms:int64;
|
||||||
|
translated_subject:string;
|
||||||
|
translated_body:string;
|
||||||
|
translation_lang:string;
|
||||||
|
translator:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailRecipientState mirrors the `UserMailRecipientState` payload
|
||||||
|
// returned from mark-read and soft-delete endpoints. Same timestamp
|
||||||
|
// conventions as `MailMessage`.
|
||||||
|
table MailRecipientState {
|
||||||
|
message_id:string;
|
||||||
|
read_at_ms:int64;
|
||||||
|
deleted_at_ms:int64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailBroadcastReceipt mirrors `UserMailBroadcastReceipt`. Returned
|
||||||
|
// from broadcast sends (paid-tier and admin); `recipient_count` is
|
||||||
|
// the number of recipient rows the server materialised.
|
||||||
|
table MailBroadcastReceipt {
|
||||||
|
message_id:string;
|
||||||
|
game_id:string;
|
||||||
|
game_name:string;
|
||||||
|
kind:string;
|
||||||
|
sender_kind:string;
|
||||||
|
subject:string;
|
||||||
|
body:string;
|
||||||
|
body_lang:string;
|
||||||
|
broadcast_scope:string;
|
||||||
|
created_at_ms:int64;
|
||||||
|
recipient_count:int32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxRequest stores the read-side request for the caller's inbox
|
||||||
|
// in `game_id`. Backend filters to messages with `available_at` set
|
||||||
|
// (translation completed when the recipient's preferred language
|
||||||
|
// differs from the body language).
|
||||||
|
table InboxRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxResponse stores the resulting inbox list, newest first.
|
||||||
|
// `items` is empty when the caller has no available messages in
|
||||||
|
// this game.
|
||||||
|
table InboxResponse {
|
||||||
|
items:[MailMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentRequest stores the read-side request for the caller's sent
|
||||||
|
// personal messages in `game_id`. Admin / system rows are not
|
||||||
|
// included.
|
||||||
|
table SentRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentResponse stores the caller's outgoing personal-message list.
|
||||||
|
// Each `MailMessage` carries the original recipient snapshot.
|
||||||
|
table SentResponse {
|
||||||
|
items:[MailMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageGetRequest stores the read-side request for a single
|
||||||
|
// message detail. The caller must be a recipient of the message.
|
||||||
|
table MessageGetRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
message_id:common.UUID (required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageGetResponse stores the fully decorated message detail
|
||||||
|
// including any cached translation into the caller's preferred
|
||||||
|
// language.
|
||||||
|
table MessageGetResponse {
|
||||||
|
message:MailMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRequest stores the write-side request for a single-recipient
|
||||||
|
// personal send. Exactly one of `recipient_user_id` /
|
||||||
|
// `recipient_race_name` must be supplied; the empty string means
|
||||||
|
// "use the other field".
|
||||||
|
table SendRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
recipient_user_id:string;
|
||||||
|
recipient_race_name:string;
|
||||||
|
subject:string;
|
||||||
|
body:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendResponse echoes the freshly inserted message detail.
|
||||||
|
table SendResponse {
|
||||||
|
message:MailMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastRequest stores the paid-tier player broadcast. The
|
||||||
|
// recipient set is always "every other active member of the game".
|
||||||
|
table BroadcastRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
subject:string;
|
||||||
|
body:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastResponse stores the receipt returned by the server.
|
||||||
|
table BroadcastResponse {
|
||||||
|
receipt:MailBroadcastReceipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRequest stores the owner-only admin send. `target="user"`
|
||||||
|
// requires exactly one of `recipient_user_id` / `recipient_race_name`;
|
||||||
|
// `target="all"` accepts the optional `recipients` scope (default
|
||||||
|
// `active`).
|
||||||
|
table AdminRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
target:string;
|
||||||
|
recipient_user_id:string;
|
||||||
|
recipient_race_name:string;
|
||||||
|
recipients:string;
|
||||||
|
subject:string;
|
||||||
|
body:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResponse carries the result of an admin send. When the
|
||||||
|
// request had `target="user"`, `message` is set; when `target="all"`,
|
||||||
|
// `receipt` is set. Callers branch on which field is present.
|
||||||
|
table AdminResponse {
|
||||||
|
message:MailMessage;
|
||||||
|
receipt:MailBroadcastReceipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRequest stores the mark-read intent for a single message. The
|
||||||
|
// caller must be a recipient. Idempotent.
|
||||||
|
table ReadRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
message_id:common.UUID (required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadResponse echoes the recipient state after the operation.
|
||||||
|
table ReadResponse {
|
||||||
|
state:MailRecipientState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRequest stores the soft-delete intent for a single message.
|
||||||
|
// The message must already be marked read (HTTP 409 otherwise).
|
||||||
|
table DeleteRequest {
|
||||||
|
game_id:common.UUID (required);
|
||||||
|
message_id:common.UUID (required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteResponse echoes the recipient state after the operation.
|
||||||
|
table DeleteResponse {
|
||||||
|
state:MailRecipientState;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user