Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19512adaa | |||
| 814eae0802 | |||
| cb8491c200 | |||
| 45815c27d9 | |||
| e11092234c | |||
| 84a0ccb23f | |||
| 7fb6a63c2b | |||
| 225f89fad6 | |||
| 0cae89cba2 | |||
| 26f1e62924 | |||
| 7cac910de4 | |||
| 87a272166b | |||
| ecfb2d3351 | |||
| cf34710b4f | |||
| 985e51d25e | |||
| 27916bbe61 | |||
| 5d2f2bfc26 | |||
| e998c8a03a | |||
| a01e3891e7 | |||
| f9f725f657 | |||
| 9b689b2885 | |||
| 2a3f31a32b | |||
| 140ee8e0ee | |||
| d3770e7f77 | |||
| 3d7e4d30bb | |||
| eb549e6049 | |||
| bc838d72af | |||
| 658ab7f6e7 | |||
| afb8c1225c | |||
| 9e9977d5f1 | |||
| 9dce15c7bb | |||
| dc621cc715 | |||
| bef6c46a1c | |||
| 3b1c52cd02 | |||
| 200236369f | |||
| a01f39e4a7 | |||
| 6c00a24577 | |||
| 6ec1098f15 | |||
| f877a199c2 | |||
| 53b3cafbc4 | |||
| b4abf90ec5 | |||
| 5e86ca9999 | |||
| cc67364113 | |||
| 97b5535c02 | |||
| bde9d535dc | |||
| 40d6ba6ba4 | |||
| 06a2e631c9 | |||
| 2f55fc4988 | |||
| 601970b028 | |||
| e36d33482f | |||
| 15d35f6f1f | |||
| 4a7bf0be61 | |||
| 2ffd7527a6 | |||
| 723885e74e | |||
| e038ea6154 | |||
| af30846091 | |||
| ce1dc19a29 | |||
| a37b784452 | |||
| f4670c1831 | |||
| 4d729c1f50 | |||
| 24d75564bb | |||
| eb5018342e | |||
| 6c3cd25476 | |||
| 6996a79286 | |||
| 75a4211373 | |||
| 680ebac919 | |||
| ba93a9092e | |||
| 209f8508cd | |||
| e4fbb6644c | |||
| 8e552f556d | |||
| 80ed11e3b6 | |||
| ef4cecb4b2 | |||
| 1b2c13ecd6 | |||
| cfbe052242 | |||
| 147c7d0a6a | |||
| 24c68e9846 | |||
| cc4bc3c2b7 | |||
| aee5f39a7e | |||
| 4a23c357e5 | |||
| 2901ecb21b | |||
| ed4e2f58a1 | |||
| b31d9f4c45 | |||
| 208d30073b | |||
| 6fbab5417f | |||
| 8e8b34d112 | |||
| 175bf25794 | |||
| 3d8aa91973 | |||
| 3153a95292 | |||
| f42ab87233 | |||
| cff7cc3859 | |||
| 058c4fcf69 | |||
| 009ea560f9 | |||
| 98d1fe6cae | |||
| a679d9cdcb | |||
| 2ecdecad1e | |||
| b03993fcb1 | |||
| b01a60e42b | |||
| cc4727a32e | |||
| cbf7f65916 | |||
| e9b904332e | |||
| 793b709d8f | |||
| 2294d8b3d9 | |||
| 5ca30df334 | |||
| 1f6791549a | |||
| e82c9f8bbd | |||
| b957d17022 | |||
| 3d5b331bd9 | |||
| 6f2967024a | |||
| f6e4a4f6bd | |||
| d44ad9b6eb | |||
| 91e34a0929 | |||
| f0857243e2 | |||
| 1dadf08672 | |||
| c1672224a6 | |||
| e31fb2c17a | |||
| 4e0058d46c | |||
| 80545e9f9d | |||
| be7f06e163 | |||
| b6770d394c | |||
| 182beebcd6 | |||
| ae91037bc3 | |||
| ec98639d49 | |||
| 9cb5097f54 | |||
| a453b74b04 | |||
| 8565942392 | |||
| fa0df5183a | |||
| 8e0a1c39c0 | |||
| 780769b3c4 | |||
| 53fb4f5f76 | |||
| 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 |
@@ -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
|
||||||
@@ -28,4 +28,4 @@ jobs:
|
|||||||
echo " 2. scp the .tar.gz bundles to the production host."
|
echo " 2. scp the .tar.gz bundles to the production host."
|
||||||
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
|
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 " 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."
|
echo " 5. Probe https://<public host>/healthz and roll back on failure."
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ name: Deploy · Dev
|
|||||||
# `integration` as part of the PR that produced this push, so this
|
# `integration` as part of the PR that produced this push, so this
|
||||||
# workflow does not re-run those tests — it focuses on packaging and
|
# workflow does not re-run those tests — it focuses on packaging and
|
||||||
# rollout.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,11 +24,13 @@ on:
|
|||||||
- 'game/**'
|
- 'game/**'
|
||||||
- 'pkg/**'
|
- 'pkg/**'
|
||||||
- 'ui/**'
|
- 'ui/**'
|
||||||
|
- 'site/**'
|
||||||
- 'go.work'
|
- 'go.work'
|
||||||
- 'go.work.sum'
|
- 'go.work.sum'
|
||||||
- 'tools/dev-deploy/**'
|
- 'tools/dev-deploy/**'
|
||||||
- '.gitea/workflows/dev-deploy.yaml'
|
- '.gitea/workflows/dev-deploy.yaml'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
@@ -40,32 +48,45 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
# See go-unit.yaml for why `cache: true` is disabled.
|
cache: true
|
||||||
cache: false
|
|
||||||
|
|
||||||
- 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
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
# `cache: pnpm` pushes the pnpm store through the Gitea
|
cache: pnpm
|
||||||
# Actions cache service which is currently unreachable
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
# (192.168.0.222:43513 ETIMEDOUT). In host-mode the real
|
|
||||||
# store lives in ~/.local/share/pnpm and persists between
|
|
||||||
# jobs without any action plumbing.
|
|
||||||
|
|
||||||
- name: Install UI dependencies
|
- name: Install UI dependencies
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build core.wasm
|
||||||
|
uses: ./.gitea/actions/build-wasm
|
||||||
|
|
||||||
- name: Build UI frontend
|
- name: Build UI frontend
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
env:
|
env:
|
||||||
VITE_GATEWAY_BASE_URL: https://api.galaxy.lan
|
# Single-origin deployment: an empty base URL means the
|
||||||
|
# gateway shares the document origin (REST at /api, Connect at
|
||||||
|
# /rpc). The game UI is served under the /game/ base path.
|
||||||
|
VITE_GATEWAY_BASE_URL: ""
|
||||||
|
BASE_PATH: /game
|
||||||
|
# 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: |
|
run: |
|
||||||
# The response-signing public key is committed in
|
# The response-signing public key is committed in
|
||||||
# `.env.development` alongside its private counterpart in
|
# `.env.development` alongside its private counterpart in
|
||||||
@@ -75,6 +96,14 @@ jobs:
|
|||||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
|
- name: Install site dependencies
|
||||||
|
working-directory: site
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project site
|
||||||
|
working-directory: site
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
- name: Build galaxy-engine image
|
- name: Build galaxy-engine image
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
run: |
|
run: |
|
||||||
@@ -96,13 +125,155 @@ jobs:
|
|||||||
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
||||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||||
|
|
||||||
|
- name: Seed site volume
|
||||||
|
run: |
|
||||||
|
docker volume create galaxy-dev-site-dist >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v galaxy-dev-site-dist:/dst \
|
||||||
|
-v "${{ gitea.workspace }}/site/.vitepress/dist:/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: Seed mailpit relay config
|
||||||
|
env:
|
||||||
|
GALAXY_DEV_MAIL_RELAY_USERNAME: ${{ secrets.GALAXY_DEV_MAIL_RELAY_USERNAME }}
|
||||||
|
GALAXY_DEV_MAIL_RELAY_PASSWORD: ${{ secrets.GALAXY_DEV_MAIL_RELAY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Render the Mailpit relay upstream config from the template,
|
||||||
|
# substituting the Gmail App Password from a Gitea secret, then
|
||||||
|
# seed it into a named volume (same rationale as the geoip seed:
|
||||||
|
# a workspace bind-mount would vanish with the runner workspace).
|
||||||
|
# The secret never lands in git or a committed file; it is
|
||||||
|
# rendered to a tmpfile outside the repo and removed after. Gmail
|
||||||
|
# App Passwords are [a-z]{16}, so the `|` sed delimiter is safe.
|
||||||
|
# When the secret is unset the creds render empty and the compose
|
||||||
|
# default relay-match is non-routable, so the stack only captures.
|
||||||
|
rendered="$(mktemp)"
|
||||||
|
sed -e "s|\${GALAXY_DEV_MAIL_RELAY_USERNAME}|${GALAXY_DEV_MAIL_RELAY_USERNAME}|g" \
|
||||||
|
-e "s|\${GALAXY_DEV_MAIL_RELAY_PASSWORD}|${GALAXY_DEV_MAIL_RELAY_PASSWORD}|g" \
|
||||||
|
"${{ gitea.workspace }}/tools/dev-deploy/mailpit/relay.conf.tmpl" > "$rendered"
|
||||||
|
docker volume create galaxy-dev-mailpit-config >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v galaxy-dev-mailpit-config:/dst \
|
||||||
|
-v "$rendered:/src/relay.conf:ro" \
|
||||||
|
alpine sh -c 'cp /src/relay.conf /dst/relay.conf && chmod 600 /dst/relay.conf'
|
||||||
|
rm -f "$rendered"
|
||||||
|
|
||||||
|
- name: Recycle engine containers on image drift
|
||||||
|
run: |
|
||||||
|
# Compare the freshly-built `galaxy-engine:dev` SHA against
|
||||||
|
# every running `galaxy-game-*` container. The backend
|
||||||
|
# reconciler adopts pre-existing labelled engine containers
|
||||||
|
# without checking image drift, so a running game would
|
||||||
|
# otherwise keep serving the previous engine code until the
|
||||||
|
# container is recycled by hand. This step makes the recycle
|
||||||
|
# automatic but only when it is actually needed:
|
||||||
|
#
|
||||||
|
# * BuildKit cache hit on the `Build galaxy-engine image`
|
||||||
|
# step → `galaxy-engine:dev` keeps its previous SHA →
|
||||||
|
# no drift → no-op (no engine source change to deploy).
|
||||||
|
# * engine source change → fresh SHA → for each drifted
|
||||||
|
# container we stop the backend, remove the container,
|
||||||
|
# wipe its bind-mounted state directory (Engine.Init()
|
||||||
|
# writes turn-0 over any pre-existing `turn-N` files —
|
||||||
|
# silent state corruption otherwise), and cascade-delete
|
||||||
|
# the lobby `games` row (the FKs in `00001_init.sql`
|
||||||
|
# drop the matching `runtime_records`, `memberships`,
|
||||||
|
# `player_mappings`, etc. in the same write).
|
||||||
|
#
|
||||||
|
# Backend is stopped first to keep the reconciler from
|
||||||
|
# racing the recycle (mid-stream adoption / restart). The
|
||||||
|
# subsequent `Bring up the stack` step restarts it.
|
||||||
|
set -u
|
||||||
|
new_sha=$(docker image inspect galaxy-engine:dev --format '{{.Id}}')
|
||||||
|
echo "fresh galaxy-engine:dev = $new_sha"
|
||||||
|
|
||||||
|
drift=()
|
||||||
|
for c in $(docker ps --filter "name=galaxy-game-" --format '{{.Names}}'); do
|
||||||
|
cur=$(docker inspect "$c" --format '{{.Image}}')
|
||||||
|
if [ "$cur" != "$new_sha" ]; then
|
||||||
|
drift+=("${c#galaxy-game-}")
|
||||||
|
echo " drift: $c was on $cur"
|
||||||
|
else
|
||||||
|
echo " match: $c"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#drift[@]} -eq 0 ]; then
|
||||||
|
echo "no drift detected — recycle skipped"
|
||||||
|
else
|
||||||
|
docker stop -t 30 galaxy-dev-backend >/dev/null 2>&1 || true
|
||||||
|
state_root="$HOME/.galaxy-dev/game-state"
|
||||||
|
for gid in "${drift[@]}"; do
|
||||||
|
echo "recycling $gid"
|
||||||
|
docker rm -f "galaxy-game-$gid" >/dev/null 2>&1 || true
|
||||||
|
# Wipe the per-game state dir as root inside a throwaway
|
||||||
|
# container so we can remove files left behind by the
|
||||||
|
# engine container even when its uid differs from the
|
||||||
|
# runner's.
|
||||||
|
docker run --rm -v "$state_root:/state" alpine \
|
||||||
|
sh -c "rm -rf -- /state/$gid"
|
||||||
|
done
|
||||||
|
ids_csv=$(printf "'%s'," "${drift[@]}")
|
||||||
|
ids_csv=${ids_csv%,}
|
||||||
|
docker exec galaxy-dev-postgres psql -v ON_ERROR_STOP=1 \
|
||||||
|
-U galaxy -d galaxy_backend \
|
||||||
|
-c "DELETE FROM backend.games WHERE game_id IN (${ids_csv});"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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 engine containers spawned by backend with
|
||||||
|
# the same label are left intact when their image SHA still
|
||||||
|
# matches the freshly-built `galaxy-engine:dev` (handled by
|
||||||
|
# the preceding `Recycle engine containers on image drift`
|
||||||
|
# step); the reconciler reattaches them on backend 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
|
- name: Bring up the stack
|
||||||
working-directory: tools/dev-deploy
|
working-directory: tools/dev-deploy
|
||||||
|
env:
|
||||||
|
# Recipient regex Mailpit auto-relays to the owner's Gmail.
|
||||||
|
# Unset/empty → the compose default (non-routable) keeps the
|
||||||
|
# stack capture-only.
|
||||||
|
GALAXY_DEV_MAIL_RELAY_MATCH: ${{ vars.GALAXY_DEV_MAIL_RELAY_MATCH }}
|
||||||
|
# Grafana admin password; unset/empty -> compose default 'admin'.
|
||||||
|
GALAXY_DEV_GRAFANA_ADMIN_PASSWORD: ${{ secrets.GALAXY_DEV_GRAFANA_ADMIN_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||||
# is empty at the workflow-evaluation stage.
|
# is empty at the workflow-evaluation stage.
|
||||||
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||||
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||||
|
# Seed the monitoring config to a stable, reboot-surviving host
|
||||||
|
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
|
||||||
|
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
|
||||||
|
rm -rf "$GALAXY_DEV_MONITORING_DIR"
|
||||||
|
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
|
||||||
|
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
|
||||||
docker compose up -d --wait --remove-orphans
|
docker compose up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Probe the stack
|
- name: Probe the stack
|
||||||
@@ -113,9 +284,12 @@ jobs:
|
|||||||
# `tls internal`) terminates and forwards into the edge
|
# `tls internal`) terminates and forwards into the edge
|
||||||
# network. We accept the host's internal CA via -k because
|
# network. We accept the host's internal CA via -k because
|
||||||
# the runner image has no reason to trust it.
|
# the runner image has no reason to trust it.
|
||||||
curl -sk --max-time 10 https://api.galaxy.lan/healthz \
|
curl -sk --max-time 10 https://galaxy.lan/healthz \
|
||||||
| tee /tmp/healthz
|
| tee /tmp/healthz
|
||||||
test -s /tmp/healthz
|
test -s /tmp/healthz
|
||||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||||
https://www.galaxy.lan/ | tee /tmp/www_status
|
https://galaxy.lan/ | tee /tmp/site_status
|
||||||
grep -qE '^(200|304)$' /tmp/www_status
|
grep -qE '^(200|304)$' /tmp/site_status
|
||||||
|
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||||
|
https://galaxy.lan/game/ | tee /tmp/game_status
|
||||||
|
grep -qE '^(200|304)$' /tmp/game_status
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Tests · FBS codegen
|
||||||
|
|
||||||
|
# Guards that the committed FlatBuffers bindings (Go under
|
||||||
|
# pkg/schema/fbs/<schema>/ and TS under ui/frontend/src/proto/galaxy/fbs/)
|
||||||
|
# are exactly what the pinned flatc produces from the .fbs schemas.
|
||||||
|
# Catches both "changed a schema but forgot to regenerate" and
|
||||||
|
# "regenerated with the wrong flatc version" (e.g. a distro's older
|
||||||
|
# flatbuffers-compiler), which silently churns output and can flip
|
||||||
|
# nullable-scalar wire defaults. Path-filtered so it only runs when the
|
||||||
|
# schemas, the generated trees, the fbs Makefiles, or this workflow change.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'pkg/schema/fbs/**'
|
||||||
|
- 'ui/frontend/src/proto/galaxy/fbs/**'
|
||||||
|
- 'ui/Makefile'
|
||||||
|
- '.gitea/workflows/fbs-codegen.yaml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'pkg/schema/fbs/**'
|
||||||
|
- 'ui/frontend/src/proto/galaxy/fbs/**'
|
||||||
|
- 'ui/Makefile'
|
||||||
|
- '.gitea/workflows/fbs-codegen.yaml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: fbs-codegen-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLATC_VERSION: 25.9.23
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
codegen:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Cache flatc
|
||||||
|
id: cache-flatc
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/flatc-bin
|
||||||
|
key: flatc-${{ env.FLATC_VERSION }}-linux-g++13
|
||||||
|
|
||||||
|
- name: Install pinned flatc
|
||||||
|
if: steps.cache-flatc.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/flatc-bin"
|
||||||
|
curl -sSL -o /tmp/flatc.zip \
|
||||||
|
"https://github.com/google/flatbuffers/releases/download/v${FLATC_VERSION}/Linux.flatc.binary.g++-13.zip"
|
||||||
|
python3 -m zipfile -e /tmp/flatc.zip "${{ runner.temp }}/flatc-bin"
|
||||||
|
chmod +x "${{ runner.temp }}/flatc-bin/flatc"
|
||||||
|
|
||||||
|
- name: Add flatc to PATH
|
||||||
|
run: echo "${{ runner.temp }}/flatc-bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Verify flatc version
|
||||||
|
run: flatc --version
|
||||||
|
|
||||||
|
- name: Regenerate Go + TS bindings
|
||||||
|
run: |
|
||||||
|
make -C pkg/schema/fbs fbs-go
|
||||||
|
make -C ui fbs-ts
|
||||||
|
|
||||||
|
- name: Assert no drift
|
||||||
|
run: |
|
||||||
|
if ! git diff --exit-code || [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "::error::Committed FlatBuffers bindings differ from a flatc ${FLATC_VERSION} regeneration."
|
||||||
|
echo "Run 'make -C pkg/schema/fbs fbs-go' and 'make -C ui fbs-ts' with flatc ${FLATC_VERSION} and commit the result."
|
||||||
|
git status --porcelain
|
||||||
|
git --no-pager diff
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -46,13 +46,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
# `cache: true` here pushes/pulls archives through the Gitea
|
cache: true
|
||||||
# Actions cache service at 192.168.0.222:43513. That endpoint
|
|
||||||
# currently does not answer, and the action wastes minutes
|
|
||||||
# per run on reserveCache retries. In host-mode the real
|
|
||||||
# caches live in $HOME (~/go/pkg/mod, ~/.cache/go-build) and
|
|
||||||
# persist between jobs without any actions/cache plumbing.
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Run Go tests
|
- name: Run Go tests
|
||||||
# client/ is the deprecated Fyne client; excluded from CI per
|
# client/ is the deprecated Fyne client; excluded from CI per
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
# See go-unit.yaml for why `cache: true` is disabled.
|
cache: true
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Run integration suite
|
- name: Run integration suite
|
||||||
# `make integration` precleans leftover docker-compose state and
|
# `make integration` precleans leftover docker-compose state and
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ on:
|
|||||||
- 'game/**'
|
- 'game/**'
|
||||||
- 'pkg/**'
|
- 'pkg/**'
|
||||||
- 'ui/**'
|
- 'ui/**'
|
||||||
|
- 'site/**'
|
||||||
- 'go.work'
|
- 'go.work'
|
||||||
- 'go.work.sum'
|
- 'go.work.sum'
|
||||||
- '.gitea/workflows/prod-build.yaml'
|
- '.gitea/workflows/prod-build.yaml'
|
||||||
@@ -37,19 +38,24 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
# See go-unit.yaml for why `cache: true` is disabled.
|
cache: true
|
||||||
cache: false
|
|
||||||
|
|
||||||
- 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
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
# See dev-deploy.yaml for why `cache: pnpm` is disabled.
|
cache: pnpm
|
||||||
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Resolve image tag
|
- name: Resolve image tag
|
||||||
id: tag
|
id: tag
|
||||||
@@ -82,10 +88,17 @@ jobs:
|
|||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build core.wasm
|
||||||
|
uses: ./.gitea/actions/build-wasm
|
||||||
|
|
||||||
- name: Build UI bundle
|
- name: Build UI bundle
|
||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
env:
|
env:
|
||||||
VITE_GATEWAY_BASE_URL: https://api.galaxy.com
|
# Single-origin deployment: an empty base URL means the
|
||||||
|
# gateway shares the document origin (REST at /api, Connect at
|
||||||
|
# /rpc). The game UI is served under the /game/ base path.
|
||||||
|
VITE_GATEWAY_BASE_URL: ""
|
||||||
|
BASE_PATH: /game
|
||||||
run: |
|
run: |
|
||||||
# Production response-signing public key is not in the repo
|
# Production response-signing public key is not in the repo
|
||||||
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
||||||
@@ -96,6 +109,14 @@ jobs:
|
|||||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
|
- name: Install site dependencies
|
||||||
|
working-directory: site
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project site
|
||||||
|
working-directory: site
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
- name: Save images as artifact bundles
|
- name: Save images as artifact bundles
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
@@ -107,6 +128,8 @@ jobs:
|
|||||||
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||||
tar -C ui/frontend -czf \
|
tar -C ui/frontend -czf \
|
||||||
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
||||||
|
tar -C site/.vitepress -czf \
|
||||||
|
"artifacts/site-dist-${{ steps.tag.outputs.tag }}.tar.gz" dist
|
||||||
|
|
||||||
- name: Upload images
|
- name: Upload images
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Build · Site
|
||||||
|
|
||||||
|
# Builds the VitePress project site so a broken site change fails its PR.
|
||||||
|
# The dev-deploy / prod-build workflows build and ship the site
|
||||||
|
# separately; this is the fast PR gate. No `!**/*.md` exclusion — the
|
||||||
|
# site is Markdown, so content changes must be exercised too.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'site/**'
|
||||||
|
- '.gitea/workflows/site-build.yaml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'site/**'
|
||||||
|
- '.gitea/workflows/site-build.yaml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.0.7
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: site/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install site dependencies
|
||||||
|
working-directory: site
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project site
|
||||||
|
working-directory: site
|
||||||
|
run: pnpm build
|
||||||
@@ -16,6 +16,18 @@ on:
|
|||||||
- '.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
|
||||||
@@ -32,21 +44,32 @@ jobs:
|
|||||||
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
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
# `cache: pnpm` pushes the pnpm store through the Gitea
|
cache: pnpm
|
||||||
# Actions cache service which is currently unreachable
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
# (192.168.0.222:43513 ETIMEDOUT). In host-mode the real
|
|
||||||
# store lives in ~/.local/share/pnpm and persists between
|
|
||||||
# jobs without any action plumbing.
|
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
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
|
# `--with-deps` would shell out to `sudo apt-get install` for
|
||||||
# the system .so libraries, which the host-mode runner cannot
|
# the system .so libraries, which the host-mode runner cannot
|
||||||
@@ -62,10 +85,25 @@ jobs:
|
|||||||
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
|
||||||
@@ -81,3 +119,16 @@ jobs:
|
|||||||
name: playwright-traces
|
name: playwright-traces
|
||||||
path: ui/frontend/test-results/
|
path: ui/frontend/test-results/
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Remove root-owned build artifacts
|
||||||
|
if: always()
|
||||||
|
# In host-mode the job runs as root, so vite (test:pwa),
|
||||||
|
# svelte-kit and Playwright write these outputs root-owned into
|
||||||
|
# the shared host workspace. The act_runner (non-root) then
|
||||||
|
# cannot remove them at teardown ("unlinkat ... permission
|
||||||
|
# denied"), which spuriously fails this or a sibling job that
|
||||||
|
# inherits the dirty workspace (observed on go-unit). Clean them
|
||||||
|
# here while the step still has root, after the uploads above.
|
||||||
|
run: |
|
||||||
|
rm -rf ui/frontend/build ui/frontend/.svelte-kit \
|
||||||
|
ui/frontend/test-results ui/frontend/playwright-report
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ This repository hosts the Galaxy Game project.
|
|||||||
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
|
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
|
||||||
the changed paragraphs only, do not re-translate the whole file).
|
the changed paragraphs only, do not re-translate the whole file).
|
||||||
A full re-translation only happens on explicit owner request.
|
A full re-translation only happens on explicit owner request.
|
||||||
|
- `site/ru/rules.md` — the player-facing game rules (ported from the
|
||||||
|
former `game/rules.txt`). **Russian is authoritative here**, inverting
|
||||||
|
the usual English-first rule: the game's rules and lore are
|
||||||
|
Russian-native, so `site/ru/rules.md` leads and the English
|
||||||
|
`site/rules.md` is its mirror. Mirror point edits the same way as
|
||||||
|
`docs/FUNCTIONAL.md`, but RU → EN.
|
||||||
- `docs/TESTING.md` — testing layers (unit / integration), the
|
- `docs/TESTING.md` — testing layers (unit / integration), the
|
||||||
integration runbook, and the principles every test must follow
|
integration runbook, and the principles every test must follow
|
||||||
(no-op observability for testcontainers, `t.Fatal` on
|
(no-op observability for testcontainers, `t.Fatal` on
|
||||||
@@ -44,9 +50,10 @@ Branches:
|
|||||||
is manual through `deploy-prod.yaml`.
|
is manual through `deploy-prod.yaml`.
|
||||||
- `development` — long-lived dev integration branch. Every merge into
|
- `development` — long-lived dev integration branch. Every merge into
|
||||||
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
||||||
(reachable at `https://www.galaxy.lan` / `https://api.galaxy.lan`).
|
(single origin `https://galaxy.lan`: site at `/`, game at `/game/`,
|
||||||
|
gateway REST at `/api`).
|
||||||
- `feature/*` — short-lived branches off `development`. Merged back
|
- `feature/*` — short-lived branches off `development`. Merged back
|
||||||
via PR; only then do they reach the dev environment.
|
via PR; only then do they reach the dev environment automatically.
|
||||||
|
|
||||||
Workflows in `.gitea/workflows/`:
|
Workflows in `.gitea/workflows/`:
|
||||||
|
|
||||||
@@ -55,10 +62,24 @@ Workflows in `.gitea/workflows/`:
|
|||||||
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||||
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||||
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
||||||
| `dev-deploy.yaml` | push to `development` | Build images + (re)deploy to `tools/dev-deploy/`. |
|
| `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. |
|
| `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). |
|
| `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`)
|
||||||
@@ -72,10 +93,6 @@ short version:
|
|||||||
4. Only after every workflow that fired is `success` may the stage be
|
4. Only after every workflow that fired is `success` may the stage be
|
||||||
marked done in the corresponding `PLAN.md`.
|
marked done in the corresponding `PLAN.md`.
|
||||||
|
|
||||||
`tools/local-ci/` is now an opt-in fallback for testing workflow
|
|
||||||
changes without `gitea.lan` (offline iterations, runner-isolation
|
|
||||||
debugging). It is no longer required for the per-stage gate.
|
|
||||||
|
|
||||||
## Decisions during stage implementation
|
## Decisions during stage implementation
|
||||||
|
|
||||||
Stages from `PLAN.md` produce decisions. Those decisions never live in a
|
Stages from `PLAN.md` produce decisions. Those decisions never live in a
|
||||||
@@ -102,18 +119,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
|
||||||
|
|
||||||
|
|||||||
+24
-7
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
|
|||||||
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
||||||
| `/api/v1/public/*` | none | Registration, code confirmation |
|
| `/api/v1/public/*` | none | Registration, code confirmation |
|
||||||
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
|
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
|
||||||
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
|
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) |
|
||||||
|
| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
|
||||||
| `/healthz` | none | Liveness probe |
|
| `/healthz` | none | Liveness probe |
|
||||||
| `/readyz` | none | Readiness probe |
|
| `/readyz` | none | Readiness probe |
|
||||||
|
|
||||||
|
The `/_gm` operator console is the human-facing surface for the admin
|
||||||
|
operations; it reuses the admin Basic Auth verifier, renders with
|
||||||
|
`html/template`, and is the only admin surface exposed publicly (through
|
||||||
|
the gateway). See `docs/admin-console.md`.
|
||||||
|
|
||||||
The full contract is documented in `openapi.yaml` and validated at
|
The full contract is documented in `openapi.yaml` and validated at
|
||||||
runtime by the contract tests under `internal/server/`.
|
runtime by the contract tests under `internal/server/`.
|
||||||
|
|
||||||
@@ -45,6 +51,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
|
||||||
@@ -99,6 +106,7 @@ fast.
|
|||||||
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
|
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
|
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
|
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
|
||||||
|
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
|
||||||
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
|
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
|
||||||
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
||||||
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
||||||
@@ -128,9 +136,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 +161,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.
|
||||||
@@ -249,11 +264,13 @@ introduce its own request/response types.
|
|||||||
|
|
||||||
Endpoints used:
|
Endpoints used:
|
||||||
|
|
||||||
- `POST /api/v1/admin/init`
|
- `POST /api/v1/admin/init` — the runtime worker passes the canonical
|
||||||
|
`game_id` (the same UUID that names the engine container and the
|
||||||
|
host bind-mount directory) in the request body so the engine's
|
||||||
|
`state.json` shares identity with the backend's `games.game_id`.
|
||||||
- `GET /api/v1/admin/status`
|
- `GET /api/v1/admin/status`
|
||||||
- `PUT /api/v1/admin/turn`
|
- `PUT /api/v1/admin/turn`
|
||||||
- `POST /api/v1/admin/race/banish`
|
- `POST /api/v1/admin/race/banish`
|
||||||
- `PUT /api/v1/command`
|
|
||||||
- `PUT /api/v1/order`
|
- `PUT /api/v1/order`
|
||||||
- `GET /api/v1/report`
|
- `GET /api/v1/report`
|
||||||
- `GET /healthz`
|
- `GET /healthz`
|
||||||
|
|||||||
+361
-19
@@ -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
|
||||||
@@ -21,10 +22,13 @@ import (
|
|||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
"galaxy/backend/internal/admin"
|
"galaxy/backend/internal/admin"
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
"galaxy/backend/internal/app"
|
"galaxy/backend/internal/app"
|
||||||
"galaxy/backend/internal/auth"
|
"galaxy/backend/internal/auth"
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
"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"
|
||||||
@@ -33,6 +37,7 @@ import (
|
|||||||
"galaxy/backend/internal/mail"
|
"galaxy/backend/internal/mail"
|
||||||
"galaxy/backend/internal/metricsapi"
|
"galaxy/backend/internal/metricsapi"
|
||||||
"galaxy/backend/internal/notification"
|
"galaxy/backend/internal/notification"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
backendpostgres "galaxy/backend/internal/postgres"
|
backendpostgres "galaxy/backend/internal/postgres"
|
||||||
"galaxy/backend/push"
|
"galaxy/backend/push"
|
||||||
"galaxy/backend/internal/runtime"
|
"galaxy/backend/internal/runtime"
|
||||||
@@ -131,6 +136,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 +203,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,
|
||||||
@@ -266,29 +273,18 @@ func run(ctx context.Context) (err error) {
|
|||||||
)
|
)
|
||||||
runtimeGateway.svc = runtimeSvc
|
runtimeGateway.svc = runtimeSvc
|
||||||
|
|
||||||
// Run a single reconciliation pass before the dev-sandbox
|
// Run a single reconciliation pass at startup so any runtime row
|
||||||
// bootstrap so any runtime row pointing at a vanished engine
|
// pointing at a vanished engine container (a host reboot wiped
|
||||||
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
|
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
|
||||||
// `tools/local-dev`'s `prune-broken-engines` target reaped the
|
// `prune-broken-engines` target reaped the husk) is cascaded
|
||||||
// husk) is already cascaded through `markRemoved` → lobby
|
// through `markRemoved` → lobby `cancelled` before the server
|
||||||
// `cancelled` by the time the bootstrap walks the sandbox list.
|
// starts serving requests. Failures are
|
||||||
// Without this pre-tick the bootstrap would reuse the
|
|
||||||
// soon-to-be-cancelled game and force the developer into a
|
|
||||||
// second `make up` cycle to land a healthy sandbox. Failures are
|
|
||||||
// non-fatal: the periodic ticker started later catches up, and
|
// non-fatal: the periodic ticker started later catches up, and
|
||||||
// the worst case degrades to the legacy two-cycle recovery.
|
// the worst case degrades to the legacy two-cycle recovery.
|
||||||
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
||||||
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
|
|
||||||
Users: userSvc,
|
|
||||||
Lobby: lobbySvc,
|
|
||||||
EngineVersions: engineVersionSvc,
|
|
||||||
}, cfg.DevSandbox, logger); err != nil {
|
|
||||||
return fmt.Errorf("dev sandbox bootstrap: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
notifStore := notification.NewStore(db)
|
notifStore := notification.NewStore(db)
|
||||||
notifSvc := notification.NewService(notification.Deps{
|
notifSvc := notification.NewService(notification.Deps{
|
||||||
Store: notifStore,
|
Store: notifStore,
|
||||||
@@ -301,6 +297,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,14 +340,42 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var consoleCSRF *adminconsole.CSRF
|
||||||
|
if cfg.AdminConsole.CSRFKey != "" {
|
||||||
|
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
|
||||||
|
} else {
|
||||||
|
consoleCSRF, err = adminconsole.NewRandomCSRF()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init admin console CSRF: %w", err)
|
||||||
|
}
|
||||||
|
logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
|
||||||
|
zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
|
||||||
|
}
|
||||||
|
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
|
||||||
|
CSRF: consoleCSRF,
|
||||||
|
Monitor: opsstatus.NewStore(db),
|
||||||
|
Ready: ready,
|
||||||
|
Users: userSvc,
|
||||||
|
Games: lobbySvc,
|
||||||
|
Runtime: runtimeSvc,
|
||||||
|
EngineVersions: engineVersionSvc,
|
||||||
|
Operators: adminSvc,
|
||||||
|
Mail: mailSvc,
|
||||||
|
Notifications: notifSvc,
|
||||||
|
Diplomail: diplomailSvc,
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
|
||||||
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Telemetry: telemetryRT,
|
Telemetry: telemetryRT,
|
||||||
@@ -356,9 +399,12 @@ 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,
|
||||||
|
AdminConsole: adminConsoleHandlers,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build backend router: %w", err)
|
return fmt.Errorf("build backend router: %w", err)
|
||||||
@@ -374,7 +420,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)
|
||||||
}
|
}
|
||||||
@@ -456,6 +502,17 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
|
|||||||
return snap.MaxRegisteredRaceNames, nil
|
return snap.MaxRegisteredRaceNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||||
|
if a == nil || a.svc == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return snap.IsPaid, nil
|
||||||
|
}
|
||||||
|
|
||||||
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
|
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
|
||||||
// delegating to `*runtime.Service`. The svc pointer is patched after
|
// delegating to `*runtime.Service`. The svc pointer is patched after
|
||||||
// the services are constructed — runtime depends on lobby
|
// the services are constructed — runtime depends on lobby
|
||||||
@@ -579,3 +636,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,128 @@
|
|||||||
|
# Operator console (`/_gm`)
|
||||||
|
|
||||||
|
The operator console is a server-rendered web UI for the platform's admin
|
||||||
|
operations. It is the human-facing counterpart to the JSON admin API under
|
||||||
|
`/api/v1/admin/*`: both call the same service layer, but the console renders
|
||||||
|
HTML pages an operator drives in a browser, while the JSON API stays internal
|
||||||
|
to the deployment for programmatic and test use.
|
||||||
|
|
||||||
|
## Design choices
|
||||||
|
|
||||||
|
- **Server-rendered, no client framework.** Pages are rendered with the
|
||||||
|
standard library's `html/template`. Navigation is by ordinary links and
|
||||||
|
query parameters; every state change is an HTML form `POST` answered with a
|
||||||
|
Post/Redirect/Get redirect. There is no build step, no JavaScript framework,
|
||||||
|
and no separate asset pipeline — a single embedded stylesheet under
|
||||||
|
`/_gm/assets/`.
|
||||||
|
- **Reuses the existing admin auth.** The console mounts behind the same
|
||||||
|
`basicauth.Middleware(admin.Service)` verifier that gates `/api/v1/admin/*`,
|
||||||
|
so there is one credential store (`admin_accounts`, bcrypt-12) and no second
|
||||||
|
secret to manage.
|
||||||
|
- **Lives in the backend.** The backend owns the admin domain and the data, so
|
||||||
|
rendering there lets the console call the service layer directly. The gateway
|
||||||
|
stays a thin proxy.
|
||||||
|
|
||||||
|
## Request path
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ── /_gm/* ──► edge Caddy ──► gateway (public listener)
|
||||||
|
gateway: anti-abuse `admin` class (per-IP rate limit, body + method limits)
|
||||||
|
└─► reverse proxy ──► backend /_gm/*
|
||||||
|
backend: basicauth.Middleware(admin.Service)
|
||||||
|
└─► CSRF guard (state-changing methods)
|
||||||
|
└─► console handler ──► admin service layer ──► html/template
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway preserves the inbound `Host` and relays the backend's `401` Basic
|
||||||
|
Auth challenge unchanged, so the browser shows its native credential dialog.
|
||||||
|
The gateway adds only the edge anti-abuse layer; authentication and every state
|
||||||
|
change are enforced by the backend. The gateway answers `502` when the backend
|
||||||
|
is unreachable. See the gateway README "Operator Console Proxy" section for the
|
||||||
|
`admin` route-class env vars.
|
||||||
|
|
||||||
|
## Components (package `internal/adminconsole`)
|
||||||
|
|
||||||
|
The package is framework-agnostic (no gin) so it unit-tests in isolation:
|
||||||
|
|
||||||
|
- `Renderer` — parses the embedded layout plus one content page per route and
|
||||||
|
renders a named page wrapped in the shared layout. Rendering goes through an
|
||||||
|
intermediate buffer, so a template failure never emits a partial document.
|
||||||
|
- `CSRF` — issues and verifies the stateless anti-CSRF token: HMAC-SHA256 over
|
||||||
|
the authenticated username, keyed by `BACKEND_ADMIN_CONSOLE_CSRF_KEY`. When
|
||||||
|
the key is unset a per-process random key is used (secure, but forms reset on
|
||||||
|
restart and do not validate across replicas — set a shared key for
|
||||||
|
multi-replica deployments).
|
||||||
|
- `Assets` — the embedded stylesheet filesystem served under `/_gm/assets/`.
|
||||||
|
|
||||||
|
The gin glue (route group, Basic Auth, the CSRF guard middleware, the per-page
|
||||||
|
handlers) lives in `internal/server/handlers_admin_console.go` and
|
||||||
|
`internal/server/router.go` (`registerAdminConsoleRoutes`).
|
||||||
|
|
||||||
|
## CSRF protection
|
||||||
|
|
||||||
|
Because the console is sessionless (HTTP Basic Auth, whose credentials the
|
||||||
|
browser replays automatically), state-changing requests are double-guarded:
|
||||||
|
|
||||||
|
1. A stateless per-operator token (`_csrf` form field) that a cross-site page
|
||||||
|
cannot read or forge.
|
||||||
|
2. A same-origin `Origin`/`Referer` check (when the browser sends one), which
|
||||||
|
relies on the gateway preserving the inbound `Host`.
|
||||||
|
|
||||||
|
Safe methods (`GET`/`HEAD`/`OPTIONS`) pass without a token.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
The dashboard is the console landing page. It surfaces backend-visible
|
||||||
|
operational state — service health, game-runtime status, and queue depths —
|
||||||
|
read through the existing service and persistence layers. Richer cross-service
|
||||||
|
metrics are out of scope for the console itself: the `/metrics` Prometheus
|
||||||
|
exporters on `backend` and `gateway` are wired and enabled in the dev
|
||||||
|
deployment so a future Prometheus + Grafana stack can scrape them without code
|
||||||
|
changes.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
| --------------------------------- | -------- | -------------------------------------------------------------- |
|
||||||
|
| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. |
|
||||||
|
| `/_gm/assets/*` | GET | Embedded stylesheet. |
|
||||||
|
| `/_gm/users` | GET | Paginated account list. |
|
||||||
|
| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. |
|
||||||
|
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
|
||||||
|
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
|
||||||
|
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
|
||||||
|
| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
|
||||||
|
| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
|
||||||
|
| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
|
||||||
|
| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
|
||||||
|
| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
|
||||||
|
| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
|
||||||
|
| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
|
||||||
|
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
|
||||||
|
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
|
||||||
|
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
|
||||||
|
| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. |
|
||||||
|
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
||||||
|
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
||||||
|
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
|
||||||
|
| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
|
||||||
|
| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
|
||||||
|
| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
|
||||||
|
| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
|
||||||
|
| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
|
||||||
|
|
||||||
|
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
|
||||||
|
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
||||||
|
mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
|
||||||
|
so a static action segment never collides with a path parameter in the gin
|
||||||
|
router. Unblocking a user is not yet available because the JSON admin API
|
||||||
|
exposes no remove-sanction endpoint.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Where | Notes |
|
||||||
|
| --------------------------------- | ------- | ------------------------------------------------------------ |
|
||||||
|
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | backend | CSRF token key; unset → per-process random key. |
|
||||||
|
| `BACKEND_ADMIN_BOOTSTRAP_USER` | backend | Bootstrap operator account (shared with the JSON admin API). |
|
||||||
|
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`| backend | Bootstrap operator password. |
|
||||||
|
| `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_*` | gateway | `admin` route-class rate-limit and body budgets. |
|
||||||
@@ -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.
|
||||||
@@ -234,8 +234,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
Workers->>Docker: pull / create / start engine container
|
Workers->>Docker: pull / create / start engine container
|
||||||
Docker-->>Workers: container id
|
Docker-->>Workers: container id
|
||||||
Workers->>Engine: POST /api/v1/admin/init
|
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
|
||||||
Engine-->>Workers: ok / error
|
Engine-->>Workers: StateResponse{id == gameId} / error
|
||||||
Workers->>Runtime: write runtime_records (running or start_failed)
|
Workers->>Runtime: write runtime_records (running or start_failed)
|
||||||
Workers->>Lobby: OnRuntimeJobResult
|
Workers->>Lobby: OnRuntimeJobResult
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ boot).
|
|||||||
polls the engine `/healthz` until the listener is bound (Docker
|
polls the engine `/healthz` until the listener is bound (Docker
|
||||||
marks a container running as soon as the entrypoint starts; the
|
marks a container running as soon as the entrypoint starts; the
|
||||||
Go binary inside takes a moment to bind its TCP port). Only after
|
Go binary inside takes a moment to bind its TCP port). Only after
|
||||||
`/healthz` succeeds does the worker call `/admin/init`.
|
`/healthz` succeeds does the worker call `/admin/init`, passing the
|
||||||
|
same `game_id` the backend uses to mount the engine's storage
|
||||||
|
directory; the engine echoes it back in `StateResponse.id`. The
|
||||||
|
engine rejects a mismatched gameId with `409 Conflict`.
|
||||||
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
|
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
|
||||||
`pkg/cronutil` schedule per running game; each tick invokes the
|
`pkg/cronutil` schedule per running game; each tick invokes the
|
||||||
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
galaxy/model v0.0.0
|
galaxy/model v0.0.0
|
||||||
galaxy/postgres v0.0.0
|
galaxy/postgres v0.0.0
|
||||||
galaxy/util v0.0.0-00010101000000-000000000000
|
galaxy/util v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/abadojack/whatlanggo v1.0.1
|
||||||
github.com/disciplinedware/go-confusables v0.1.1
|
github.com/disciplinedware/go-confusables v0.1.1
|
||||||
github.com/getkin/kin-openapi v0.135.0
|
github.com/getkin/kin-openapi v0.135.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/* Admin console stylesheet. Deliberately small and dependency-free: the
|
||||||
|
console is an internal operator tool, not a public surface. */
|
||||||
|
:root {
|
||||||
|
--bg: #11151c;
|
||||||
|
--panel: #1b2230;
|
||||||
|
--panel-hi: #232c3d;
|
||||||
|
--ink: #e6ebf2;
|
||||||
|
--ink-dim: #9aa7ba;
|
||||||
|
--line: #2c3850;
|
||||||
|
--accent: #5aa9ff;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--ok: #4ecb8d;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
|
||||||
|
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; }
|
||||||
|
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
|
||||||
|
.topbar .who { color: var(--ink-dim); }
|
||||||
|
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
||||||
|
.lede { color: var(--ink-dim); margin-top: 0; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
|
||||||
|
.card {
|
||||||
|
display: block;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.card:hover { background: var(--panel-hi); text-decoration: none; }
|
||||||
|
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
|
||||||
|
.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.grid .panel { margin-bottom: 0; }
|
||||||
|
.kv { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
||||||
|
.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
|
||||||
|
.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||||
|
.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
|
||||||
|
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
||||||
|
.errors { border-color: var(--danger); }
|
||||||
|
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
|
||||||
|
.ok { color: var(--ok); }
|
||||||
|
.bad { color: var(--danger); }
|
||||||
|
|
||||||
|
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||||
|
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
||||||
|
.list th { color: var(--ink-dim); font-weight: 600; }
|
||||||
|
.list tr:hover td { background: var(--panel-hi); }
|
||||||
|
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
||||||
|
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
|
||||||
|
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
|
.form input, .form select {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #06121f;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
button.danger { background: var(--danger); color: #1a0606; }
|
||||||
|
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
|
.actions form { margin: 0; }
|
||||||
|
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSRF issues and verifies the stateless anti-CSRF token used by the admin
|
||||||
|
// console. The token is an HMAC-SHA256 over the authenticated operator's
|
||||||
|
// username keyed by a process secret, so a cross-site request cannot forge it
|
||||||
|
// without already being able to read an authenticated page. The console is
|
||||||
|
// sessionless (HTTP Basic Auth), which makes a stateless, per-operator token
|
||||||
|
// the natural fit.
|
||||||
|
type CSRF struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCSRF returns a CSRF signer keyed by key. A shared key across backend
|
||||||
|
// replicas lets a form rendered by one replica validate on another; callers
|
||||||
|
// that pass a per-process random key (see NewRandomCSRF) accept that forms do
|
||||||
|
// not survive a restart or span replicas.
|
||||||
|
func NewCSRF(key []byte) *CSRF {
|
||||||
|
return &CSRF{key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRandomCSRF returns a CSRF signer keyed by a fresh 32-byte random secret.
|
||||||
|
// It is the secure default when no shared key is configured.
|
||||||
|
func NewRandomCSRF() (*CSRF, error) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate admin console CSRF key: %w", err)
|
||||||
|
}
|
||||||
|
return &CSRF{key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token returns the anti-CSRF token bound to username.
|
||||||
|
func (c *CSRF) Token(username string) string {
|
||||||
|
mac := hmac.New(sha256.New, c.key)
|
||||||
|
mac.Write([]byte(username))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify reports whether token is the valid anti-CSRF token for username. The
|
||||||
|
// comparison runs in constant time relative to the token bytes.
|
||||||
|
func (c *CSRF) Verify(username, token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expected := c.Token(username)
|
||||||
|
return hmac.Equal([]byte(token), []byte(expected))
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCSRFTokenRoundTrip(t *testing.T) {
|
||||||
|
signer := NewCSRF([]byte("shared-secret"))
|
||||||
|
token := signer.Token("alice")
|
||||||
|
|
||||||
|
if !signer.Verify("alice", token) {
|
||||||
|
t.Fatal("valid token rejected")
|
||||||
|
}
|
||||||
|
if signer.Verify("bob", token) {
|
||||||
|
t.Fatal("token accepted for a different operator")
|
||||||
|
}
|
||||||
|
if signer.Verify("alice", "") {
|
||||||
|
t.Fatal("empty token accepted")
|
||||||
|
}
|
||||||
|
if signer.Verify("alice", token+"x") {
|
||||||
|
t.Fatal("tampered token accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFKeySeparation(t *testing.T) {
|
||||||
|
a := NewCSRF([]byte("key-a"))
|
||||||
|
b := NewCSRF([]byte("key-b"))
|
||||||
|
if a.Token("operator") == b.Token("operator") {
|
||||||
|
t.Fatal("tokens collide across distinct keys")
|
||||||
|
}
|
||||||
|
if b.Verify("operator", a.Token("operator")) {
|
||||||
|
t.Fatal("token minted under one key verified under another")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomCSRFRoundTrip(t *testing.T) {
|
||||||
|
signer, err := NewRandomCSRF()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRandomCSRF: %v", err)
|
||||||
|
}
|
||||||
|
if !signer.Verify("operator", signer.Token("operator")) {
|
||||||
|
t.Fatal("random-key token failed to round-trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// StatusCount pairs a status label with its current row count for the
|
||||||
|
// dashboard's per-status tables. It is the view-layer counterpart of the
|
||||||
|
// data gathered by the ops-status reader; the server handler maps between
|
||||||
|
// them so this package stays free of database concerns.
|
||||||
|
type StatusCount struct {
|
||||||
|
Status string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardData is the view model for the console landing page. MonitorAvailable
|
||||||
|
// is false when no ops-status reader is wired, in which case the monitoring
|
||||||
|
// panels are omitted. Errors carries non-fatal probe failures for display.
|
||||||
|
type DashboardData struct {
|
||||||
|
MonitorAvailable bool
|
||||||
|
BackendReady bool
|
||||||
|
PostgresHealthy bool
|
||||||
|
Runtimes []StatusCount
|
||||||
|
MailDeliveries []StatusCount
|
||||||
|
NotificationRoutes []StatusCount
|
||||||
|
NotificationMalformed int64
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Package adminconsole renders the server-side operator console mounted by the
|
||||||
|
// backend under the `/_gm` route group.
|
||||||
|
//
|
||||||
|
// The console is a multi-page, server-rendered surface built on the standard
|
||||||
|
// library's html/template package: navigation is driven by request path and
|
||||||
|
// query, state changes are submitted with HTML forms and answered with a
|
||||||
|
// Post/Redirect/Get redirect. The package owns three concerns and nothing
|
||||||
|
// transport-specific:
|
||||||
|
//
|
||||||
|
// - Renderer composes the shared layout with one content page per route.
|
||||||
|
// - CSRF issues and verifies the stateless anti-CSRF token embedded in every
|
||||||
|
// state-changing form.
|
||||||
|
// - Assets exposes the embedded stylesheet served under `/_gm/assets/`.
|
||||||
|
//
|
||||||
|
// The gin glue (route registration, Basic Auth, the CSRF guard middleware, and
|
||||||
|
// the per-page handlers) lives in package server; this package stays free of
|
||||||
|
// the web framework so it can be unit-tested in isolation.
|
||||||
|
package adminconsole
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// GameRow is one line in the games list table.
|
||||||
|
type GameRow struct {
|
||||||
|
GameID string
|
||||||
|
GameName string
|
||||||
|
Visibility string
|
||||||
|
Status string
|
||||||
|
Owner string
|
||||||
|
Players string
|
||||||
|
TurnSchedule string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GamesListData is the view model for the paginated games list.
|
||||||
|
type GamesListData struct {
|
||||||
|
Items []GameRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameDetailData is the view model for a single game, combining the lobby
|
||||||
|
// record with the runtime snapshot and the available actions.
|
||||||
|
type GameDetailData struct {
|
||||||
|
GameID string
|
||||||
|
GameName string
|
||||||
|
Description string
|
||||||
|
Visibility string
|
||||||
|
Status string
|
||||||
|
Owner string
|
||||||
|
MinPlayers int32
|
||||||
|
MaxPlayers int32
|
||||||
|
StartGapHours int32
|
||||||
|
StartGapPlayers int32
|
||||||
|
TurnSchedule string
|
||||||
|
TargetEngineVersion string
|
||||||
|
EnrollmentEndsAt string
|
||||||
|
CreatedAt string
|
||||||
|
StartedAt string
|
||||||
|
FinishedAt string
|
||||||
|
|
||||||
|
HasRuntime bool
|
||||||
|
RuntimeStatus string
|
||||||
|
CurrentEngineVersion string
|
||||||
|
EngineHealth string
|
||||||
|
CurrentTurn int32
|
||||||
|
NextGenerationAt string
|
||||||
|
Paused bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionRow is one line in the engine-version registry table.
|
||||||
|
type EngineVersionRow struct {
|
||||||
|
Version string
|
||||||
|
ImageRef string
|
||||||
|
Enabled bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionsData is the view model for the engine-version registry page.
|
||||||
|
type EngineVersionsData struct {
|
||||||
|
Items []EngineVersionRow
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// MailDeliveryRow is one line in the mail deliveries table.
|
||||||
|
type MailDeliveryRow struct {
|
||||||
|
DeliveryID string
|
||||||
|
Template string
|
||||||
|
Status string
|
||||||
|
Attempts int32
|
||||||
|
NextAttempt string
|
||||||
|
Created string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailDeadLetterRow is one line in the mail dead-letters table.
|
||||||
|
type MailDeadLetterRow struct {
|
||||||
|
DeliveryID string
|
||||||
|
Reason string
|
||||||
|
Archived string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailData is the view model for the mail page: a paginated deliveries list
|
||||||
|
// plus a snapshot of dead-letters.
|
||||||
|
type MailData struct {
|
||||||
|
Deliveries []MailDeliveryRow
|
||||||
|
DeadLetters []MailDeadLetterRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int64
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailAttemptRow is one delivery attempt on the mail detail page.
|
||||||
|
type MailAttemptRow struct {
|
||||||
|
AttemptNo int32
|
||||||
|
Outcome string
|
||||||
|
Started string
|
||||||
|
Finished string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailDeliveryDetail is the view model for a single delivery.
|
||||||
|
type MailDeliveryDetail struct {
|
||||||
|
DeliveryID string
|
||||||
|
Template string
|
||||||
|
Status string
|
||||||
|
Attempts int32
|
||||||
|
NextAttempt string
|
||||||
|
LastError string
|
||||||
|
Created string
|
||||||
|
Sent string
|
||||||
|
DeadLettered string
|
||||||
|
CanResend bool
|
||||||
|
AttemptRows []MailAttemptRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationRow is one line in the notifications table.
|
||||||
|
type NotificationRow struct {
|
||||||
|
NotificationID string
|
||||||
|
Kind string
|
||||||
|
UserID string
|
||||||
|
Created string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationDeadLetterRow is one line in the notification dead-letters table.
|
||||||
|
type NotificationDeadLetterRow struct {
|
||||||
|
NotificationID string
|
||||||
|
RouteID string
|
||||||
|
Reason string
|
||||||
|
Archived string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MalformedRow is one line in the malformed-intents table.
|
||||||
|
type MalformedRow struct {
|
||||||
|
ID string
|
||||||
|
Reason string
|
||||||
|
Received string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsData is the view model for the notifications overview page.
|
||||||
|
type NotificationsData struct {
|
||||||
|
Notifications []NotificationRow
|
||||||
|
DeadLetters []NotificationDeadLetterRow
|
||||||
|
Malformed []MalformedRow
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// MessageData is the view model for the generic message page used to render
|
||||||
|
// not-found, validation, and operation-failure notices. Class selects the CSS
|
||||||
|
// styling (for example "bad" for errors); BackHref, when set, renders a link
|
||||||
|
// back to a relevant page.
|
||||||
|
type MessageData struct {
|
||||||
|
Message string
|
||||||
|
Class string
|
||||||
|
BackHref string
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// OperatorRow is one line in the operators (admin accounts) table.
|
||||||
|
type OperatorRow struct {
|
||||||
|
Username string
|
||||||
|
CreatedAt string
|
||||||
|
LastUsedAt string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorsData is the view model for the operators page.
|
||||||
|
type OperatorsData struct {
|
||||||
|
Items []OperatorRow
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var templatesFS embed.FS
|
||||||
|
|
||||||
|
//go:embed assets
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
// Renderer holds the parsed admin console templates. It composes one template
|
||||||
|
// set per content page, each combining the shared layout (defining the page
|
||||||
|
// chrome and the "layout" entry template) with that page's "content" block, so
|
||||||
|
// rendering a page is a single ExecuteTemplate call against the "layout" name.
|
||||||
|
type Renderer struct {
|
||||||
|
pages map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageData is the view model passed to every admin console page. Title is the
|
||||||
|
// document title; Username is the authenticated operator; CSRFToken is the
|
||||||
|
// per-operator token embedded into state-changing forms; ActiveNav marks the
|
||||||
|
// highlighted navigation entry; Data carries the page-specific payload.
|
||||||
|
type PageData struct {
|
||||||
|
Title string
|
||||||
|
Username string
|
||||||
|
CSRFToken string
|
||||||
|
ActiveNav string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRenderer parses the embedded layout and every content page under
|
||||||
|
// templates/pages, returning a Renderer ready to serve them. It fails when a
|
||||||
|
// template cannot be parsed.
|
||||||
|
func NewRenderer() (*Renderer, error) {
|
||||||
|
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
||||||
|
}
|
||||||
|
if len(pageFiles) == 0 {
|
||||||
|
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := make(map[string]*template.Template, len(pageFiles))
|
||||||
|
for _, file := range pageFiles {
|
||||||
|
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
||||||
|
clone, err := base.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
||||||
|
}
|
||||||
|
pages[name] = clone
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Renderer{pages: pages}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
||||||
|
// embedded at build time, so a parse failure is a programmer error rather than
|
||||||
|
// a runtime condition.
|
||||||
|
func MustNewRenderer() *Renderer {
|
||||||
|
renderer, err := NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render writes the named page, wrapped in the shared layout, to w using data.
|
||||||
|
// It returns an error when page is unknown or template execution fails; the
|
||||||
|
// page is rendered into an intermediate buffer first so a mid-render failure
|
||||||
|
// never emits a partial document to w.
|
||||||
|
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
||||||
|
tmpl, ok := r.pages[page]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("admin console: unknown page %q", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||||
|
return fmt.Errorf("render admin console page %q: %w", page, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := buf.WriteTo(w)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets returns the embedded static asset tree rooted at the assets directory,
|
||||||
|
// suitable for serving under `/_gm/assets/`.
|
||||||
|
func Assets() (fs.FS, error) {
|
||||||
|
return fs.Sub(assetsFS, "assets")
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRendererRendersDashboard(t *testing.T) {
|
||||||
|
renderer, err := NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = renderer.Render(&buf, "dashboard", PageData{
|
||||||
|
Title: "Dashboard",
|
||||||
|
Username: "ops-bob",
|
||||||
|
ActiveNav: "dashboard",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
"Dashboard",
|
||||||
|
"ops-bob",
|
||||||
|
`href="/_gm/users"`,
|
||||||
|
"/_gm/assets/console.css",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRendererUnknownPage(t *testing.T) {
|
||||||
|
renderer := MustNewRenderer()
|
||||||
|
if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil {
|
||||||
|
t.Fatal("expected an error rendering an unknown page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRendererEscapesUsername(t *testing.T) {
|
||||||
|
renderer := MustNewRenderer()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := renderer.Render(&buf, "dashboard", PageData{Username: "<script>evil</script>"}); err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), "<script>evil</script>") {
|
||||||
|
t.Error("username was not HTML-escaped in the rendered page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetsContainsStylesheet(t *testing.T) {
|
||||||
|
fsys, err := Assets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Assets: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
||||||
|
t.Fatalf("console.css missing from embedded assets: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "layout" -}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{{.Title}} · Galaxy GM</title>
|
||||||
|
<link rel="stylesheet" href="/_gm/assets/console.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="brand">Galaxy · GM</span>
|
||||||
|
<nav class="mainnav">
|
||||||
|
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||||
|
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||||
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
|
<a href="/_gm/operators"{{if eq .ActiveNav "operators"}} class="active"{{end}}>Operators</a>
|
||||||
|
<a href="/_gm/mail"{{if eq .ActiveNav "mail"}} class="active"{{end}}>Mail</a>
|
||||||
|
<a href="/_gm/grafana/" target="_blank" rel="noopener">Grafana</a>
|
||||||
|
<a href="/_gm/mailpit/" target="_blank" rel="noopener">Mailpit</a>
|
||||||
|
</nav>
|
||||||
|
<span class="who">{{.Username}}</span>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Broadcast</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Admin broadcast</h2>
|
||||||
|
<form method="post" action="/_gm/broadcast" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Scope
|
||||||
|
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
|
||||||
|
<label>Recipients
|
||||||
|
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Subject <input type="text" name="subject"></label>
|
||||||
|
<label>Body <input type="text" name="body" required></label>
|
||||||
|
<button type="submit">Send broadcast</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Health</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
|
||||||
|
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{if .MonitorAvailable}}
|
||||||
|
<div class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Game runtimes</h2>
|
||||||
|
{{template "statuscounts" .Runtimes}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Mail deliveries</h2>
|
||||||
|
{{template "statuscounts" .MailDeliveries}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Notification routes</h2>
|
||||||
|
{{template "statuscounts" .NotificationRoutes}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Malformed notifications</h2>
|
||||||
|
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{if .Errors}}
|
||||||
|
<section class="panel errors">
|
||||||
|
<h2>Collection errors</h2>
|
||||||
|
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="note">Monitoring is not wired in this deployment.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
<section class="cards">
|
||||||
|
<a class="card" href="/_gm/users">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p>Accounts, sanctions, entitlements, soft-delete.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/_gm/games">
|
||||||
|
<h2>Games & runtimes</h2>
|
||||||
|
<p>Lobby state, engine versions, turn control.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/_gm/operators">
|
||||||
|
<h2>Operators</h2>
|
||||||
|
<p>Admin accounts: create, disable, reset password.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/_gm/mail">
|
||||||
|
<h2>Mail & notifications</h2>
|
||||||
|
<p>Deliveries, dead-letters, broadcasts.</p>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{define "statuscounts" -}}
|
||||||
|
{{if .}}
|
||||||
|
<table class="counts"><tbody>
|
||||||
|
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
{{else}}
|
||||||
|
<p class="note">none</p>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Engine versions</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Version}}</td>
|
||||||
|
<td><code>{{.ImageRef}}</code></td>
|
||||||
|
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Register version</h2>
|
||||||
|
<form method="post" action="/_gm/engine-versions" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
|
||||||
|
<label>Image ref <input type="text" name="image_ref" required></label>
|
||||||
|
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/games">« all games</a></p>
|
||||||
|
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Game</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Game ID: <code>{{.GameID}}</code></li>
|
||||||
|
<li>Visibility: {{.Visibility}}</li>
|
||||||
|
<li>Status: {{.Status}}</li>
|
||||||
|
<li>Owner: {{.Owner}}</li>
|
||||||
|
<li>Players: {{.MinPlayers}}–{{.MaxPlayers}}</li>
|
||||||
|
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
|
||||||
|
<li>Turn schedule: {{.TurnSchedule}}</li>
|
||||||
|
<li>Target engine: {{.TargetEngineVersion}}</li>
|
||||||
|
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
|
||||||
|
<li>Created: {{.CreatedAt}}</li>
|
||||||
|
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Runtime</h2>
|
||||||
|
{{if .HasRuntime}}
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Status: {{.RuntimeStatus}}</li>
|
||||||
|
<li>Engine version: {{.CurrentEngineVersion}}</li>
|
||||||
|
<li>Engine health: {{.EngineHealth}}</li>
|
||||||
|
<li>Current turn: {{.CurrentTurn}}</li>
|
||||||
|
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
|
||||||
|
<button type="submit">Patch</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="note">No runtime record for this game yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Ban member</h2>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>User ID <input type="text" name="user_id" required></label>
|
||||||
|
<label>Reason <input type="text" name="reason"></label>
|
||||||
|
<button type="submit" class="danger">Ban member</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Games</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
|
||||||
|
<td>{{.Visibility}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>{{.Owner}}</td>
|
||||||
|
<td>{{.Players}}</td>
|
||||||
|
<td>{{.TurnSchedule}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Create public game</h2>
|
||||||
|
<form method="post" action="/_gm/games" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Name <input type="text" name="game_name" required></label>
|
||||||
|
<label>Description <input type="text" name="description"></label>
|
||||||
|
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
|
||||||
|
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
|
||||||
|
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
|
||||||
|
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
|
||||||
|
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
|
||||||
|
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
|
||||||
|
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Mail</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Deliveries</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Deliveries}}
|
||||||
|
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dead-letters</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/mail">« mail</a></p>
|
||||||
|
<h1>Delivery</h1>
|
||||||
|
<section class="panel">
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
|
||||||
|
<li>Template: {{.Template}}</li>
|
||||||
|
<li>Status: {{.Status}}</li>
|
||||||
|
<li>Attempts: {{.Attempts}}</li>
|
||||||
|
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Created: {{.Created}}</li>
|
||||||
|
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
|
||||||
|
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
|
||||||
|
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
{{if .CanResend}}
|
||||||
|
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
|
||||||
|
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Attempts</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="{{.Class}}">{{.Message}}</p>
|
||||||
|
{{if .BackHref}}<p><a href="{{.BackHref}}">« back</a></p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent notifications</h2>
|
||||||
|
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
|
||||||
|
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dead-letters</h2>
|
||||||
|
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
|
||||||
|
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Malformed intents</h2>
|
||||||
|
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
|
||||||
|
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Operators</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
{{if .Disabled}}
|
||||||
|
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Create operator</h2>
|
||||||
|
<form method="post" action="/_gm/operators" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Username <input type="text" name="username" required></label>
|
||||||
|
<label>Password <input type="password" name="password" required></label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/users">« all users</a></p>
|
||||||
|
<h1>{{.Email}}</h1>
|
||||||
|
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>User ID: <code>{{.UserID}}</code></li>
|
||||||
|
<li>User name: {{.UserName}}</li>
|
||||||
|
<li>Display name: {{.DisplayName}}</li>
|
||||||
|
<li>Preferred language: {{.PreferredLanguage}}</li>
|
||||||
|
<li>Time zone: {{.TimeZone}}</li>
|
||||||
|
<li>Declared country: {{.DeclaredCountry}}</li>
|
||||||
|
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
|
||||||
|
<li>Created: {{.CreatedAt}}</li>
|
||||||
|
<li>Updated: {{.UpdatedAt}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Entitlement</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
|
||||||
|
<li>Source: {{.EntitlementSource}}</li>
|
||||||
|
<li>Reason: {{.EntitlementReason}}</li>
|
||||||
|
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Tier
|
||||||
|
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||||
|
</label>
|
||||||
|
<label>Source <input type="text" name="source" value="admin"></label>
|
||||||
|
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
|
||||||
|
<button type="submit">Update entitlement</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Active sanctions</h2>
|
||||||
|
{{if .Sanctions}}
|
||||||
|
<table class="counts"><tbody>
|
||||||
|
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
{{else}}<p class="note">none</p>{{end}}
|
||||||
|
{{if .Blocked}}
|
||||||
|
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Reason <input type="text" name="reason_code" required></label>
|
||||||
|
<button type="submit" class="danger">Permanently block</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<button type="submit" class="danger">Soft-delete account</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Users</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
|
||||||
|
<td>{{.UserName}}</td>
|
||||||
|
<td>{{.DisplayName}}</td>
|
||||||
|
<td>{{.Tier}}</td>
|
||||||
|
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6"><span class="note">no users</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// UserRow is one line in the users list table.
|
||||||
|
type UserRow struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
UserName string
|
||||||
|
DisplayName string
|
||||||
|
Tier string
|
||||||
|
Blocked bool
|
||||||
|
Deleted bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersListData is the view model for the paginated users list.
|
||||||
|
type UsersListData struct {
|
||||||
|
Items []UserRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanctionView is one active sanction shown on the user detail page.
|
||||||
|
type SanctionView struct {
|
||||||
|
SanctionCode string
|
||||||
|
Scope string
|
||||||
|
ReasonCode string
|
||||||
|
AppliedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetailData is the view model for a single user's detail page,
|
||||||
|
// combining the account aggregate with the form option lists.
|
||||||
|
type UserDetailData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
UserName string
|
||||||
|
DisplayName string
|
||||||
|
PreferredLanguage string
|
||||||
|
TimeZone string
|
||||||
|
DeclaredCountry string
|
||||||
|
Blocked bool
|
||||||
|
Deleted bool
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
|
||||||
|
Tier string
|
||||||
|
IsPaid bool
|
||||||
|
EntitlementSource string
|
||||||
|
EntitlementReason string
|
||||||
|
EntitlementEnds string
|
||||||
|
|
||||||
|
Sanctions []SanctionView
|
||||||
|
|
||||||
|
// Tiers lists the selectable entitlement tiers for the form.
|
||||||
|
Tiers []string
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ const (
|
|||||||
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
||||||
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
||||||
|
|
||||||
|
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
|
||||||
|
|
||||||
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
||||||
|
|
||||||
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
||||||
@@ -91,15 +93,18 @@ 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"
|
||||||
|
|
||||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
|
||||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
|
||||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
|
||||||
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
|
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||||
|
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||||
|
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values applied when an environment variable is absent.
|
// Default values applied when an environment variable is absent.
|
||||||
@@ -163,8 +168,11 @@ const (
|
|||||||
defaultNotificationWorkerInterval = 5 * time.Second
|
defaultNotificationWorkerInterval = 5 * time.Second
|
||||||
defaultNotificationMaxAttempts = 8
|
defaultNotificationMaxAttempts = 8
|
||||||
|
|
||||||
defaultDevSandboxEngineVersion = "0.1.0"
|
defaultDiplomailMaxBodyBytes = 4096
|
||||||
defaultDevSandboxPlayerCount = 20
|
defaultDiplomailMaxSubjectBytes = 256
|
||||||
|
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||||
|
defaultDiplomailTranslatorMaxAttempts = 5
|
||||||
|
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Allowed values for the closed-set string options.
|
// Allowed values for the closed-set string options.
|
||||||
@@ -194,6 +202,7 @@ type Config struct {
|
|||||||
Docker DockerConfig
|
Docker DockerConfig
|
||||||
Game GameConfig
|
Game GameConfig
|
||||||
Admin AdminBootstrapConfig
|
Admin AdminBootstrapConfig
|
||||||
|
AdminConsole AdminConsoleConfig
|
||||||
GeoIP GeoIPConfig
|
GeoIP GeoIPConfig
|
||||||
Telemetry TelemetryConfig
|
Telemetry TelemetryConfig
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
@@ -201,29 +210,13 @@ type Config struct {
|
|||||||
Engine EngineConfig
|
Engine EngineConfig
|
||||||
Runtime RuntimeConfig
|
Runtime RuntimeConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
DevSandbox DevSandboxConfig
|
Diplomail DiplomailConfig
|
||||||
|
|
||||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||||
// push server to bound the cursor TTL.
|
// push server to bound the cursor TTL.
|
||||||
FreshnessWindow time.Duration
|
FreshnessWindow time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// DevSandboxConfig configures the boot-time bootstrap implemented in
|
|
||||||
// `backend/internal/devsandbox`. When Email is empty the bootstrap
|
|
||||||
// is a no-op, which is the production posture. When Email is set —
|
|
||||||
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
|
|
||||||
// the bootstrap idempotently provisions a real user, the configured
|
|
||||||
// number of dummy participants, a private "Dev Sandbox" game, the
|
|
||||||
// matching memberships, and drives the lifecycle to `running`. The
|
|
||||||
// engine image and engine version refer to a row that the bootstrap
|
|
||||||
// also seeds in `engine_versions`.
|
|
||||||
type DevSandboxConfig struct {
|
|
||||||
Email string
|
|
||||||
EngineImage string
|
|
||||||
EngineVersion string
|
|
||||||
PlayerCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoggingConfig stores the parameters used by the structured logger.
|
// LoggingConfig stores the parameters used by the structured logger.
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||||
@@ -293,6 +286,15 @@ type AdminBootstrapConfig struct {
|
|||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminConsoleConfig configures the server-rendered operator console.
|
||||||
|
// CSRFKey is the secret keying the console's stateless anti-CSRF token.
|
||||||
|
// When empty the console falls back to a per-process random key, which is
|
||||||
|
// secure but means forms do not survive a restart and do not validate across
|
||||||
|
// replicas; set a shared key when running more than one backend instance.
|
||||||
|
type AdminConsoleConfig struct {
|
||||||
|
CSRFKey string
|
||||||
|
}
|
||||||
|
|
||||||
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
||||||
type GeoIPConfig struct {
|
type GeoIPConfig struct {
|
||||||
DBPath string
|
DBPath string
|
||||||
@@ -395,6 +397,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,9 +540,12 @@ func DefaultConfig() Config {
|
|||||||
WorkerInterval: defaultNotificationWorkerInterval,
|
WorkerInterval: defaultNotificationWorkerInterval,
|
||||||
MaxAttempts: defaultNotificationMaxAttempts,
|
MaxAttempts: defaultNotificationMaxAttempts,
|
||||||
},
|
},
|
||||||
DevSandbox: DevSandboxConfig{
|
Diplomail: DiplomailConfig{
|
||||||
EngineVersion: defaultDevSandboxEngineVersion,
|
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
|
||||||
PlayerCount: defaultDevSandboxPlayerCount,
|
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
|
||||||
|
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
|
||||||
|
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||||
|
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||||
},
|
},
|
||||||
Runtime: RuntimeConfig{
|
Runtime: RuntimeConfig{
|
||||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||||
@@ -578,6 +627,8 @@ func LoadFromEnv() (Config, error) {
|
|||||||
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
||||||
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
||||||
|
|
||||||
|
cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey)
|
||||||
|
|
||||||
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
||||||
|
|
||||||
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
||||||
@@ -648,6 +699,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,10 +709,20 @@ func LoadFromEnv() (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil {
|
||||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
return Config{}, err
|
||||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
}
|
||||||
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
|
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
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,27 +915,28 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
|
|
||||||
if _, err := netmail.ParseAddress(email); err != nil {
|
|
||||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
|
|
||||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
|
|
||||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
|
|
||||||
}
|
|
||||||
if c.DevSandbox.PlayerCount <= 0 {
|
|
||||||
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
// Package devsandbox provisions a ready-to-play game on backend boot
|
|
||||||
// for the `tools/local-dev` stack.
|
|
||||||
//
|
|
||||||
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
|
|
||||||
// admin bootstrap and before the HTTP listener starts. It reads
|
|
||||||
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
|
|
||||||
// the function logs "skipped" and returns nil. When set, it
|
|
||||||
// idempotently:
|
|
||||||
//
|
|
||||||
// 1. registers the configured engine version and image;
|
|
||||||
// 2. find-or-creates the real dev user with the configured email;
|
|
||||||
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
|
|
||||||
// users so the engine's minimum-players constraint is met;
|
|
||||||
// 4. find-or-creates a private "Dev Sandbox" game owned by the
|
|
||||||
// real user with min/max_players = cfg.PlayerCount and a
|
|
||||||
// year-out turn schedule (effectively frozen at turn 1);
|
|
||||||
// 5. inserts memberships for all participants bypassing the
|
|
||||||
// application/approval flow;
|
|
||||||
// 6. drives the lifecycle to `running` (or as far as possible if
|
|
||||||
// the runtime is busy).
|
|
||||||
//
|
|
||||||
// The function is a no-op on subsequent boots once the game is
|
|
||||||
// running; partial states from earlier crashes are recovered.
|
|
||||||
package devsandbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/config"
|
|
||||||
"galaxy/backend/internal/lobby"
|
|
||||||
"galaxy/backend/internal/runtime"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SandboxGameName is the display name used to identify the
|
|
||||||
// auto-provisioned game on subsequent reboots. The combination of
|
|
||||||
// game_name and owner_user_id is unique enough in practice — only
|
|
||||||
// the dev sandbox bootstrap creates a game owned by the configured
|
|
||||||
// real user with this exact name.
|
|
||||||
const SandboxGameName = "Dev Sandbox"
|
|
||||||
|
|
||||||
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
|
|
||||||
// next turn a year out. The runtime scheduler still parses this and
|
|
||||||
// will tick once a year — long enough to never interfere with
|
|
||||||
// solo UI development.
|
|
||||||
const SandboxTurnSchedule = "0 0 1 1 *"
|
|
||||||
|
|
||||||
// UserEnsurer matches `auth.UserEnsurer`. We define a local
|
|
||||||
// interface to avoid importing the auth package and circular
|
|
||||||
// dependencies — the production wiring passes the same `*user.Service`
|
|
||||||
// instance used by auth.
|
|
||||||
type UserEnsurer interface {
|
|
||||||
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deps aggregates the collaborators Bootstrap needs.
|
|
||||||
type Deps struct {
|
|
||||||
Users UserEnsurer
|
|
||||||
Lobby *lobby.Service
|
|
||||||
EngineVersions *runtime.EngineVersionService
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap runs the seven-step provisioning flow described on the
|
|
||||||
// package doc comment. Errors are returned to the caller; the boot
|
|
||||||
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
|
|
||||||
// a misconfigured dev environment surfaces immediately rather than
|
|
||||||
// silently leaving the lobby empty.
|
|
||||||
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("dev_sandbox")
|
|
||||||
|
|
||||||
if cfg.Email == "" {
|
|
||||||
logger.Info("skipped (no email)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
|
|
||||||
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
|
|
||||||
}
|
|
||||||
if cfg.PlayerCount <= 0 {
|
|
||||||
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
|
|
||||||
for i := 1; i < cfg.PlayerCount; i++ {
|
|
||||||
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
|
|
||||||
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
|
|
||||||
}
|
|
||||||
dummyIDs = append(dummyIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("bootstrap complete",
|
|
||||||
zap.String("user_id", realID.String()),
|
|
||||||
zap.String("game_id", game.GameID.String()),
|
|
||||||
zap.String("status", game.Status),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
|
||||||
_, err := svc.Register(ctx, runtime.RegisterInput{
|
|
||||||
Version: cfg.EngineVersion,
|
|
||||||
ImageRef: cfg.EngineImage,
|
|
||||||
})
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
logger.Info("engine version registered",
|
|
||||||
zap.String("version", cfg.EngineVersion),
|
|
||||||
zap.String("image", cfg.EngineImage),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
case errors.Is(err, runtime.ErrEngineVersionTaken):
|
|
||||||
logger.Debug("engine version already registered",
|
|
||||||
zap.String("version", cfg.EngineVersion),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// terminalSandboxStatus reports whether a sandbox game has reached a
|
|
||||||
// state from which it can no longer be driven back to running. We
|
|
||||||
// treat such games as "absent" so the next bootstrap creates a fresh
|
|
||||||
// one rather than handing the developer a dead lobby tile.
|
|
||||||
func terminalSandboxStatus(status string) bool {
|
|
||||||
switch status {
|
|
||||||
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
|
||||||
// the dev user owns that has reached a terminal state
|
|
||||||
// (cancelled / finished / start_failed). The cascade declared in
|
|
||||||
// `00001_init.sql` removes the matching memberships, applications,
|
|
||||||
// invites, runtime records, and player mappings in the same write,
|
|
||||||
// so the developer's lobby never piles up dead tiles between
|
|
||||||
// `make rebuild` cycles. Non-terminal games are left untouched —
|
|
||||||
// a `running` sandbox from a previous boot is the happy path.
|
|
||||||
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
|
||||||
games, err := svc.ListMyGames(ctx, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: list my games: %w", err)
|
|
||||||
}
|
|
||||||
for _, g := range games {
|
|
||||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !terminalSandboxStatus(g.Status) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
|
||||||
}
|
|
||||||
logger.Info("purged terminal sandbox game",
|
|
||||||
zap.String("game_id", g.GameID.String()),
|
|
||||||
zap.String("status", g.Status),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
|
||||||
games, err := svc.ListMyGames(ctx, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
|
|
||||||
}
|
|
||||||
for _, g := range games {
|
|
||||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
|
||||||
// game still in the list is either a live one we should
|
|
||||||
// reuse or a transient state we can drive forward.
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
|
||||||
OwnerUserID: &ownerID,
|
|
||||||
Visibility: lobby.VisibilityPrivate,
|
|
||||||
GameName: SandboxGameName,
|
|
||||||
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
|
|
||||||
MinPlayers: int32(cfg.PlayerCount),
|
|
||||||
MaxPlayers: int32(cfg.PlayerCount),
|
|
||||||
StartGapHours: 0,
|
|
||||||
StartGapPlayers: 0,
|
|
||||||
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
|
|
||||||
TurnSchedule: SandboxTurnSchedule,
|
|
||||||
TargetEngineVersion: cfg.EngineVersion,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
|
|
||||||
}
|
|
||||||
return rec, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
|
|
||||||
caller := realID
|
|
||||||
if game.Status == lobby.GameStatusDraft {
|
|
||||||
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
|
|
||||||
if game.Status == lobby.GameStatusEnrollmentOpen {
|
|
||||||
users := append([]uuid.UUID{realID}, dummyIDs...)
|
|
||||||
for i, uid := range users {
|
|
||||||
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
|
|
||||||
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
|
|
||||||
GameID: game.GameID,
|
|
||||||
UserID: uid,
|
|
||||||
RaceName: raceName,
|
|
||||||
}); err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Info("memberships ensured",
|
|
||||||
zap.Int("count", len(users)),
|
|
||||||
zap.String("game_id", game.GameID.String()),
|
|
||||||
)
|
|
||||||
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
|
|
||||||
if game.Status == lobby.GameStatusReadyToStart {
|
|
||||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: start: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
|
|
||||||
if game.Status == lobby.GameStatusStartFailed {
|
|
||||||
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("retry start failed", zap.Error(err))
|
|
||||||
return game, nil
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
if game.Status == lobby.GameStatusReadyToStart {
|
|
||||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return game, nil
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package devsandbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/config"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
|
|
||||||
// the production posture (Email == "") Bootstrap must return without
|
|
||||||
// touching any dependency. The fact that Users/Lobby/EngineVersions
|
|
||||||
// are nil here doubles as a check that the early-return runs first.
|
|
||||||
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
|
|
||||||
err := Bootstrap(
|
|
||||||
context.Background(),
|
|
||||||
Deps{},
|
|
||||||
config.DevSandboxConfig{},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected nil error on empty email, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBootstrapRejectsZeroPlayerCount confirms the validation
|
|
||||||
// short-circuits the flow before any DB call when PlayerCount is
|
|
||||||
// non-positive but Email is set. The error path is fast and never
|
|
||||||
// dereferences the (still-nil) Users/Lobby deps.
|
|
||||||
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
|
|
||||||
err := Bootstrap(
|
|
||||||
context.Background(),
|
|
||||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
|
||||||
config.DevSandboxConfig{
|
|
||||||
Email: "dev@local.test",
|
|
||||||
EngineImage: "galaxy-engine:local-dev",
|
|
||||||
EngineVersion: "0.0.0-local-dev",
|
|
||||||
PlayerCount: 0,
|
|
||||||
},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error on zero PlayerCount, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
|
|
||||||
// (Email set but one of the required services nil) fails fast rather
|
|
||||||
// than panicking when the bootstrap reaches its first service call.
|
|
||||||
func TestBootstrapRejectsMissingDeps(t *testing.T) {
|
|
||||||
err := Bootstrap(
|
|
||||||
context.Background(),
|
|
||||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
|
||||||
config.DevSandboxConfig{
|
|
||||||
Email: "dev@local.test",
|
|
||||||
EngineImage: "galaxy-engine:local-dev",
|
|
||||||
EngineVersion: "0.0.0-local-dev",
|
|
||||||
PlayerCount: 20,
|
|
||||||
},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error on missing deps, got nil")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
|
|
||||||
// The exact wording is not part of the contract; this branch
|
|
||||||
// only asserts the error is non-nil and human-readable.
|
|
||||||
t.Fatalf("error has empty message: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// errMissingDepsSentinel exists so the assertion above can compile;
|
|
||||||
// the real error is constructed via errors.New inside Bootstrap and
|
|
||||||
// is intentionally not exported. The test only needs to confirm the
|
|
||||||
// returned error has a message.
|
|
||||||
var errMissingDepsSentinel = errors.New("sentinel")
|
|
||||||
|
|
||||||
// TestTerminalSandboxStatus pins the contract that decides whether a
|
|
||||||
// previously created sandbox game gets purged on the next boot.
|
|
||||||
// Terminal states are deleted (cascade-style) so the developer's
|
|
||||||
// lobby never piles up dead tiles between `make rebuild` cycles.
|
|
||||||
func TestTerminalSandboxStatus(t *testing.T) {
|
|
||||||
terminal := []string{"cancelled", "finished", "start_failed"}
|
|
||||||
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
|
||||||
|
|
||||||
for _, status := range terminal {
|
|
||||||
if !terminalSandboxStatus(status) {
|
|
||||||
t.Errorf("expected %q to be terminal", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, status := range live {
|
|
||||||
if terminalSandboxStatus(status) {
|
|
||||||
t.Errorf("expected %q to be non-terminal", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubEnsurer struct{}
|
|
||||||
|
|
||||||
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
|
|
||||||
return uuid.UUID{}, nil
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ const (
|
|||||||
pathAdminStatus = "/api/v1/admin/status"
|
pathAdminStatus = "/api/v1/admin/status"
|
||||||
pathAdminTurn = "/api/v1/admin/turn"
|
pathAdminTurn = "/api/v1/admin/turn"
|
||||||
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
||||||
pathPlayerCommand = "/api/v1/command"
|
|
||||||
pathPlayerOrder = "/api/v1/order"
|
pathPlayerOrder = "/api/v1/order"
|
||||||
pathPlayerReport = "/api/v1/report"
|
pathPlayerReport = "/api/v1/report"
|
||||||
pathPlayerBattle = "/api/v1/battle"
|
pathPlayerBattle = "/api/v1/battle"
|
||||||
@@ -183,16 +182,10 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
|
// PutOrders calls `PUT /api/v1/order` with the payload forwarded
|
||||||
// verbatim. The engine response body is returned verbatim; on 4xx the
|
// verbatim. The engine response body is returned verbatim; on 4xx the
|
||||||
// body is returned alongside ErrEngineValidation so callers can
|
// body is returned alongside ErrEngineValidation so callers can forward
|
||||||
// forward the per-command error.
|
// the per-command error.
|
||||||
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
|
||||||
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutOrders calls `PUT /api/v1/order` with the same forwarding
|
|
||||||
// semantics as ExecuteCommands.
|
|
||||||
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
||||||
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
|
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
|
|||||||
|
|
||||||
func TestClientInitSuccess(t *testing.T) {
|
func TestClientInitSuccess(t *testing.T) {
|
||||||
wantID := uuid.New()
|
wantID := uuid.New()
|
||||||
|
var gotReq rest.InitRequest
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathAdminInit {
|
if r.URL.Path != pathAdminInit {
|
||||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
@@ -33,13 +34,16 @@ func TestClientInitSuccess(t *testing.T) {
|
|||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Fatalf("unexpected method: %s", r.Method)
|
t.Fatalf("unexpected method: %s", r.Method)
|
||||||
}
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
cli := newTestClient(t, srv)
|
cli := newTestClient(t, srv)
|
||||||
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
|
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Init returned error: %v", err)
|
t.Fatalf("Init returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,9 @@ func TestClientInitSuccess(t *testing.T) {
|
|||||||
if got.Turn != 1 {
|
if got.Turn != 1 {
|
||||||
t.Fatalf("Turn = %d, want 1", got.Turn)
|
t.Fatalf("Turn = %d, want 1", got.Turn)
|
||||||
}
|
}
|
||||||
|
if gotReq.GameID != wantID {
|
||||||
|
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientInitValidationError(t *testing.T) {
|
func TestClientInitValidationError(t *testing.T) {
|
||||||
@@ -149,27 +156,6 @@ func TestClientBanishRace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientCommandsForwardsBody(t *testing.T) {
|
|
||||||
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
|
|
||||||
gotResp := json.RawMessage(`{"applied":true}`)
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
|
|
||||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
_, _ = w.Write(gotResp)
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
cli := newTestClient(t, srv)
|
|
||||||
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ExecuteCommands: %v", err)
|
|
||||||
}
|
|
||||||
if string(resp) != string(gotResp) {
|
|
||||||
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientReportsForwardsQuery(t *testing.T) {
|
func TestClientReportsForwardsQuery(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathPlayerReport {
|
if r.URL.Path != pathPlayerReport {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ import (
|
|||||||
|
|
||||||
// EntitlementProvider is the read-only view the lobby needs over the
|
// EntitlementProvider is the read-only view the lobby needs over the
|
||||||
// user-domain entitlement snapshot. The canonical implementation is
|
// user-domain entitlement snapshot. The canonical implementation is
|
||||||
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
|
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
|
||||||
// a fake.
|
// substitute a fake.
|
||||||
//
|
//
|
||||||
// `MaxRegisteredRaceNames` is the only field consumed by when
|
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
|
||||||
// the caller attempts to register a `pending_registration` row the lobby
|
// — when the caller attempts to register a `pending_registration` row the
|
||||||
// counts already-`registered` rows for that user against this limit.
|
// lobby counts already-`registered` rows for that user against this limit.
|
||||||
|
//
|
||||||
|
// `IsPaid` is consumed by the user-facing private-game creation gate at
|
||||||
|
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
|
||||||
|
// callers are rejected with `403 forbidden` before the lobby Service is
|
||||||
|
// invoked. Admin-driven public-game creation
|
||||||
|
// (`POST /api/v1/admin/games`) bypasses the gate.
|
||||||
type EntitlementProvider interface {
|
type EntitlementProvider interface {
|
||||||
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
|
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
|
||||||
|
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
|
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
|
||||||
@@ -51,6 +58,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 +161,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,16 +234,50 @@ 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`.
|
||||||
// Idempotent: returns nil when no game matches.
|
// Idempotent: returns nil when no game matches.
|
||||||
//
|
//
|
||||||
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
|
// `DeleteGame` is destructive — a hard delete that bypasses the
|
||||||
// terminal "Dev Sandbox" tile from a previous local-dev session can
|
// cascade-notification machinery — so production callers stay on the
|
||||||
// be scrubbed before a fresh game spawns. Production callers must
|
// regular cancel / finish lifecycle. It is exercised by the lobby
|
||||||
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
|
// integration tests.
|
||||||
// destructive and bypasses the cascade-notification machinery.
|
|
||||||
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -441,9 +476,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.).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
package lobby
|
package lobby
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -28,6 +29,7 @@ import (
|
|||||||
|
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -124,6 +126,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 +159,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 {
|
||||||
@@ -203,6 +209,17 @@ func (s *Service) Config() config.LobbyConfig {
|
|||||||
return s.deps.Config
|
return s.deps.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsPaid reports whether userID currently sits on a paid tier. Thin
|
||||||
|
// pass-through over EntitlementProvider used by the HTTP handler that
|
||||||
|
// fronts user-driven private-game creation; admin-driven public-game
|
||||||
|
// creation does not consult this gate.
|
||||||
|
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||||
|
if s == nil || s.deps.Entitlement == nil {
|
||||||
|
return false, fmt.Errorf("lobby: entitlement provider not configured")
|
||||||
|
}
|
||||||
|
return s.deps.Entitlement.IsPaid(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
|
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
|
||||||
// for code-based invites. The function uses `crypto/rand`; a failure to
|
// for code-based invites. The function uses `crypto/rand`; a failure to
|
||||||
// read entropy is propagated to the caller.
|
// read entropy is propagated to the caller.
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
|
|||||||
return s.max, nil
|
return s.max, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
|
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store := lobby.NewStore(db)
|
store := lobby.NewStore(db)
|
||||||
@@ -244,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
|
||||||
// bootstrap relies on: removing a game wipes every referencing row
|
// removing a game wipes every referencing row
|
||||||
// (memberships, applications, invites, runtime_records,
|
// (memberships, applications, invites, runtime_records,
|
||||||
// player_mappings) in a single SQL statement. Before this is wired
|
// player_mappings) in a single SQL statement. Before this is wired
|
||||||
// the developer's lobby pile up cancelled tiles between
|
// the developer's lobby pile up cancelled tiles between
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
|
|||||||
// writes as ApproveApplication: the per-game race-name reservation
|
// writes as ApproveApplication: the per-game race-name reservation
|
||||||
// row plus the membership row, and refreshes the in-memory caches.
|
// row plus the membership row, and refreshes the in-memory caches.
|
||||||
//
|
//
|
||||||
// The method is intended for boot-time provisioning by
|
// The method is intended for trusted boot-time provisioning and
|
||||||
// `backend/internal/devsandbox` and similar trusted callers. It is
|
// integration tests; it is not exposed through any HTTP handler. The
|
||||||
// not exposed through any HTTP handler. The caller must guarantee
|
// caller must guarantee
|
||||||
// game.Status == GameStatusEnrollmentOpen — the function returns
|
// game.Status == GameStatusEnrollmentOpen — the function returns
|
||||||
// ErrConflict otherwise — and that the race-name policy and
|
// ErrConflict otherwise — and that the race-name policy and
|
||||||
// canonical-key invariants are honoured (the implementation reuses
|
// canonical-key invariants are honoured (the implementation reuses
|
||||||
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
|
|||||||
// or unsuitable name still fails).
|
// or unsuitable name still fails).
|
||||||
//
|
//
|
||||||
// Idempotency: if a membership for (GameID, UserID) already exists
|
// Idempotency: if a membership for (GameID, UserID) already exists
|
||||||
// the function returns the existing row without modifying state.
|
// the function returns the existing row without modifying state, so
|
||||||
// This makes the helper safe to call on every backend boot from
|
// the helper is safe to call repeatedly.
|
||||||
// devsandbox.Bootstrap.
|
|
||||||
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
||||||
displayName, err := ValidateDisplayName(in.RaceName)
|
displayName, err := ValidateDisplayName(in.RaceName)
|
||||||
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.
|
||||||
|
|||||||
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
|
|||||||
// referencing table (memberships / applications / invites /
|
// referencing table (memberships / applications / invites /
|
||||||
// runtime_records / player_mappings — all declared with ON DELETE
|
// runtime_records / player_mappings — all declared with ON DELETE
|
||||||
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
||||||
// matches. Used by the dev-sandbox bootstrap to scrub terminal
|
// matches. A hard delete for trusted callers and integration tests;
|
||||||
// games on every backend boot so the developer's lobby never piles
|
// production lifecycle uses cancel / finish.
|
||||||
// up cancelled tiles.
|
|
||||||
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
g := table.Games
|
g := table.Games
|
||||||
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
||||||
|
|||||||
@@ -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,139 @@
|
|||||||
|
// Package opsstatus reads point-in-time operational signals from Postgres for
|
||||||
|
// the admin console dashboard: database reachability, per-status counts of game
|
||||||
|
// runtimes, mail deliveries, and notification routes, plus the malformed
|
||||||
|
// notification-intent count.
|
||||||
|
//
|
||||||
|
// It is a read-only projection built entirely through the go-jet query builder
|
||||||
|
// against the generated table bindings; it owns no business logic and mutates
|
||||||
|
// nothing. Richer, historical metrics are out of scope — those belong to the
|
||||||
|
// Prometheus exporters wired on `backend` and `gateway`.
|
||||||
|
package opsstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultCollectTimeout bounds a single Collect call so a slow or wedged
|
||||||
|
// database cannot hang the dashboard request.
|
||||||
|
const defaultCollectTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// StatusCount pairs a status value with the number of rows currently in it.
|
||||||
|
type StatusCount struct {
|
||||||
|
Status string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is a point-in-time view of the operational signals rendered on the
|
||||||
|
// dashboard. Errors collects per-query failures so a single failing probe
|
||||||
|
// degrades to a visible note rather than failing the whole page.
|
||||||
|
type Snapshot struct {
|
||||||
|
PostgresHealthy bool
|
||||||
|
Runtimes []StatusCount
|
||||||
|
MailDeliveries []StatusCount
|
||||||
|
NotificationRoutes []StatusCount
|
||||||
|
NotificationMalformed int64
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader collects an operational Snapshot. The admin console depends on this
|
||||||
|
// interface so the dashboard can be tested without a database.
|
||||||
|
type Reader interface {
|
||||||
|
Collect(ctx context.Context) Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the Postgres-backed Reader.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore constructs a Store reading from db.
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db, timeout: defaultCollectTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect gathers the dashboard signals within a bounded timeout. It never
|
||||||
|
// returns an error: a failed probe is recorded in Snapshot.Errors and the
|
||||||
|
// remaining probes still run, except that a failed Postgres ping short-circuits
|
||||||
|
// the rest (the dependent queries would only fail the same way).
|
||||||
|
func (s *Store) Collect(ctx context.Context) Snapshot {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var snap Snapshot
|
||||||
|
|
||||||
|
if err := s.db.PingContext(ctx); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
snap.PostgresHealthy = true
|
||||||
|
|
||||||
|
if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.Runtimes = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.MailDeliveries = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.NotificationRoutes = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.NotificationMalformed = n
|
||||||
|
}
|
||||||
|
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCounts runs `SELECT status, COUNT(*) FROM <from> GROUP BY status`
|
||||||
|
// through jet and returns the rows ordered by status.
|
||||||
|
func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
|
||||||
|
stmt := postgres.SELECT(
|
||||||
|
status.AS("status_count.status"),
|
||||||
|
postgres.COUNT(postgres.STAR).AS("status_count.count"),
|
||||||
|
).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
Status string `alias:"status_count.status"`
|
||||||
|
Count int64 `alias:"status_count.count"`
|
||||||
|
}
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]StatusCount, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
out[i] = StatusCount{Status: row.Status, Count: row.Count}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countAll runs `SELECT COUNT(*) FROM <from>` through jet.
|
||||||
|
func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
|
||||||
|
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
|
||||||
|
|
||||||
|
var row struct {
|
||||||
|
Count int64 `alias:"count"`
|
||||||
|
}
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return row.Count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package opsstatus_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/mail"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
|
backendpg "galaxy/backend/internal/postgres"
|
||||||
|
pgshared "galaxy/postgres"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||||
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pgImage = "postgres:16-alpine"
|
||||||
|
pgUser = "galaxy"
|
||||||
|
pgPassword = "galaxy"
|
||||||
|
pgDatabase = "galaxy_backend"
|
||||||
|
pgSchema = "backend"
|
||||||
|
pgStartup = 90 * time.Second
|
||||||
|
pgOpTO = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// startPostgres mirrors the per-package scaffolding used by the other store
|
||||||
|
// tests: spin up Postgres, apply migrations, return *sql.DB.
|
||||||
|
func startPostgres(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
pgContainer, err := tcpostgres.Run(ctx, pgImage,
|
||||||
|
tcpostgres.WithDatabase(pgDatabase),
|
||||||
|
tcpostgres.WithUsername(pgUser),
|
||||||
|
tcpostgres.WithPassword(pgPassword),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForLog("database system is ready to accept connections").
|
||||||
|
WithOccurrence(2).
|
||||||
|
WithStartupTimeout(pgStartup),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
||||||
|
t.Errorf("terminate postgres container: %v", termErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connection string: %v", err)
|
||||||
|
}
|
||||||
|
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scope dsn: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := pgshared.DefaultConfig()
|
||||||
|
cfg.PrimaryDSN = scopedDSN
|
||||||
|
cfg.OperationTimeout = pgOpTO
|
||||||
|
|
||||||
|
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open primary: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
||||||
|
t.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
||||||
|
parsed, err := url.Parse(baseDSN)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
values := parsed.Query()
|
||||||
|
values.Set("search_path", schema)
|
||||||
|
if values.Get("sslmode") == "" {
|
||||||
|
values.Set("sslmode", "disable")
|
||||||
|
}
|
||||||
|
parsed.RawQuery = values.Encode()
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCollect(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
store := opsstatus.NewStore(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Empty schema: queries must execute cleanly with zero counts.
|
||||||
|
empty := store.Collect(ctx)
|
||||||
|
if !empty.PostgresHealthy {
|
||||||
|
t.Fatal("PostgresHealthy must be true against a reachable database")
|
||||||
|
}
|
||||||
|
if len(empty.Errors) != 0 {
|
||||||
|
t.Fatalf("unexpected collection errors: %v", empty.Errors)
|
||||||
|
}
|
||||||
|
if got := totalCount(empty.MailDeliveries); got != 0 {
|
||||||
|
t.Fatalf("mail deliveries total = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
|
||||||
|
t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
|
||||||
|
}
|
||||||
|
if empty.NotificationMalformed != 0 {
|
||||||
|
t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue one mail delivery and confirm the GROUP BY count reflects it.
|
||||||
|
mailStore := mail.NewStore(db)
|
||||||
|
inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
|
||||||
|
DeliveryID: uuid.New(),
|
||||||
|
TemplateID: mail.TemplateLoginCode,
|
||||||
|
IdempotencyKey: uuid.NewString(),
|
||||||
|
Recipients: []string{"ops@example.test"},
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Subject: "hello",
|
||||||
|
Body: []byte("hi"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert mail delivery: %v", err)
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
t.Fatal("expected the delivery to be inserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
after := store.Collect(ctx)
|
||||||
|
if len(after.Errors) != 0 {
|
||||||
|
t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
|
||||||
|
}
|
||||||
|
if got := totalCount(after.MailDeliveries); got != 1 {
|
||||||
|
t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCount(counts []opsstatus.StatusCount) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, c := range counts {
|
||||||
|
total += c.Count
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
@@ -67,18 +70,84 @@ func Open(ctx context.Context, cfg config.PostgresConfig, runtime *telemetry.Run
|
|||||||
// backend table lives here.
|
// backend table lives here.
|
||||||
const schemaName = "backend"
|
const schemaName = "backend"
|
||||||
|
|
||||||
|
// migrationRetryAttempts and migrationRetryBackoff bound the transient-error
|
||||||
|
// retry around ApplyMigrations. A freshly started Postgres — notably a test
|
||||||
|
// container — can reset a pooled connection moments after it reports ready,
|
||||||
|
// which surfaces as `driver: bad connection` mid-migration; a handful of quick
|
||||||
|
// retries rides over that without masking real failures.
|
||||||
|
const (
|
||||||
|
migrationRetryAttempts = 5
|
||||||
|
migrationRetryBackoff = 250 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
// ApplyMigrations runs every pending Up migration embedded in the backend
|
// ApplyMigrations runs every pending Up migration embedded in the backend
|
||||||
// binary against db. The schema is created upfront so goose's bookkeeping
|
// binary against db. The schema is created upfront so goose's bookkeeping
|
||||||
// table (`goose_db_version`, scoped to the DSN `search_path = backend`)
|
// table (`goose_db_version`, scoped to the DSN `search_path = backend`)
|
||||||
// has somewhere to land before the first migration runs; migration
|
// has somewhere to land before the first migration runs; migration
|
||||||
// `00001_init.sql` re-asserts the schema with `IF NOT EXISTS`, so the
|
// `00001_init.sql` re-asserts the schema with `IF NOT EXISTS`, so the
|
||||||
// double-create is idempotent.
|
// double-create is idempotent.
|
||||||
|
//
|
||||||
|
// The apply is retried on transient connection errors (see retryOnTransient).
|
||||||
|
// Both steps are idempotent — `CREATE SCHEMA IF NOT EXISTS` and goose's
|
||||||
|
// version tracking — so a retry after a dropped connection re-runs cleanly and
|
||||||
|
// resumes from the last committed migration.
|
||||||
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
|
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
|
||||||
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
|
return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error {
|
||||||
return fmt.Errorf("ensure backend schema: %w", err)
|
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
|
||||||
}
|
return fmt.Errorf("ensure backend schema: %w", err)
|
||||||
if err := pgshared.RunMigrations(ctx, db, migrations.Migrations(), "."); err != nil {
|
}
|
||||||
return fmt.Errorf("apply backend migrations: %w", err)
|
if err := pgshared.RunMigrations(ctx, db, migrations.Migrations(), "."); err != nil {
|
||||||
}
|
return fmt.Errorf("apply backend migrations: %w", err)
|
||||||
return nil
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryOnTransient runs op up to attempts times, retrying only when op fails
|
||||||
|
// with a transient connection error (see isTransientConnError) — a dropped,
|
||||||
|
// reset, or refused connection, as opposed to a deterministic SQL error. It
|
||||||
|
// waits backoff between attempts and stops early if ctx is cancelled. A
|
||||||
|
// non-transient error, or the error from the final attempt, is returned as-is.
|
||||||
|
func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error {
|
||||||
|
var err error
|
||||||
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
|
if err = op(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if attempt == attempts || !isTransientConnError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.Join(err, ctx.Err())
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTransientConnError reports whether err is a transient connection-level
|
||||||
|
// failure worth retrying. It matches database/sql's driver.ErrBadConn and the
|
||||||
|
// connection-failure messages Postgres drivers surface, while leaving
|
||||||
|
// deterministic SQL errors (syntax, constraint violations) to fail fast.
|
||||||
|
func isTransientConnError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, driver.ErrBadConn) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
for _, s := range []string{
|
||||||
|
"bad connection",
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"broken pipe",
|
||||||
|
"server closed the connection",
|
||||||
|
} {
|
||||||
|
if strings.Contains(msg, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsTransientConnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, false},
|
||||||
|
{"driver.ErrBadConn", driver.ErrBadConn, true},
|
||||||
|
{"wrapped ErrBadConn", fmt.Errorf("run migrations: %w", driver.ErrBadConn), true},
|
||||||
|
// The exact shape observed flaking CI: goose surfaces the driver
|
||||||
|
// error as a plain string, so errors.Is can't see ErrBadConn.
|
||||||
|
{"bad connection string", errors.New(`apply backend migrations: run migrations: ERROR 00001_init.sql: CREATE TABLE race_names: driver: bad connection`), true},
|
||||||
|
{"connection refused", errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"), true},
|
||||||
|
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||||
|
{"broken pipe", errors.New("write tcp: broken pipe"), true},
|
||||||
|
{"server closed", errors.New("pq: server closed the connection unexpectedly"), true},
|
||||||
|
{"syntax error is not transient", errors.New(`pq: syntax error at or near "TABL"`), false},
|
||||||
|
{"constraint violation is not transient", errors.New("pq: duplicate key value violates unique constraint"), false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assert.Equal(t, tt.want, isTransientConnError(tt.err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOnTransientSucceedsAfterTransientFailures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
|
||||||
|
calls++
|
||||||
|
if calls < 3 {
|
||||||
|
return fmt.Errorf("attempt %d: %w", calls, driver.ErrBadConn)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, calls, "should retry until the transient error clears")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOnTransientStopsOnNonTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sentinel := errors.New(`pq: syntax error at or near "TABL"`)
|
||||||
|
calls := 0
|
||||||
|
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
|
||||||
|
calls++
|
||||||
|
return sentinel
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, sentinel)
|
||||||
|
assert.Equal(t, 1, calls, "a deterministic SQL error must not be retried")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOnTransientExhaustsAttempts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
err := retryOnTransient(context.Background(), 3, time.Millisecond, func() error {
|
||||||
|
calls++
|
||||||
|
return driver.ErrBadConn
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, driver.ErrBadConn)
|
||||||
|
assert.Equal(t, 3, calls, "must stop after the attempt budget is spent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOnTransientRespectsContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
err := retryOnTransient(ctx, 5, time.Hour, func() error {
|
||||||
|
calls++
|
||||||
|
return driver.ErrBadConn
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
require.ErrorIs(t, err, driver.ErrBadConn, "the underlying transient error is preserved")
|
||||||
|
assert.Equal(t, 1, calls, "cancellation during backoff stops further attempts")
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ var (
|
|||||||
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
||||||
|
|
||||||
// ErrGamePaused reports that the game is not in a state that
|
// ErrGamePaused reports that the game is not in a state that
|
||||||
// accepts user-games commands or orders: the runtime row
|
// accepts user-games orders: the runtime row
|
||||||
// carries `paused = true`, or the runtime status lands on any
|
// carries `paused = true`, or the runtime status lands on any
|
||||||
// terminal value (`engine_unreachable`, `generation_failed`,
|
// terminal value (`engine_unreachable`, `generation_failed`,
|
||||||
// `stopped`, `finished`, `removed`), or the game has not yet
|
// `stopped`, `finished`, `removed`), or the game has not yet
|
||||||
|
|||||||
@@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckOrdersAccept verifies that the runtime is in a state that
|
// CheckOrdersAccept verifies that the runtime is in a state that
|
||||||
// accepts user-games commands and orders. It is called by the user
|
// accepts user-games orders. It is called by the user game-proxy
|
||||||
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
|
// handler (`Orders`) before forwarding to engine, so the backend's
|
||||||
// engine, so the backend's turn-cutoff and pause guards run before
|
// turn-cutoff and pause guards run before network traffic leaves the
|
||||||
// network traffic leaves the host. The decision itself lives in the
|
// host. The decision itself lives in the
|
||||||
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
||||||
// constructing a full Service.
|
// constructing a full Service.
|
||||||
//
|
//
|
||||||
@@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OrdersAcceptStatus inspects a runtime record and returns the
|
// OrdersAcceptStatus inspects a runtime record and returns the
|
||||||
// matching sentinel for the user-games order/command pre-check:
|
// matching sentinel for the user-games order pre-check:
|
||||||
//
|
//
|
||||||
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
||||||
// The cron-driven `Scheduler.tick` has flipped the row before
|
// The cron-driven `Scheduler.tick` has flipped the row before
|
||||||
@@ -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,
|
||||||
@@ -610,7 +607,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{Races: races})
|
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{GameID: gameID, Races: races})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.deps.Logger.Warn("engine init failed",
|
s.deps.Logger.Warn("engine init failed",
|
||||||
zap.String("game_id", gameID.String()),
|
zap.String("game_id", gameID.String()),
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
|
|||||||
case "/healthz":
|
case "/healthz":
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
case "/api/v1/admin/init":
|
case "/api/v1/admin/init":
|
||||||
|
var got rest.InitRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Errorf("decode init request: %v", err)
|
||||||
|
}
|
||||||
|
if got.GameID != gameID {
|
||||||
|
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
|
||||||
|
}
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
||||||
case "/api/v1/admin/status":
|
case "/api/v1/admin/status":
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
||||||
|
|||||||
@@ -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,235 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
|
"galaxy/backend/internal/server/httperr"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminConsoleHandlers renders the server-side operator console mounted under
|
||||||
|
// the `/_gm` route group. It wraps the framework-agnostic
|
||||||
|
// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
|
||||||
|
// handlers, the embedded static-asset handler, and the CSRF guard middleware
|
||||||
|
// applied to state-changing requests. Authentication is provided by the shared
|
||||||
|
// admin Basic Auth middleware mounted on the group, so this type assumes the
|
||||||
|
// caller has already been verified.
|
||||||
|
type AdminConsoleHandlers struct {
|
||||||
|
renderer *adminconsole.Renderer
|
||||||
|
csrf *adminconsole.CSRF
|
||||||
|
assets http.Handler
|
||||||
|
monitor opsstatus.Reader
|
||||||
|
ready func() bool
|
||||||
|
users UserAdmin
|
||||||
|
games GameAdmin
|
||||||
|
runtime RuntimeAdmin
|
||||||
|
engineVersions EngineVersionAdmin
|
||||||
|
operators OperatorAdmin
|
||||||
|
mail MailAdmin
|
||||||
|
notifications NotificationAdmin
|
||||||
|
diplomail DiplomailAdmin
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminConsoleDeps bundles the collaborators for the operator console. Every
|
||||||
|
// field is optional: a nil Renderer or CSRF falls back to the embedded default
|
||||||
|
// templates and a per-process random key; a nil Monitor renders the dashboard
|
||||||
|
// without the monitoring panels; a nil Ready reports backend readiness as not
|
||||||
|
// ready; a nil Logger falls back to zap.NewNop.
|
||||||
|
type AdminConsoleDeps struct {
|
||||||
|
Renderer *adminconsole.Renderer
|
||||||
|
CSRF *adminconsole.CSRF
|
||||||
|
Monitor opsstatus.Reader
|
||||||
|
Ready func() bool
|
||||||
|
Users UserAdmin
|
||||||
|
Games GameAdmin
|
||||||
|
Runtime RuntimeAdmin
|
||||||
|
EngineVersions EngineVersionAdmin
|
||||||
|
Operators OperatorAdmin
|
||||||
|
Mail MailAdmin
|
||||||
|
Notifications NotificationAdmin
|
||||||
|
Diplomail DiplomailAdmin
|
||||||
|
Logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminConsoleHandlers constructs the console handler set from deps. It
|
||||||
|
// panics only on conditions that are unrecoverable at startup (template parse
|
||||||
|
// failure or crypto/rand failure), both of which indicate a broken build or
|
||||||
|
// host rather than a runtime input.
|
||||||
|
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
||||||
|
logger := deps.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
renderer := deps.Renderer
|
||||||
|
if renderer == nil {
|
||||||
|
renderer = adminconsole.MustNewRenderer()
|
||||||
|
}
|
||||||
|
csrf := deps.CSRF
|
||||||
|
if csrf == nil {
|
||||||
|
generated, err := adminconsole.NewRandomCSRF()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
csrf = generated
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsFS, err := adminconsole.Assets()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AdminConsoleHandlers{
|
||||||
|
renderer: renderer,
|
||||||
|
csrf: csrf,
|
||||||
|
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
||||||
|
monitor: deps.Monitor,
|
||||||
|
ready: deps.Ready,
|
||||||
|
users: deps.Users,
|
||||||
|
games: deps.Games,
|
||||||
|
runtime: deps.Runtime,
|
||||||
|
engineVersions: deps.EngineVersions,
|
||||||
|
operators: deps.Operators,
|
||||||
|
mail: deps.Mail,
|
||||||
|
notifications: deps.Notifications,
|
||||||
|
diplomail: deps.Diplomail,
|
||||||
|
logger: logger.Named("http.admin.console"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
|
||||||
|
// including the monitoring panels when an ops-status reader is wired.
|
||||||
|
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
data := adminconsole.DashboardData{}
|
||||||
|
if h.ready != nil {
|
||||||
|
data.BackendReady = h.ready()
|
||||||
|
}
|
||||||
|
if h.monitor != nil {
|
||||||
|
data.MonitorAvailable = true
|
||||||
|
snapshot := h.monitor.Collect(c.Request.Context())
|
||||||
|
data.PostgresHealthy = snapshot.PostgresHealthy
|
||||||
|
data.Runtimes = toViewCounts(snapshot.Runtimes)
|
||||||
|
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
|
||||||
|
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
|
||||||
|
data.NotificationMalformed = snapshot.NotificationMalformed
|
||||||
|
data.Errors = snapshot.Errors
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toViewCounts maps ops-status counts to the console's view-layer counts.
|
||||||
|
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]adminconsole.StatusCount, len(in))
|
||||||
|
for i, sc := range in {
|
||||||
|
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset serves the embedded console static assets under `/_gm/assets/`.
|
||||||
|
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
|
||||||
|
return gin.WrapH(h.assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireCSRF returns middleware guarding state-changing requests against
|
||||||
|
// cross-site request forgery. Safe methods pass through untouched. For unsafe
|
||||||
|
// methods it requires both a same-origin Origin/Referer header (when the
|
||||||
|
// browser sends one) and a valid per-operator token in the `_csrf` form field;
|
||||||
|
// either check failing yields 403.
|
||||||
|
func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if isSafeHTTPMethod(c.Request.Method) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sameOriginRequest(c.Request) {
|
||||||
|
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
if !h.csrf.Verify(username, c.PostForm("_csrf")) {
|
||||||
|
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// render composes the data common to every console page (operator name, CSRF
|
||||||
|
// token, active navigation entry) and writes the named page. It renders into an
|
||||||
|
// intermediate buffer so a template failure surfaces as a clean 500 without
|
||||||
|
// emitting a partial document.
|
||||||
|
func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
|
||||||
|
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := h.renderer.Render(&buf, page, adminconsole.PageData{
|
||||||
|
Title: title,
|
||||||
|
Username: username,
|
||||||
|
CSRFToken: h.csrf.Token(username),
|
||||||
|
ActiveNav: activeNav,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
|
||||||
|
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMessage renders the generic message page (not-found, validation, or
|
||||||
|
// operation-failure notices). class selects the CSS styling and backHref, when
|
||||||
|
// non-empty, adds a back link.
|
||||||
|
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
|
||||||
|
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
|
||||||
|
Message: message,
|
||||||
|
Class: class,
|
||||||
|
BackHref: backHref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
|
||||||
|
// CSRF guard may let through without a token.
|
||||||
|
func isSafeHTTPMethod(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameOriginRequest reports whether the request's Origin (or, failing that,
|
||||||
|
// Referer) names the same host as the request itself. A request that carries
|
||||||
|
// neither header is treated as same-origin, leaving the CSRF token as the sole
|
||||||
|
// guard; a malformed or cross-host value is rejected. This relies on the
|
||||||
|
// gateway reverse proxy preserving the inbound Host header.
|
||||||
|
func sameOriginRequest(r *http.Request) bool {
|
||||||
|
source := r.Header.Get("Origin")
|
||||||
|
if source == "" {
|
||||||
|
source = r.Header.Get("Referer")
|
||||||
|
}
|
||||||
|
if source == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(source)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(parsed.Host, r.Host)
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
"galaxy/backend/internal/runtime"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GameAdmin is the subset of the lobby service the console uses for games.
|
||||||
|
type GameAdmin interface {
|
||||||
|
ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
|
||||||
|
GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||||
|
CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
|
||||||
|
AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||||
|
AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||||
|
AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeAdmin is the subset of the runtime service the console uses.
|
||||||
|
type RuntimeAdmin interface {
|
||||||
|
GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
|
||||||
|
AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||||
|
AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
|
||||||
|
AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionAdmin is the subset of the engine-version service the console uses.
|
||||||
|
type EngineVersionAdmin interface {
|
||||||
|
List(ctx context.Context) ([]runtime.EngineVersion, error)
|
||||||
|
Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
|
||||||
|
Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GamesList renders GET /_gm/games.
|
||||||
|
func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||||
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||||
|
result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list games", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameCreate handles POST /_gm/games — create a public game.
|
||||||
|
func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
|
||||||
|
OwnerUserID: nil,
|
||||||
|
Visibility: lobby.VisibilityPublic,
|
||||||
|
GameName: strings.TrimSpace(c.PostForm("game_name")),
|
||||||
|
Description: strings.TrimSpace(c.PostForm("description")),
|
||||||
|
MinPlayers: formInt32(c, "min_players"),
|
||||||
|
MaxPlayers: formInt32(c, "max_players"),
|
||||||
|
StartGapHours: formInt32(c, "start_gap_hours"),
|
||||||
|
StartGapPlayers: formInt32(c, "start_gap_players"),
|
||||||
|
EnrollmentEndsAt: enrollmentEndsAt,
|
||||||
|
TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
|
||||||
|
TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, lobby.ErrInvalidInput) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: create game", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
|
||||||
|
func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
game, err := h.games.GetGame(c.Request.Context(), gameID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, lobby.ErrNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: get game", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeRecord *runtime.RuntimeRecord
|
||||||
|
if h.runtime != nil {
|
||||||
|
if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
|
||||||
|
runtimeRecord = &record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameForceStart handles POST /_gm/games/:game_id/force-start.
|
||||||
|
func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
|
||||||
|
return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.games.AdminForceStart(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameForceStop handles POST /_gm/games/:game_id/force-stop.
|
||||||
|
func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
|
||||||
|
return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.games.AdminForceStop(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameBanMember handles POST /_gm/games/:game_id/ban-member.
|
||||||
|
func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
|
||||||
|
h.logger.Error("admin console: ban member", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
|
||||||
|
func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
|
||||||
|
return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.runtime.AdminRestart(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
|
||||||
|
func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
|
||||||
|
return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.runtime.AdminForceNextTurn(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
|
||||||
|
func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.runtime == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
target := strings.TrimSpace(c.PostForm("target_version"))
|
||||||
|
if target == "" {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
|
||||||
|
h.logger.Error("admin console: runtime patch", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameAction is the shared shape for game-state POST actions that take only the
|
||||||
|
// game id and redirect back to the detail page.
|
||||||
|
func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
if err := run(c.Request.Context(), gameID); err != nil {
|
||||||
|
h.logger.Error("admin console: game "+label, zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeAction is the shared shape for runtime POST actions that take only the
|
||||||
|
// game id and redirect back to the detail page.
|
||||||
|
func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.runtime == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
if err := run(c.Request.Context(), gameID); err != nil {
|
||||||
|
h.logger.Error("admin console: runtime "+label, zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionsList renders GET /_gm/engine-versions.
|
||||||
|
func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.engineVersions == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.engineVersions.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list engine versions", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionRegister handles POST /_gm/engine-versions.
|
||||||
|
func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.engineVersions == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled := c.PostForm("enabled") == "true"
|
||||||
|
_, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
|
||||||
|
Version: strings.TrimSpace(c.PostForm("version")),
|
||||||
|
ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
|
||||||
|
Enabled: &enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: register engine version", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
|
||||||
|
func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.engineVersions == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(c.Param("version"))
|
||||||
|
if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
|
||||||
|
h.logger.Error("admin console: disable engine version", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formInt32 reads a non-negative int32 form field, defaulting to 0.
|
||||||
|
func formInt32(c *gin.Context, name string) int32 {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int32(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConsoleDateTime parses the value of an <input type="datetime-local">
|
||||||
|
// (or an RFC 3339 timestamp) as UTC.
|
||||||
|
func parseConsoleDateTime(raw string) (time.Time, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
|
||||||
|
if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, errors.New("invalid date/time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGamesListData maps a game page into the games list view model.
|
||||||
|
func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
|
||||||
|
data := adminconsole.GamesListData{
|
||||||
|
Items: make([]adminconsole.GameRow, 0, len(page.Items)),
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Total: page.Total,
|
||||||
|
PrevPage: page.Page - 1,
|
||||||
|
NextPage: page.Page + 1,
|
||||||
|
HasPrev: page.Page > 1,
|
||||||
|
HasNext: page.Page*page.PageSize < page.Total,
|
||||||
|
}
|
||||||
|
for _, game := range page.Items {
|
||||||
|
data.Items = append(data.Items, adminconsole.GameRow{
|
||||||
|
GameID: game.GameID.String(),
|
||||||
|
GameName: game.GameName,
|
||||||
|
Visibility: game.Visibility,
|
||||||
|
Status: game.Status,
|
||||||
|
Owner: ownerLabel(game.OwnerUserID),
|
||||||
|
Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)),
|
||||||
|
TurnSchedule: game.TurnSchedule,
|
||||||
|
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGameDetailData maps a game record and optional runtime record into the
|
||||||
|
// detail view model.
|
||||||
|
func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
|
||||||
|
data := adminconsole.GameDetailData{
|
||||||
|
GameID: game.GameID.String(),
|
||||||
|
GameName: game.GameName,
|
||||||
|
Description: game.Description,
|
||||||
|
Visibility: game.Visibility,
|
||||||
|
Status: game.Status,
|
||||||
|
Owner: ownerLabel(game.OwnerUserID),
|
||||||
|
MinPlayers: game.MinPlayers,
|
||||||
|
MaxPlayers: game.MaxPlayers,
|
||||||
|
StartGapHours: game.StartGapHours,
|
||||||
|
StartGapPlayers: game.StartGapPlayers,
|
||||||
|
TurnSchedule: game.TurnSchedule,
|
||||||
|
TargetEngineVersion: game.TargetEngineVersion,
|
||||||
|
EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
|
||||||
|
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||||
|
StartedAt: fmtConsoleTimePtr(game.StartedAt),
|
||||||
|
FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
|
||||||
|
}
|
||||||
|
if rec != nil {
|
||||||
|
data.HasRuntime = true
|
||||||
|
data.RuntimeStatus = rec.Status
|
||||||
|
data.CurrentEngineVersion = rec.CurrentEngineVersion
|
||||||
|
data.EngineHealth = rec.EngineHealth
|
||||||
|
data.CurrentTurn = rec.CurrentTurn
|
||||||
|
data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
|
||||||
|
data.Paused = rec.Paused
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// toEngineVersionsData maps engine versions into the registry view model.
|
||||||
|
func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
|
||||||
|
data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
|
||||||
|
for _, v := range items {
|
||||||
|
data.Items = append(data.Items, adminconsole.EngineVersionRow{
|
||||||
|
Version: v.Version,
|
||||||
|
ImageRef: v.ImageRef,
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
CreatedAt: fmtConsoleTime(v.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerLabel renders an optional owner id; public games have no owner.
|
||||||
|
func ownerLabel(ownerID *uuid.UUID) string {
|
||||||
|
if ownerID == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return ownerID.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
"galaxy/backend/internal/runtime"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeGameAdmin struct {
|
||||||
|
page lobby.GamePage
|
||||||
|
game lobby.GameRecord
|
||||||
|
getErr error
|
||||||
|
created lobby.CreateGameInput
|
||||||
|
|
||||||
|
createCalls int
|
||||||
|
forceStartCalls int
|
||||||
|
forceStopCalls int
|
||||||
|
banCalls int
|
||||||
|
lastBanUser uuid.UUID
|
||||||
|
lastBanReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
|
||||||
|
return f.page, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||||
|
return f.game, f.getErr
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
|
||||||
|
f.createCalls++
|
||||||
|
f.created = in
|
||||||
|
return f.game, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||||
|
f.forceStartCalls++
|
||||||
|
return f.game, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||||
|
f.forceStopCalls++
|
||||||
|
return f.game, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
|
||||||
|
f.banCalls++
|
||||||
|
f.lastBanUser = userID
|
||||||
|
f.lastBanReason = reason
|
||||||
|
return lobby.Membership{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRuntimeAdmin struct {
|
||||||
|
record runtime.RuntimeRecord
|
||||||
|
getErr error
|
||||||
|
restartCalls int
|
||||||
|
forceNextCalls int
|
||||||
|
patchCalls int
|
||||||
|
lastPatchVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
|
||||||
|
return f.record, f.getErr
|
||||||
|
}
|
||||||
|
func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
||||||
|
f.restartCalls++
|
||||||
|
return runtime.OperationLog{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
|
||||||
|
f.patchCalls++
|
||||||
|
f.lastPatchVersion = target
|
||||||
|
return runtime.OperationLog{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
||||||
|
f.forceNextCalls++
|
||||||
|
return runtime.OperationLog{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeEngineVersionAdmin struct {
|
||||||
|
list []runtime.EngineVersion
|
||||||
|
registered runtime.RegisterInput
|
||||||
|
registerCalls int
|
||||||
|
disableCalls int
|
||||||
|
lastDisabled string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
|
||||||
|
return f.list, nil
|
||||||
|
}
|
||||||
|
func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
|
||||||
|
f.registerCalls++
|
||||||
|
f.registered = in
|
||||||
|
return runtime.EngineVersion{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
|
||||||
|
f.disableCalls++
|
||||||
|
f.lastDisabled = version
|
||||||
|
return runtime.EngineVersion{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||||
|
t.Helper()
|
||||||
|
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
||||||
|
CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
return handler, csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGamesList(t *testing.T) {
|
||||||
|
games := &fakeGameAdmin{page: lobby.GamePage{
|
||||||
|
Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
|
||||||
|
Page: 1, PageSize: 50, Total: 1,
|
||||||
|
}}
|
||||||
|
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("games list missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameDetailWithRuntime(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
|
||||||
|
rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("game detail missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameDetailNoRuntime(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
|
||||||
|
rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
|
||||||
|
router, _ := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "No runtime record") {
|
||||||
|
t.Error("expected a no-runtime note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameCreate(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") +
|
||||||
|
"&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
|
||||||
|
"&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
|
||||||
|
rec := consolePost(t, router, "/_gm/games", form)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
|
||||||
|
t.Errorf("redirect = %q, want detail page", got)
|
||||||
|
}
|
||||||
|
if games.createCalls != 1 {
|
||||||
|
t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
|
||||||
|
}
|
||||||
|
if games.created.Visibility != lobby.VisibilityPublic {
|
||||||
|
t.Errorf("visibility = %q, want public", games.created.Visibility)
|
||||||
|
}
|
||||||
|
if games.created.GameName != "Nova" {
|
||||||
|
t.Errorf("game name = %q", games.created.GameName)
|
||||||
|
}
|
||||||
|
if games.created.EnrollmentEndsAt.Year() != 2030 {
|
||||||
|
t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
|
||||||
|
}
|
||||||
|
if games.created.OwnerUserID != nil {
|
||||||
|
t.Error("public game must have a nil owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameForceStart(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if games.forceStartCalls != 1 {
|
||||||
|
t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if games.forceStartCalls != 0 {
|
||||||
|
t.Error("force-start must not run without a CSRF token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameBanMember(t *testing.T) {
|
||||||
|
gameID := uuid.New()
|
||||||
|
target := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
|
||||||
|
t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
|
||||||
|
gameID := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if games.banCalls != 0 {
|
||||||
|
t.Error("ban must not run with an invalid user id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleRuntimePatch(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
rt := &fakeRuntimeAdmin{}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
|
||||||
|
t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
rt := &fakeRuntimeAdmin{}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if rt.patchCalls != 0 {
|
||||||
|
t.Error("patch must not run without a target version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleEngineVersions(t *testing.T) {
|
||||||
|
ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/engine-versions")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("engine versions page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
|
||||||
|
t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("disable status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
|
||||||
|
t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGamesUnavailable(t *testing.T) {
|
||||||
|
router, _ := newGamesConsoleRouter(t, nil, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games")
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/mail"
|
||||||
|
"galaxy/backend/internal/notification"
|
||||||
|
"galaxy/backend/internal/server/clientip"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailAdmin is the subset of the mail service the console uses.
|
||||||
|
type MailAdmin interface {
|
||||||
|
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
|
||||||
|
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||||
|
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
|
||||||
|
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||||
|
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationAdmin is the subset of the notification service the console uses.
|
||||||
|
type NotificationAdmin interface {
|
||||||
|
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
|
||||||
|
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
|
||||||
|
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiplomailAdmin is the subset of the diplomail service the console uses.
|
||||||
|
type DiplomailAdmin interface {
|
||||||
|
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleSnapshotPageSize = 50
|
||||||
|
|
||||||
|
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
|
||||||
|
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.mail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||||
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list deliveries", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
|
||||||
|
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.mail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, mail.ErrDeliveryNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: get delivery", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list attempts", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
|
||||||
|
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.mail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/mail/deliveries/" + deliveryID.String()
|
||||||
|
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
|
||||||
|
h.logger.Error("admin console: resend delivery", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
|
||||||
|
// and malformed intents on one overview page.
|
||||||
|
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.notifications == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list notifications", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list malformed intents", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastForm renders GET /_gm/broadcast.
|
||||||
|
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.diplomail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
|
||||||
|
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.diplomail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
|
||||||
|
CallerUsername: username,
|
||||||
|
Scope: strings.TrimSpace(c.PostForm("scope")),
|
||||||
|
GameIDs: gameIDs,
|
||||||
|
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
|
||||||
|
Subject: strings.TrimSpace(c.PostForm("subject")),
|
||||||
|
Body: c.PostForm("body"),
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, diplomail.ErrInvalidInput) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: broadcast", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
|
||||||
|
// console message page on failure.
|
||||||
|
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
|
||||||
|
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
|
||||||
|
func parseGameIDList(raw string) ([]uuid.UUID, error) {
|
||||||
|
fields := strings.Split(raw, ",")
|
||||||
|
ids := make([]uuid.UUID, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := uuid.Parse(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, parsed)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
|
||||||
|
data := adminconsole.MailData{
|
||||||
|
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
|
||||||
|
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
|
||||||
|
Page: deliveries.Page,
|
||||||
|
PageSize: deliveries.PageSize,
|
||||||
|
Total: deliveries.Total,
|
||||||
|
PrevPage: deliveries.Page - 1,
|
||||||
|
NextPage: deliveries.Page + 1,
|
||||||
|
HasPrev: deliveries.Page > 1,
|
||||||
|
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
|
||||||
|
}
|
||||||
|
for _, d := range deliveries.Items {
|
||||||
|
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
|
||||||
|
DeliveryID: d.DeliveryID.String(),
|
||||||
|
Template: d.TemplateID,
|
||||||
|
Status: d.Status,
|
||||||
|
Attempts: d.Attempts,
|
||||||
|
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||||
|
Created: fmtConsoleTime(d.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, d := range dead.Items {
|
||||||
|
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
|
||||||
|
DeliveryID: d.DeliveryID.String(),
|
||||||
|
Reason: d.Reason,
|
||||||
|
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
|
||||||
|
detail := adminconsole.MailDeliveryDetail{
|
||||||
|
DeliveryID: d.DeliveryID.String(),
|
||||||
|
Template: d.TemplateID,
|
||||||
|
Status: d.Status,
|
||||||
|
Attempts: d.Attempts,
|
||||||
|
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||||
|
LastError: d.LastError,
|
||||||
|
Created: fmtConsoleTime(d.CreatedAt),
|
||||||
|
Sent: fmtConsoleTimePtr(d.SentAt),
|
||||||
|
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
|
||||||
|
CanResend: d.Status != mail.StatusSent,
|
||||||
|
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
|
||||||
|
}
|
||||||
|
for _, a := range attempts {
|
||||||
|
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
|
||||||
|
AttemptNo: a.AttemptNo,
|
||||||
|
Outcome: a.Outcome,
|
||||||
|
Started: fmtConsoleTime(a.StartedAt),
|
||||||
|
Finished: fmtConsoleTimePtr(a.FinishedAt),
|
||||||
|
Error: a.Error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
|
||||||
|
data := adminconsole.NotificationsData{
|
||||||
|
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
|
||||||
|
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
|
||||||
|
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
|
||||||
|
}
|
||||||
|
for _, n := range notifications.Items {
|
||||||
|
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
|
||||||
|
NotificationID: n.NotificationID.String(),
|
||||||
|
Kind: n.Kind,
|
||||||
|
UserID: optionalUUID(n.UserID),
|
||||||
|
Created: fmtConsoleTime(n.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, d := range dead.Items {
|
||||||
|
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
|
||||||
|
NotificationID: d.NotificationID.String(),
|
||||||
|
RouteID: d.RouteID.String(),
|
||||||
|
Reason: d.Reason,
|
||||||
|
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, m := range malformed.Items {
|
||||||
|
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
|
||||||
|
ID: m.ID.String(),
|
||||||
|
Reason: m.Reason,
|
||||||
|
Received: fmtConsoleTime(m.ReceivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalUUID renders a nullable user id; system-scoped rows have none.
|
||||||
|
func optionalUUID(id *uuid.UUID) string {
|
||||||
|
if id == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user