Compare commits
32 Commits
main
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c1e7ab24 | |||
| 2d36b54b8d | |||
| 9f7c9099bc | |||
| e22f4b7800 | |||
| 362f92e520 | |||
| b3f24cc440 | |||
| 535e27008f | |||
| 77cb7c78b6 | |||
| 1a0e3e992f | |||
| faf598b2cd | |||
| 6e6186a571 | |||
| e3bb30201d | |||
| 7ff81de2b6 | |||
| 9d65bf5157 | |||
| 1855e43699 | |||
| 7bce67462c | |||
| 2be7e5c110 | |||
| 2a95bf4a50 | |||
| fd071260ec | |||
| 8058f26397 | |||
| 660044559c | |||
| 9135991887 | |||
| bb74e3336e | |||
| 4a88b24f4b | |||
| fe8ad6a02a | |||
| 9ebb2e7f0f | |||
| 0da360a644 | |||
| 6686059535 | |||
| c6c5f3c8dd | |||
| f00c8efd18 | |||
| f316952c12 | |||
| 00c79064fc |
@@ -0,0 +1,31 @@
|
||||
name: Deploy · Prod
|
||||
|
||||
# Placeholder for the production rollout workflow. Today it only proves
|
||||
# the manual entry point works; the actual `docker save | ssh prod
|
||||
# docker load` + remote `docker compose up -d` pipeline is wired in
|
||||
# once the production host, SSH credentials, and DNS are decided.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to deploy (commit-<sha12>, produced by prod-build.yaml)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Announce target
|
||||
run: |
|
||||
echo "Would deploy image tag: ${{ inputs.image_tag }}"
|
||||
echo "TODO:"
|
||||
echo " 1. Download galaxy-images-${{ inputs.image_tag }} from prod-build artifacts."
|
||||
echo " 2. scp the .tar.gz bundles to the production host."
|
||||
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
|
||||
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
|
||||
echo " 5. Probe https://api.galaxy.com/healthz and roll back on failure."
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Deploy · Dev
|
||||
|
||||
# Builds the Galaxy stack and (re)deploys it into the long-lived dev
|
||||
# environment on the host running this Gitea Actions runner. Triggered
|
||||
# on every merge into `development`. Branch protections on `development`
|
||||
# guarantee the commit already passed `go-unit`, `ui-test`, and
|
||||
# `integration` as part of the PR that produced this push, so this
|
||||
# workflow does not re-run those tests — it focuses on packaging and
|
||||
# rollout.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- 'tools/dev-deploy/**'
|
||||
- '.gitea/workflows/dev-deploy.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build UI frontend
|
||||
working-directory: ui/frontend
|
||||
env:
|
||||
VITE_GATEWAY_BASE_URL: https://api.galaxy.lan
|
||||
run: |
|
||||
# The response-signing public key is committed in
|
||||
# `.env.development` alongside its private counterpart in
|
||||
# `tools/local-dev/keys/`. Pull it from there at build time so
|
||||
# the production-mode bundle ships the same key the dev
|
||||
# gateway uses to sign.
|
||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||
pnpm build
|
||||
|
||||
- name: Build galaxy-engine image
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
docker build \
|
||||
-t galaxy-engine:dev \
|
||||
-f game/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Build backend + gateway images
|
||||
working-directory: tools/dev-deploy
|
||||
run: |
|
||||
docker compose build galaxy-backend galaxy-api
|
||||
|
||||
- name: Seed UI volume
|
||||
run: |
|
||||
docker volume create galaxy-dev-ui-dist >/dev/null
|
||||
docker run --rm \
|
||||
-v galaxy-dev-ui-dist:/dst \
|
||||
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||
|
||||
- name: Bring up the stack
|
||||
working-directory: tools/dev-deploy
|
||||
run: |
|
||||
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||
# is empty at the workflow-evaluation stage.
|
||||
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||
docker compose up -d --wait --remove-orphans
|
||||
|
||||
- name: Probe the stack
|
||||
run: |
|
||||
set -e
|
||||
# Use --resolve so the probe goes through the same routing as
|
||||
# a browser on the host: the host Caddy on :443 (which has
|
||||
# `tls internal`) terminates and forwards into the edge
|
||||
# network. We accept the host's internal CA via -k because
|
||||
# the runner image has no reason to trust it.
|
||||
curl -sk --max-time 10 https://api.galaxy.lan/healthz \
|
||||
| tee /tmp/healthz
|
||||
test -s /tmp/healthz
|
||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||
https://www.galaxy.lan/ | tee /tmp/www_status
|
||||
grep -qE '^(200|304)$' /tmp/www_status
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Tests · Go
|
||||
|
||||
# Fast unit tests for the Go side of the monorepo. Runs on every push
|
||||
# and pull request whose path filter matches a Go source directory.
|
||||
# The integration suite (testcontainers-driven, slow) lives in
|
||||
# `integration.yaml` and only fires for PRs into `development`/`main`
|
||||
# and pushes to `development`.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run Go tests
|
||||
# client/ is the deprecated Fyne client; excluded from CI per
|
||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||
# run never depends on a previous runner's cached state. The
|
||||
# backend suite is run with -p 1 because most backend packages
|
||||
# spawn their own Postgres testcontainer, and parallel
|
||||
# Postgres bootstraps starve each other on a constrained
|
||||
# runner. pkg modules are listed one by one because ./pkg/...
|
||||
# does not recurse across the independent go.work modules
|
||||
# under pkg/.
|
||||
run: |
|
||||
go test -count=1 -p 1 ./backend/...
|
||||
go test -count=1 \
|
||||
./gateway/... \
|
||||
./game/... \
|
||||
./ui/core/... \
|
||||
./pkg/calc/... \
|
||||
./pkg/connector/... \
|
||||
./pkg/cronutil/... \
|
||||
./pkg/error/... \
|
||||
./pkg/geoip/... \
|
||||
./pkg/model/... \
|
||||
./pkg/postgres/... \
|
||||
./pkg/redisconn/... \
|
||||
./pkg/schema/... \
|
||||
./pkg/storage/... \
|
||||
./pkg/transcoder/... \
|
||||
./pkg/util/...
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Tests · Integration
|
||||
|
||||
# Full integration suite (testcontainers-driven, ~5–10 minutes). Heavy
|
||||
# enough that we do not run it on every push to a feature branch — only
|
||||
# when there is an open PR aimed at `development`/`main`, or after a
|
||||
# merge into `development`. The unit jobs (`go-unit.yaml`,
|
||||
# `ui-test.yaml`) keep guarding fast feedback on every push.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- development
|
||||
- main
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'integration/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/integration.yaml'
|
||||
- '!**/*.md'
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'integration/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/integration.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run integration suite
|
||||
# `make integration` precleans leftover docker-compose state and
|
||||
# then runs every test under integration/ serially (-p=1
|
||||
# -parallel=1, 15-minute per-test timeout). Testcontainers
|
||||
# reaches the host's docker daemon via the socket Gitea exposes
|
||||
# to the runner; the workflow inherits the same access the
|
||||
# runner has.
|
||||
run: make -C integration integration
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Build · Prod
|
||||
|
||||
# Builds the production-grade Docker images and the UI bundle on every
|
||||
# merge into `main`, then saves the artifacts so a future
|
||||
# `deploy-prod.yaml` run can ship them to the production host. This
|
||||
# workflow does not deploy anything by itself — production rollout is
|
||||
# strictly manual (workflow_dispatch on `deploy-prod.yaml`).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/prod-build.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
short_sha=$(git rev-parse --short=12 HEAD)
|
||||
echo "tag=commit-${short_sha}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build backend image
|
||||
run: |
|
||||
docker build \
|
||||
-t "galaxy/backend:${{ steps.tag.outputs.tag }}" \
|
||||
-f backend/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Build gateway image
|
||||
run: |
|
||||
docker build \
|
||||
-t "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
|
||||
-f gateway/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Build engine image
|
||||
run: |
|
||||
docker build \
|
||||
-t "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
|
||||
-f game/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build UI bundle
|
||||
working-directory: ui/frontend
|
||||
env:
|
||||
VITE_GATEWAY_BASE_URL: https://api.galaxy.com
|
||||
run: |
|
||||
# Production response-signing public key is not in the repo
|
||||
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
||||
# only). When real prod keys exist, source them from a Gitea
|
||||
# Actions secret and set VITE_GATEWAY_RESPONSE_PUBLIC_KEY
|
||||
# here. Until then the prod bundle compiles with the dev
|
||||
# key as a placeholder so the artifact exists.
|
||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||
pnpm build
|
||||
|
||||
- name: Save images as artifact bundles
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
docker save "galaxy/backend:${{ steps.tag.outputs.tag }}" \
|
||||
| gzip >"artifacts/backend-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||
docker save "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
|
||||
| gzip >"artifacts/gateway-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||
docker save "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
|
||||
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||
tar -C ui/frontend -czf \
|
||||
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
||||
|
||||
- name: Upload images
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: galaxy-images-${{ steps.tag.outputs.tag }}
|
||||
path: artifacts/*.tar.gz
|
||||
retention-days: 30
|
||||
@@ -1,148 +0,0 @@
|
||||
name: ui-release
|
||||
|
||||
# Tier 2 (release) workflow. Runs on tag push.
|
||||
#
|
||||
# Currently mirrors the Tier 1 step set. Visual regression baseline
|
||||
# checks and the macOS-runner iOS smoke job are landed in later phases
|
||||
# of ui/PLAN.md and live as commented sections at the end of this file
|
||||
# until those phases ship.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run Go tests
|
||||
# client/ is the deprecated Fyne client; excluded from CI per
|
||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||
# run never depends on a previous runner's cached state. The
|
||||
# backend suite is run with -p 1 because most backend packages
|
||||
# spawn their own Postgres testcontainer, and parallel
|
||||
# Postgres bootstraps starve each other on a constrained
|
||||
# runner. pkg modules are listed one by one because ./pkg/...
|
||||
# does not recurse across the independent go.work modules
|
||||
# under pkg/.
|
||||
run: |
|
||||
go test -count=1 -p 1 ./backend/...
|
||||
go test -count=1 \
|
||||
./gateway/... \
|
||||
./game/... \
|
||||
./ui/core/... \
|
||||
./pkg/calc/... \
|
||||
./pkg/connector/... \
|
||||
./pkg/cronutil/... \
|
||||
./pkg/error/... \
|
||||
./pkg/geoip/... \
|
||||
./pkg/model/... \
|
||||
./pkg/postgres/... \
|
||||
./pkg/redisconn/... \
|
||||
./pkg/schema/... \
|
||||
./pkg/storage/... \
|
||||
./pkg/transcoder/... \
|
||||
./pkg/util/...
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Vitest
|
||||
working-directory: ui/frontend
|
||||
run: pnpm test
|
||||
|
||||
- name: Run Playwright
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ui/frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload Playwright traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-traces
|
||||
path: ui/frontend/test-results/
|
||||
retention-days: 14
|
||||
|
||||
# visual-regression: enabled in Phase 33 of ui/PLAN.md, once the PWA
|
||||
# shell and service worker land and a snapshot baseline is committed
|
||||
# under ui/frontend/tests/__snapshots__/.
|
||||
#
|
||||
# visual-regression:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: pnpm/action-setup@v4
|
||||
# with: { version: 11.0.7 }
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 22
|
||||
# cache: pnpm
|
||||
# cache-dependency-path: ui/pnpm-lock.yaml
|
||||
# - working-directory: ui
|
||||
# run: pnpm install --frozen-lockfile
|
||||
# - working-directory: ui/frontend
|
||||
# run: pnpm exec playwright install --with-deps
|
||||
# - working-directory: ui/frontend
|
||||
# run: pnpm exec playwright test --grep @visual
|
||||
|
||||
# ios-smoke: enabled in Phase 32 of ui/PLAN.md, once the Capacitor
|
||||
# wrapper lands. Runs a Capacitor + Appium smoke against an iOS
|
||||
# simulator on a macOS runner.
|
||||
#
|
||||
# ios-smoke:
|
||||
# runs-on: macos-13
|
||||
# needs: test
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: pnpm/action-setup@v4
|
||||
# with: { version: 11.0.7 }
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 22
|
||||
# cache: pnpm
|
||||
# cache-dependency-path: ui/pnpm-lock.yaml
|
||||
# - working-directory: ui
|
||||
# run: pnpm install --frozen-lockfile
|
||||
# - working-directory: ui/mobile
|
||||
# run: pnpm exec cap sync ios && pnpm exec appium-smoke ios
|
||||
@@ -1,41 +1,33 @@
|
||||
name: ui-test
|
||||
name: Tests · UI
|
||||
|
||||
# Tier 1 (per-PR) workflow. Runs Vitest + Playwright for the UI client and
|
||||
# the monorepo Go service tests (everything except the integration suite,
|
||||
# which lives behind `make -C integration integration` and needs a Docker
|
||||
# daemon set up for testcontainers).
|
||||
#
|
||||
# The path filter is intentionally broad until a dedicated go-test
|
||||
# workflow is introduced; this is the only CI gate today.
|
||||
# UI-side unit and end-to-end tests (Vitest + Playwright). The Go side
|
||||
# of the workspace is tested in `go-unit.yaml`. Both workflows can run
|
||||
# in parallel for a push that touches Go and UI together.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/ui-test.yaml'
|
||||
# Skip docs-only commits. Negation removes pure markdown changes;
|
||||
# mixed commits (code + .md) still match a positive pattern above
|
||||
# and trigger the workflow. Image and other binary asset paths
|
||||
# are already outside the positive list.
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/ui-test.yaml'
|
||||
- '!**/*.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:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,41 +40,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run Go tests
|
||||
# client/ is the deprecated Fyne client; excluded from CI per
|
||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||
# run never depends on a previous runner's cached state. The
|
||||
# backend suite is run with -p 1 because most backend packages
|
||||
# spawn their own Postgres testcontainer, and parallel
|
||||
# Postgres bootstraps starve each other on a constrained
|
||||
# runner. pkg modules are listed one by one because ./pkg/...
|
||||
# does not recurse across the independent go.work modules
|
||||
# under pkg/.
|
||||
run: |
|
||||
go test -count=1 -p 1 ./backend/...
|
||||
go test -count=1 \
|
||||
./gateway/... \
|
||||
./game/... \
|
||||
./ui/core/... \
|
||||
./pkg/calc/... \
|
||||
./pkg/connector/... \
|
||||
./pkg/cronutil/... \
|
||||
./pkg/error/... \
|
||||
./pkg/geoip/... \
|
||||
./pkg/model/... \
|
||||
./pkg/postgres/... \
|
||||
./pkg/redisconn/... \
|
||||
./pkg/schema/... \
|
||||
./pkg/storage/... \
|
||||
./pkg/transcoder/... \
|
||||
./pkg/util/...
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -100,13 +57,29 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
# `--with-deps` would shell out to `sudo apt-get install` for
|
||||
# the system .so libraries, which the host-mode runner cannot
|
||||
# run non-interactively. The host has the deps installed once,
|
||||
# globally; we only need to fetch the browser binaries here.
|
||||
# If a future run fails with missing libraries, install them
|
||||
# on the host via `pnpm exec playwright install-deps` (one
|
||||
# shot, requires sudo).
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright install --with-deps
|
||||
run: pnpm exec playwright install
|
||||
|
||||
- name: Run Vitest
|
||||
working-directory: ui/frontend
|
||||
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
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright test
|
||||
|
||||
@@ -34,32 +34,47 @@ This repository hosts the Galaxy Game project.
|
||||
deeper than what fits in `README.md` (per-feature design notes,
|
||||
protocol specs, runbooks). Not stage-by-stage history.
|
||||
|
||||
## Branching and CI flow
|
||||
|
||||
Branches:
|
||||
|
||||
- `main` — production-track. Direct pushes are disallowed; the only
|
||||
way in is a PR merge from `development`. A merge fires
|
||||
`prod-build.yaml` which packages the artifacts; production rollout
|
||||
is manual through `deploy-prod.yaml`.
|
||||
- `development` — long-lived dev integration branch. Every merge into
|
||||
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
||||
(reachable at `https://www.galaxy.lan` / `https://api.galaxy.lan`).
|
||||
- `feature/*` — short-lived branches off `development`. Merged back
|
||||
via PR; only then do they reach the dev environment.
|
||||
|
||||
Workflows in `.gitea/workflows/`:
|
||||
|
||||
| File | Trigger | What it does |
|
||||
|------|---------|--------------|
|
||||
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
||||
| `dev-deploy.yaml` | push to `development` | Build images + (re)deploy to `tools/dev-deploy/`. |
|
||||
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
|
||||
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
|
||||
|
||||
## Per-stage CI gate
|
||||
|
||||
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
|
||||
must be exercised on the local Gitea Actions runner before being
|
||||
declared done. The runbook lives in `tools/local-ci/README.md`; the
|
||||
short version is:
|
||||
must be exercised on `gitea.lan` before being declared done. The
|
||||
short version:
|
||||
|
||||
1. Commit the stage changes.
|
||||
2. `make -C tools/local-ci push` — pushes `HEAD` to the local Gitea
|
||||
instance and triggers every workflow that matches the changed
|
||||
paths.
|
||||
3. Poll the latest run via the API snippet in `ui/docs/testing.md`
|
||||
(or the Gitea UI on `http://localhost:3000`) until it leaves
|
||||
1. Commit the stage changes on the feature branch.
|
||||
2. `git push gitea …` to publish the branch.
|
||||
3. Poll the latest run in the Gitea UI (or the API) until it leaves
|
||||
`running`. Inspect the log on failure.
|
||||
4. Only after the run is `success` may the stage be marked done in
|
||||
the corresponding `PLAN.md`.
|
||||
4. Only after every workflow that fired is `success` may the stage be
|
||||
marked done in the corresponding `PLAN.md`.
|
||||
|
||||
This applies even when the local unit-test suite is green —
|
||||
workflow-only failures (path filters, action-version mismatches,
|
||||
missing secrets, runner-only environment differences) are cheap to
|
||||
catch here and expensive to catch on a remote PR. The push step is
|
||||
implicitly authorised: do not ask for confirmation on every stage.
|
||||
|
||||
If `tools/local-ci` is not running, bring it up first
|
||||
(`make -C tools/local-ci up`); do not skip this gate. The single
|
||||
exception is when the user explicitly waives it for a stage.
|
||||
`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
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ backend/
|
||||
│ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations
|
||||
│ ├── auth/ # email-code challenges, device sessions, Ed25519 keys
|
||||
│ ├── config/ # env-var loader, Validate
|
||||
│ ├── diplomail/ # diplomatic-mail messages, recipients, translations
|
||||
│ ├── dockerclient/ # docker/docker wrapper for container ops
|
||||
│ ├── engineclient/ # net/http client to galaxy-game containers
|
||||
│ ├── geo/ # geoip lookup, declared_country, per-user counters
|
||||
@@ -131,6 +132,12 @@ fast.
|
||||
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
|
||||
| `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. |
|
||||
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
|
||||
| `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` | no | `4096` | Maximum size of `diplomail_messages.body` enforced at send time. Tune at runtime without a migration. |
|
||||
| `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` | no | `256` | Maximum size of `diplomail_messages.subject`. Subject is optional; empty is always accepted. |
|
||||
| `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
|
||||
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
|
||||
|
||||
+315
-1
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// time/tzdata embeds the IANA timezone database so time.LoadLocation
|
||||
// works in container images without /usr/share/zoneinfo (distroless
|
||||
@@ -25,6 +26,9 @@ import (
|
||||
"galaxy/backend/internal/auth"
|
||||
"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/engineclient"
|
||||
"galaxy/backend/internal/geo"
|
||||
@@ -131,6 +135,7 @@ func run(ctx context.Context) (err error) {
|
||||
lobbyCascade := &lobbyCascadeAdapter{}
|
||||
userNotifyCascade := &userNotificationCascadeAdapter{}
|
||||
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
|
||||
lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{}
|
||||
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
|
||||
|
||||
userSvc := user.NewService(user.Deps{
|
||||
@@ -197,6 +202,7 @@ func run(ctx context.Context) (err error) {
|
||||
Cache: lobbyCache,
|
||||
Runtime: runtimeGateway,
|
||||
Notification: lobbyNotifyPublisher,
|
||||
Diplomail: lobbyDiplomailPublisher,
|
||||
Entitlement: &userEntitlementAdapter{svc: userSvc},
|
||||
Config: cfg.Lobby,
|
||||
Logger: logger,
|
||||
@@ -301,6 +307,25 @@ func run(ctx context.Context) (err error) {
|
||||
userNotifyCascade.svc = notifSvc
|
||||
lobbyNotifyPublisher.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 == "" {
|
||||
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
||||
} else {
|
||||
@@ -325,9 +350,11 @@ func run(ctx context.Context) (err error) {
|
||||
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
|
||||
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
|
||||
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
|
||||
adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger)
|
||||
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
|
||||
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
|
||||
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
|
||||
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger)
|
||||
|
||||
ready := func() bool {
|
||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||
@@ -356,9 +383,11 @@ func run(ctx context.Context) (err error) {
|
||||
AdminRuntimes: adminRuntimesHandlers,
|
||||
AdminEngineVersions: adminEngineVersionsHandlers,
|
||||
AdminMail: adminMailHandlers,
|
||||
AdminDiplomail: adminDiplomailHandlers,
|
||||
AdminNotifications: adminNotificationsHandlers,
|
||||
AdminGeo: adminGeoHandlers,
|
||||
UserGames: userGamesHandlers,
|
||||
UserMail: userMailHandlers,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build backend router: %w", err)
|
||||
@@ -374,7 +403,7 @@ func run(ctx context.Context) (err error) {
|
||||
runtimeScheduler := runtimeSvc.SchedulerComponent()
|
||||
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() {
|
||||
components = append(components, metricsServer)
|
||||
}
|
||||
@@ -579,3 +608,288 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co
|
||||
}
|
||||
return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload)
|
||||
}
|
||||
|
||||
// diplomailMembershipAdapter implements `diplomail.MembershipLookup`
|
||||
// by walking the lobby cache (for active rows) and the lobby service
|
||||
// (for any-status rows) and stitching each membership row to the
|
||||
// immutable `accounts.user_name` resolved through `*user.Service`.
|
||||
type diplomailMembershipAdapter struct {
|
||||
lobby *lobby.Service
|
||||
users *user.Service
|
||||
}
|
||||
|
||||
func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
|
||||
if a == nil || a.lobby == nil || a.users == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
cache := a.lobby.Cache()
|
||||
if cache == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := cache.GetGame(gameID)
|
||||
if !ok {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
var found *lobby.Membership
|
||||
for _, m := range cache.MembershipsForGame(gameID) {
|
||||
if m.UserID == userID {
|
||||
mm := m
|
||||
found = &mm
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
account, err := a.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return diplomail.ActiveMembership{}, err
|
||||
}
|
||||
return diplomail.ActiveMembership{
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.users == nil {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||
if !ok {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return diplomail.MemberSnapshot{}, err
|
||||
}
|
||||
var found *lobby.Membership
|
||||
for _, m := range members {
|
||||
if m.UserID == userID {
|
||||
mm := m
|
||||
found = &mm
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
account, err := a.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return diplomail.MemberSnapshot{}, err
|
||||
}
|
||||
return diplomail.MemberSnapshot{
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
Status: found.Status,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.users == nil {
|
||||
return nil, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||
if !ok {
|
||||
return nil, diplomail.ErrNotFound
|
||||
}
|
||||
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := func(status string) bool {
|
||||
switch scope {
|
||||
case diplomail.RecipientScopeActive:
|
||||
return status == lobby.MembershipStatusActive
|
||||
case diplomail.RecipientScopeActiveAndRemoved:
|
||||
return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved
|
||||
case diplomail.RecipientScopeAllMembers:
|
||||
return true
|
||||
default:
|
||||
return status == lobby.MembershipStatusActive
|
||||
}
|
||||
}
|
||||
out := make([]diplomail.MemberSnapshot, 0, len(members))
|
||||
for _, m := range members {
|
||||
if !matches(m.Status) {
|
||||
continue
|
||||
}
|
||||
account, err := a.users.GetAccount(ctx, m.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err)
|
||||
}
|
||||
out = append(out, diplomail.MemberSnapshot{
|
||||
UserID: m.UserID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: m.RaceName,
|
||||
Status: m.Status,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher`
|
||||
// by translating each lobby.LifecycleEvent into the diplomail
|
||||
// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`.
|
||||
// The svc pointer is patched once diplomailSvc exists — diplomail
|
||||
// depends on lobby through MembershipLookup, so the lobby service
|
||||
// is constructed first and patched up.
|
||||
type lobbyDiplomailPublisherAdapter struct {
|
||||
svc *diplomail.Service
|
||||
}
|
||||
|
||||
func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil
|
||||
}
|
||||
return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
|
||||
GameID: ev.GameID,
|
||||
Kind: ev.Kind,
|
||||
Actor: ev.Actor,
|
||||
Reason: ev.Reason,
|
||||
TargetUser: ev.TargetUser,
|
||||
})
|
||||
}
|
||||
|
||||
// buildDiplomailTranslator selects the diplomail translator backend
|
||||
// from configuration: a non-empty `TranslatorURL` constructs the
|
||||
// LibreTranslate HTTP client; an empty URL falls through to the
|
||||
// noop translator so deployments without a translation service still
|
||||
// boot and deliver mail with the fallback path.
|
||||
func buildDiplomailTranslator(cfg config.DiplomailConfig, logger *zap.Logger) (translator.Translator, error) {
|
||||
if cfg.TranslatorURL == "" {
|
||||
logger.Info("diplomail translator URL not configured, using noop translator")
|
||||
return translator.NewNoop(), nil
|
||||
}
|
||||
return translator.NewLibreTranslate(translator.LibreTranslateConfig{
|
||||
URL: cfg.TranslatorURL,
|
||||
Timeout: cfg.TranslatorTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// diplomailEntitlementAdapter implements
|
||||
// `diplomail.EntitlementReader` by reading the user-service
|
||||
// entitlement snapshot. The IsPaid flag mirrors the per-tier policy
|
||||
// defined in `internal/user`, so updates to the tier set (monthly,
|
||||
// yearly, permanent, …) flow through without changes here.
|
||||
type diplomailEntitlementAdapter struct {
|
||||
users *user.Service
|
||||
}
|
||||
|
||||
func (a *diplomailEntitlementAdapter) IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
if a == nil || a.users == nil {
|
||||
return false, nil
|
||||
}
|
||||
snap, err := a.users.GetEntitlementSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return snap.IsPaid, nil
|
||||
}
|
||||
|
||||
// diplomailGameAdapter implements `diplomail.GameLookup`. The
|
||||
// running-games and finished-games queries walk the lobby cache so
|
||||
// the admin multi-game broadcast and bulk-purge endpoints do not
|
||||
// fan out a per-game DB query each time. GetGame falls back to the
|
||||
// cache; an unknown id is surfaced as ErrNotFound (the diplomail
|
||||
// sentinel).
|
||||
type diplomailGameAdapter struct {
|
||||
lobby *lobby.Service
|
||||
}
|
||||
|
||||
func (a *diplomailGameAdapter) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []diplomail.GameSnapshot
|
||||
for _, game := range a.lobby.Cache().ListGames() {
|
||||
if !isRunningStatus(game.Status) {
|
||||
continue
|
||||
}
|
||||
out = append(out, gameSnapshot(game))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *diplomailGameAdapter) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) {
|
||||
if a == nil || a.lobby == nil {
|
||||
return nil, nil
|
||||
}
|
||||
games, err := a.lobby.ListFinishedGamesBefore(ctx, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]diplomail.GameSnapshot, 0, len(games))
|
||||
for _, g := range games {
|
||||
out = append(out, gameSnapshot(g))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *diplomailGameAdapter) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
|
||||
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||
if !ok {
|
||||
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
return gameSnapshot(game), nil
|
||||
}
|
||||
|
||||
func gameSnapshot(g lobby.GameRecord) diplomail.GameSnapshot {
|
||||
out := diplomail.GameSnapshot{
|
||||
GameID: g.GameID,
|
||||
GameName: g.GameName,
|
||||
Status: g.Status,
|
||||
}
|
||||
if g.FinishedAt != nil {
|
||||
f := *g.FinishedAt
|
||||
out.FinishedAt = &f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isRunningStatus(status string) bool {
|
||||
switch status {
|
||||
case lobby.GameStatusReadyToStart, lobby.GameStatusStarting, lobby.GameStatusRunning, lobby.GameStatusPaused:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// diplomailNotificationPublisherAdapter implements
|
||||
// `diplomail.NotificationPublisher` by translating each
|
||||
// DiplomailNotification into a notification.Intent and routing it
|
||||
// through `*notification.Service.Submit`. The publisher leaves the
|
||||
// `diplomail.message.received` catalog entry to handle channel
|
||||
// fan-out (push only in Stage A).
|
||||
type diplomailNotificationPublisherAdapter struct {
|
||||
svc *notification.Service
|
||||
}
|
||||
|
||||
func (a *diplomailNotificationPublisherAdapter) PublishDiplomailEvent(ctx context.Context, ev diplomail.DiplomailNotification) error {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil
|
||||
}
|
||||
intent := notification.Intent{
|
||||
Kind: ev.Kind,
|
||||
IdempotencyKey: ev.IdempotencyKey,
|
||||
Recipients: []uuid.UUID{ev.Recipient},
|
||||
Payload: ev.Payload,
|
||||
}
|
||||
_, err := a.svc.Submit(ctx, intent)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# LibreTranslate setup for diplomatic mail
|
||||
|
||||
This document describes how to run the LibreTranslate backend that the
|
||||
diplomatic-mail subsystem uses for body translation. The instructions
|
||||
target three audiences: developers spinning up LibreTranslate
|
||||
alongside `tools/local-dev`, operators preparing a real deployment,
|
||||
and reviewers verifying the end-to-end translation flow by hand.
|
||||
|
||||
## When you need LibreTranslate
|
||||
|
||||
The diplomatic-mail worker runs unconditionally — `make up` and `make
|
||||
test` both work without any translator. With
|
||||
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator
|
||||
short-circuits the pipeline: messages are delivered in the original
|
||||
language, and the inbox handler returns the original body to every
|
||||
reader.
|
||||
|
||||
You only need LibreTranslate when you want to exercise the cross-
|
||||
language path: sender writes in language X, recipient's
|
||||
`accounts.preferred_language` is Y, the worker is expected to fetch
|
||||
a Y rendering. The pipeline is otherwise identical and unaware of
|
||||
which engine is producing translations.
|
||||
|
||||
## Running a local instance
|
||||
|
||||
LibreTranslate ships a public Docker image at
|
||||
`libretranslate/libretranslate`. The image is ~3 GB on first pull
|
||||
because it bundles every supported language model; subsequent runs
|
||||
reuse the layer cache.
|
||||
|
||||
The simplest setup is a one-shot container:
|
||||
|
||||
```bash
|
||||
docker run --rm -d --name libretranslate \
|
||||
-p 5000:5000 \
|
||||
-e LT_LOAD_ONLY=en,ru \
|
||||
libretranslate/libretranslate:latest
|
||||
```
|
||||
|
||||
The `LT_LOAD_ONLY` whitelist trims the loaded model set so the
|
||||
container fits in ~600 MB of RAM. Drop the variable to load every
|
||||
language pair LibreTranslate ships.
|
||||
|
||||
LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm
|
||||
model cache). Wait until `curl -s http://localhost:5000/languages`
|
||||
returns a JSON array before pointing backend at it.
|
||||
|
||||
## Wiring backend at it
|
||||
|
||||
Add three env vars to the backend process:
|
||||
|
||||
```
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5
|
||||
```
|
||||
|
||||
When backend lives inside the `tools/local-dev` Docker network and
|
||||
LibreTranslate runs on the host, replace `localhost` with the host's
|
||||
docker-bridge address (`http://host.docker.internal:5000` on
|
||||
Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by
|
||||
default).
|
||||
|
||||
For a stack-internal deployment, drop LibreTranslate into the same
|
||||
Docker compose file alongside backend and reach it by its service
|
||||
name:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
environment:
|
||||
LT_LOAD_ONLY: "en,ru"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 12
|
||||
|
||||
backend:
|
||||
environment:
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000"
|
||||
depends_on:
|
||||
libretranslate:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
## Manual smoke test
|
||||
|
||||
Once both services are up:
|
||||
|
||||
1. Register two accounts via the public auth flow. Set the second
|
||||
account's `preferred_language` to a value that differs from the
|
||||
sender's writing language (e.g. sender writes in English, second
|
||||
account is `ru`).
|
||||
2. Create a private game with the first account, invite the second,
|
||||
land both as active members.
|
||||
3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages`
|
||||
with the body in English.
|
||||
4. Watch backend logs for the diplomail worker. After ~2 seconds you
|
||||
should see `translator attempt succeeded` (or equivalent INFO
|
||||
line) and the recipient flipped to `available_at`.
|
||||
5. As the second account, fetch
|
||||
`GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The
|
||||
response should carry both `body` (English original) and
|
||||
`translated_body` (Russian) along with the `translation_lang`
|
||||
and `translator` fields.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks
|
||||
around 800 MB resident; with all languages, ~3 GB. Plan accordingly.
|
||||
- **CPU.** LibreTranslate is CPU-bound. One translation of a 200-
|
||||
word body takes ~200 ms on a modern x86 core; the diplomail worker
|
||||
is single-threaded by design, so steady-state throughput is
|
||||
`1 / avg_latency` per backend instance.
|
||||
- **Outage behaviour.** A LibreTranslate outage stalls delivery of
|
||||
pending pairs by at most ~31 seconds per pair (the worker's
|
||||
exponential backoff schedule), then falls back to the original
|
||||
body. Inbox listings never depend on the translator's
|
||||
availability.
|
||||
- **API key.** Backend does not send an API key. Self-hosted
|
||||
deployments without `LT_API_KEYS` configured accept anonymous
|
||||
POSTs by default, which matches our deployment posture
|
||||
(LibreTranslate sits on the internal docker network, not
|
||||
reachable from outside).
|
||||
- **Models.** Adding a new target language is an operator-side
|
||||
task: install the corresponding Argos model into the
|
||||
LibreTranslate container (`argospm install …`) and either restart
|
||||
the container or send a SIGHUP. The diplomail pipeline notices
|
||||
the new language pair automatically — there is no allow-list
|
||||
inside backend.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`translator: do request: dial tcp ...: connect: connection refused`.**
|
||||
LibreTranslate is not listening on the configured address. Verify
|
||||
with `curl http://${URL}/languages`. On Docker setups, double-
|
||||
check the bridge address discussion above.
|
||||
- **`translator: libretranslate http 400`** in worker logs but the
|
||||
language pair clearly exists.
|
||||
Make sure the request used the two-letter codes (`en`, not
|
||||
`en-US`). Backend normalises before sending; if you see a region
|
||||
subtag in the log, file an issue against `internal/diplomail` —
|
||||
the normalisation should be unconditional.
|
||||
- **`translator: libretranslate http 503`.**
|
||||
Container is still loading models. Wait for `/languages` to
|
||||
respond `200`. The worker retries with backoff, so steady-state
|
||||
recovers automatically.
|
||||
- **Worker logs only "noop translator returned, delivering
|
||||
fallback".**
|
||||
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend
|
||||
process. Confirm with `docker compose exec backend env | grep
|
||||
DIPLOMAIL`.
|
||||
|
||||
## Future work
|
||||
|
||||
- Adding an OpenTelemetry counter and histogram for translator
|
||||
outcomes is tracked in the diplomail package README; the metrics
|
||||
will surface in Grafana once LibreTranslate is deployed.
|
||||
- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures
|
||||
in M minutes) is planned through a new
|
||||
`diplomail.translator.unhealthy` notification kind. Not wired
|
||||
yet — current monitoring lives in zap logs.
|
||||
@@ -36,6 +36,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/abadojack/whatlanggo v1.0.1 // indirect
|
||||
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
|
||||
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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
|
||||
@@ -96,6 +96,13 @@ const (
|
||||
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
||||
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
||||
|
||||
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
|
||||
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
|
||||
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
|
||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||
|
||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
||||
@@ -163,6 +170,12 @@ const (
|
||||
defaultNotificationWorkerInterval = 5 * time.Second
|
||||
defaultNotificationMaxAttempts = 8
|
||||
|
||||
defaultDiplomailMaxBodyBytes = 4096
|
||||
defaultDiplomailMaxSubjectBytes = 256
|
||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||
defaultDiplomailTranslatorMaxAttempts = 5
|
||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||
|
||||
defaultDevSandboxEngineVersion = "0.1.0"
|
||||
defaultDevSandboxPlayerCount = 20
|
||||
)
|
||||
@@ -201,6 +214,7 @@ type Config struct {
|
||||
Engine EngineConfig
|
||||
Runtime RuntimeConfig
|
||||
Notification NotificationConfig
|
||||
Diplomail DiplomailConfig
|
||||
DevSandbox DevSandboxConfig
|
||||
|
||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||
@@ -397,6 +411,42 @@ type RuntimeConfig struct {
|
||||
StopGracePeriod time.Duration
|
||||
}
|
||||
|
||||
// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
|
||||
// are enforced in the service layer, so they can be tuned at runtime
|
||||
// without a schema migration. Body and subject are stored as plain
|
||||
// UTF-8 text; HTML is neither parsed nor sanitised on the server.
|
||||
type DiplomailConfig struct {
|
||||
// MaxBodyBytes caps the length of `diplomail_messages.body` in
|
||||
// bytes (not runes). A send whose body exceeds the limit is
|
||||
// rejected with ErrInvalidInput.
|
||||
MaxBodyBytes int
|
||||
|
||||
// MaxSubjectBytes caps the length of `diplomail_messages.subject`
|
||||
// in bytes. Subjects are optional; the empty-string default
|
||||
// passes the limit trivially.
|
||||
MaxSubjectBytes int
|
||||
|
||||
// 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
|
||||
// implemented in `backend/internal/notification`. AdminEmail receives
|
||||
// admin-channel kinds (the `runtime.*` set in `backend/README.md` §10);
|
||||
@@ -494,6 +544,13 @@ func DefaultConfig() Config {
|
||||
WorkerInterval: defaultNotificationWorkerInterval,
|
||||
MaxAttempts: defaultNotificationMaxAttempts,
|
||||
},
|
||||
Diplomail: DiplomailConfig{
|
||||
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
|
||||
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
|
||||
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
|
||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||
},
|
||||
DevSandbox: DevSandboxConfig{
|
||||
EngineVersion: defaultDevSandboxEngineVersion,
|
||||
PlayerCount: defaultDevSandboxPlayerCount,
|
||||
@@ -657,6 +714,23 @@ func LoadFromEnv() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
|
||||
if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
||||
@@ -853,6 +927,22 @@ func (c Config) Validate() error {
|
||||
if c.Notification.MaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
|
||||
}
|
||||
|
||||
if c.Diplomail.MaxBodyBytes <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes)
|
||||
}
|
||||
if c.Diplomail.MaxSubjectBytes < 0 {
|
||||
return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes)
|
||||
}
|
||||
if 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 _, err := netmail.ParseAddress(email); err != nil {
|
||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# 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.
|
||||
- `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`).
|
||||
|
||||
## 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,615 @@
|
||||
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
|
||||
}
|
||||
|
||||
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID)
|
||||
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(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(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
|
||||
msgInsert := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: in.GameID,
|
||||
GameName: sender.GameName,
|
||||
Kind: KindPersonal,
|
||||
SenderKind: SenderKindPlayer,
|
||||
SenderUserID: &callerID,
|
||||
SenderUsername: &username,
|
||||
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(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(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(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_kind='admin' → CallerKind admin; sender_user_id nil
|
||||
// sender_kind='system' → CallerKind system; sender_username nil
|
||||
func (s *Service) buildAdminMessageInsert(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
|
||||
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,389 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// previewMaxRunes bounds the body excerpt embedded in the push event
|
||||
// so the gRPC payload stays small. The value matches the UI's
|
||||
// "two lines" tease and is intentionally not configurable — clients
|
||||
// drive their own truncation off the canonical fetch.
|
||||
const previewMaxRunes = 120
|
||||
|
||||
// SendPersonal persists a single-recipient personal message and
|
||||
// fan-outs a `diplomail.message.received` push event to the
|
||||
// recipient. Validation rules:
|
||||
//
|
||||
// - both sender and recipient must be active members of GameID;
|
||||
// - the recipient must differ from the sender;
|
||||
// - the body must be non-empty, valid UTF-8, and within the
|
||||
// configured byte limit;
|
||||
// - the subject must be valid UTF-8 and within the configured
|
||||
// byte limit (zero is allowed).
|
||||
//
|
||||
// On any rule violation the function returns ErrInvalidInput or
|
||||
// ErrForbidden; the inserted Message is never persisted in those
|
||||
// cases.
|
||||
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
||||
if in.SenderUserID == in.RecipientUserID {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
||||
}
|
||||
subject := strings.TrimRight(in.Subject, " \t")
|
||||
body := strings.TrimRight(in.Body, " \t\n")
|
||||
if err := s.validateContent(subject, body); err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
|
||||
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
||||
}
|
||||
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load recipient membership: %w", err)
|
||||
}
|
||||
|
||||
username := sender.UserName
|
||||
msgInsert := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: in.GameID,
|
||||
GameName: sender.GameName,
|
||||
Kind: KindPersonal,
|
||||
SenderKind: SenderKindPlayer,
|
||||
SenderUserID: &in.SenderUserID,
|
||||
SenderUsername: &username,
|
||||
SenderIP: in.SenderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: BroadcastScopeSingle,
|
||||
}
|
||||
raceName := recipient.RaceName
|
||||
rcptInsert := buildRecipientInsert(
|
||||
msgInsert.MessageID,
|
||||
MemberSnapshot{
|
||||
UserID: in.RecipientUserID,
|
||||
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
|
||||
}
|
||||
|
||||
// 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 personal messages authored by senderUserID in
|
||||
// gameID, newest first. Admin/system rows have no `sender_user_id`
|
||||
// and are therefore excluded; the user surface does not need them.
|
||||
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) {
|
||||
return s.deps.Store.ListSent(ctx, gameID, senderUserID)
|
||||
}
|
||||
|
||||
// MarkRead transitions a recipient row to `read`. Idempotent: a
|
||||
// second call on an already-read row is a no-op. Returns the
|
||||
// resulting Recipient. ErrNotFound is surfaced when the caller is
|
||||
// not a recipient of the message.
|
||||
func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
|
||||
return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC())
|
||||
}
|
||||
|
||||
// DeleteMessage soft-deletes the recipient row identified by
|
||||
// (messageID, userID). The row must already have `read_at` set, or
|
||||
// the call returns ErrConflict (item 10 of the spec: open-then-delete).
|
||||
// Returns ErrNotFound when the caller is not a recipient.
|
||||
func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
|
||||
return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC())
|
||||
}
|
||||
|
||||
// UnreadCountsForUser returns the lobby badge breakdown.
|
||||
func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
|
||||
return s.deps.Store.UnreadCountsForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// validateContent enforces the body/subject byte limits and rejects
|
||||
// non-UTF-8 input. Stage A applies the rules to plain text only; HTML
|
||||
// is treated as plain text by the server (the UI renders via
|
||||
// textContent) and gets no special handling.
|
||||
func (s *Service) validateContent(subject, body string) error {
|
||||
if body == "" {
|
||||
return fmt.Errorf("%w: body must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if !utf8.ValidString(body) {
|
||||
return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput)
|
||||
}
|
||||
if len(body) > s.deps.Config.MaxBodyBytes {
|
||||
return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes)
|
||||
}
|
||||
if subject != "" {
|
||||
if !utf8.ValidString(subject) {
|
||||
return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput)
|
||||
}
|
||||
if len(subject) > s.deps.Config.MaxSubjectBytes {
|
||||
return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishMessageReceived emits the per-recipient push notification.
|
||||
// Failures are logged at debug level: notifications are best-effort
|
||||
// over the gRPC stream, and clients always have the unread-counts
|
||||
// endpoint as the durable fallback.
|
||||
func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) {
|
||||
unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("compute unread count for push payload failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("recipient", recipient.UserID.String()),
|
||||
zap.Error(err))
|
||||
unreadGame = 0
|
||||
}
|
||||
unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("compute unread totals for push payload failed",
|
||||
zap.String("recipient", recipient.UserID.String()),
|
||||
zap.Error(err))
|
||||
unreadTotals = nil
|
||||
}
|
||||
unreadTotal := 0
|
||||
for _, u := range unreadTotals {
|
||||
unreadTotal += u.Unread
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"message_id": msg.MessageID.String(),
|
||||
"game_id": msg.GameID.String(),
|
||||
"kind": msg.Kind,
|
||||
"sender_kind": msg.SenderKind,
|
||||
"subject": msg.Subject,
|
||||
"preview": preview(msg.Body, previewMaxRunes),
|
||||
"preview_lang": msg.BodyLang,
|
||||
"unread_total": unreadTotal,
|
||||
"unread_game": unreadGame,
|
||||
}
|
||||
ev := DiplomailNotification{
|
||||
Kind: "diplomail.message.received",
|
||||
IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(),
|
||||
Recipient: recipient.UserID,
|
||||
Payload: payload,
|
||||
}
|
||||
if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil {
|
||||
s.deps.Logger.Warn("publish diplomail event failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("recipient", recipient.UserID.String()),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// preview truncates s to at most max runes and appends a horizontal
|
||||
// ellipsis when truncation actually happened. The function operates
|
||||
// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic,
|
||||
// emoji) survive without corruption.
|
||||
func preview(s string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == max {
|
||||
return s[:i] + "…"
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for the diplomail
|
||||
// package. All queries are built through go-jet against the generated
|
||||
// table bindings under `backend/internal/postgres/jet/backend/table`.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// messageColumns is the canonical projection for diplomail_messages
|
||||
// reads.
|
||||
func messageColumns() postgres.ColumnList {
|
||||
m := table.DiplomailMessages
|
||||
return postgres.ColumnList{
|
||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||
m.SenderUserID, m.SenderUsername, m.SenderIP,
|
||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// recipientColumns is the canonical projection for
|
||||
// diplomail_recipients reads.
|
||||
func recipientColumns() postgres.ColumnList {
|
||||
r := table.DiplomailRecipients
|
||||
return postgres.ColumnList{
|
||||
r.RecipientID, r.MessageID, r.GameID, r.UserID,
|
||||
r.RecipientUserName, r.RecipientRaceName, r.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
|
||||
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.SenderIP,
|
||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
|
||||
).VALUES(
|
||||
msg.MessageID,
|
||||
msg.GameID,
|
||||
msg.GameName,
|
||||
msg.Kind,
|
||||
msg.SenderKind,
|
||||
uuidPtrArg(msg.SenderUserID),
|
||||
stringPtrArg(msg.SenderUsername),
|
||||
msg.SenderIP,
|
||||
msg.Subject,
|
||||
msg.Body,
|
||||
msg.BodyLang,
|
||||
msg.BroadcastScope,
|
||||
).RETURNING(messageColumns())
|
||||
|
||||
var msgRow model.DiplomailMessages
|
||||
if err := msgStmt.QueryContext(ctx, tx, &msgRow); err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail store: insert message: %w", err)
|
||||
}
|
||||
|
||||
r := table.DiplomailRecipients
|
||||
rcptStmt := r.INSERT(
|
||||
r.RecipientID, r.MessageID, r.GameID, r.UserID,
|
||||
r.RecipientUserName, r.RecipientRaceName,
|
||||
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 messages authored by senderUserID in gameID,
|
||||
// newest first. Personal messages only — admin/system rows have
|
||||
// `sender_user_id IS NULL` and are filtered out by the WHERE clause.
|
||||
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) {
|
||||
m := table.DiplomailMessages
|
||||
stmt := postgres.SELECT(messageColumns()).
|
||||
FROM(m).
|
||||
WHERE(
|
||||
m.GameID.EQ(postgres.UUID(gameID)).
|
||||
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
|
||||
).
|
||||
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC())
|
||||
var rows []model.DiplomailMessages
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err)
|
||||
}
|
||||
out := make([]Message, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, messageFromModel(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkRead sets `read_at = at` on the recipient row identified by
|
||||
// (messageID, userID). Idempotent: a row that is already marked read
|
||||
// is left untouched but the existing Recipient is returned.
|
||||
// Returns ErrNotFound when the user is not a recipient of the message.
|
||||
func (s *Store) MarkRead(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := r.UPDATE(r.ReadAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))).
|
||||
AND(r.ReadAt.IS_NULL()),
|
||||
).
|
||||
RETURNING(recipientColumns())
|
||||
var row model.DiplomailRecipients
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return Recipient{}, fmt.Errorf("diplomail store: mark read %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
// The row exists but read_at was already set, or the row
|
||||
// does not exist at all. Fetch to disambiguate.
|
||||
existing, loadErr := s.LoadRecipient(ctx, messageID, userID)
|
||||
if loadErr != nil {
|
||||
return Recipient{}, loadErr
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return recipientFromModel(row), nil
|
||||
}
|
||||
|
||||
// SoftDelete sets `deleted_at = at` on the recipient row identified by
|
||||
// (messageID, userID). The row must already have `read_at` set;
|
||||
// otherwise the call returns ErrConflict so a hostile client cannot
|
||||
// erase a message before opening it (item 10 of the spec).
|
||||
// Returns ErrNotFound when the user is not a recipient.
|
||||
func (s *Store) SoftDelete(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := r.UPDATE(r.DeletedAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))).
|
||||
AND(r.ReadAt.IS_NOT_NULL()).
|
||||
AND(r.DeletedAt.IS_NULL()),
|
||||
).
|
||||
RETURNING(recipientColumns())
|
||||
var row model.DiplomailRecipients
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return Recipient{}, fmt.Errorf("diplomail store: soft delete %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
existing, loadErr := s.LoadRecipient(ctx, messageID, userID)
|
||||
if loadErr != nil {
|
||||
return Recipient{}, loadErr
|
||||
}
|
||||
if existing.ReadAt == nil {
|
||||
return Recipient{}, fmt.Errorf("%w: message must be read before delete", ErrConflict)
|
||||
}
|
||||
// Already deleted: return the existing row idempotently.
|
||||
return existing, nil
|
||||
}
|
||||
return recipientFromModel(row), nil
|
||||
}
|
||||
|
||||
// LoadRecipient fetches the Recipient row keyed on (messageID, userID).
|
||||
// Returns ErrNotFound when no such recipient exists.
|
||||
func (s *Store) LoadRecipient(ctx context.Context, messageID, userID uuid.UUID) (Recipient, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := postgres.SELECT(recipientColumns()).
|
||||
FROM(r).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))),
|
||||
).
|
||||
LIMIT(1)
|
||||
var row model.DiplomailRecipients
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Recipient{}, ErrNotFound
|
||||
}
|
||||
return Recipient{}, fmt.Errorf("diplomail store: load recipient %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
return recipientFromModel(row), nil
|
||||
}
|
||||
|
||||
// UnreadCountForUserGame returns the count of unread, non-deleted,
|
||||
// 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
|
||||
}
|
||||
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,255 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Message mirrors a row in `backend.diplomail_messages` enriched with
|
||||
// the per-message metadata captured at insert time.
|
||||
//
|
||||
// SenderUserID and SenderUsername are nullable in the DB so that the
|
||||
// CHECK constraint can cover the three legal sender shapes:
|
||||
//
|
||||
// - player: SenderUserID set, SenderUsername set
|
||||
// - admin: SenderUserID nil, SenderUsername set
|
||||
// - system: SenderUserID nil, SenderUsername nil
|
||||
type Message struct {
|
||||
MessageID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Kind string
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
BodyLang string
|
||||
BroadcastScope string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recipient mirrors a row in `backend.diplomail_recipients`. The
|
||||
// per-recipient state (read/deleted/delivered/notified) lives here.
|
||||
// RecipientUserName, 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. Validation
|
||||
// (active membership, body length, etc.) is performed inside the
|
||||
// service.
|
||||
type SendPersonalInput struct {
|
||||
GameID uuid.UUID
|
||||
SenderUserID uuid.UUID
|
||||
RecipientUserID uuid.UUID
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// 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. 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
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// SendAdminBroadcastInput is the request payload for an owner /
|
||||
// admin / system broadcasting an admin-kind message inside a single
|
||||
// game. RecipientScope selects the address book; the sender's own
|
||||
// recipient row is never created (a broadcast author does not get a
|
||||
// copy of their own message).
|
||||
type SendAdminBroadcastInput struct {
|
||||
GameID uuid.UUID
|
||||
CallerKind string
|
||||
CallerUserID *uuid.UUID
|
||||
CallerUsername string
|
||||
RecipientScope string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// LifecycleEventKind enumerates the producer-side intents the lobby
|
||||
// emits when a game-state or membership-state transition lands.
|
||||
const (
|
||||
LifecycleKindGamePaused = "game.paused"
|
||||
LifecycleKindGameCancelled = "game.cancelled"
|
||||
LifecycleKindMembershipRemoved = "membership.removed"
|
||||
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||
)
|
||||
|
||||
// SendPlayerBroadcastInput is the request payload for the paid-tier
|
||||
// player broadcast. The sender is a player; recipients are the
|
||||
// active members of the game minus the sender. The resulting message
|
||||
// is `kind="personal"`, `sender_kind="player"`,
|
||||
// `broadcast_scope="game_broadcast"` — recipients may reply as if it
|
||||
// were a personal send, but the reply goes back to the broadcaster
|
||||
// only.
|
||||
type SendPlayerBroadcastInput struct {
|
||||
GameID uuid.UUID
|
||||
SenderUserID uuid.UUID
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// MultiGameBroadcastScope enumerates the admin multi-game broadcast
|
||||
// modes. `selected` requires `GameIDs`; `all_running` enumerates
|
||||
// every game whose status is non-terminal through GameLookup.
|
||||
const (
|
||||
MultiGameScopeSelected = "selected"
|
||||
MultiGameScopeAllRunning = "all_running"
|
||||
)
|
||||
|
||||
// SendMultiGameBroadcastInput is the request payload for the admin
|
||||
// multi-game broadcast. The service materialises one message row per
|
||||
// addressed game (so a recipient who plays in two games receives two
|
||||
// independently-deletable inbox entries), then fan-outs the push
|
||||
// events.
|
||||
type SendMultiGameBroadcastInput struct {
|
||||
CallerUsername string
|
||||
Scope string
|
||||
GameIDs []uuid.UUID
|
||||
RecipientScope string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// BulkCleanupInput selects messages eligible for purge. OlderThanYears
|
||||
// must be >= 1; the service translates the value into a cutoff
|
||||
// expressed in years and walks `GameLookup.ListFinishedGamesBefore`.
|
||||
type BulkCleanupInput struct {
|
||||
OlderThanYears int
|
||||
}
|
||||
|
||||
// CleanupResult summarises a bulk-cleanup run for the admin response
|
||||
// envelope.
|
||||
type CleanupResult struct {
|
||||
GameIDs []uuid.UUID
|
||||
MessagesDeleted int
|
||||
}
|
||||
|
||||
// AdminMessageListing is the filter passed to ListMessagesForAdmin.
|
||||
// Pagination uses (Page, PageSize) consistent with the rest of the
|
||||
// admin surface. Filters are AND-combined; the empty filter returns
|
||||
// every persisted row.
|
||||
type AdminMessageListing struct {
|
||||
Page int
|
||||
PageSize int
|
||||
GameID *uuid.UUID
|
||||
Kind string
|
||||
SenderKind string
|
||||
}
|
||||
|
||||
// AdminMessagePage is the canonical pagination envelope.
|
||||
type AdminMessagePage struct {
|
||||
Items []Message
|
||||
Total int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// LifecycleEvent is the payload lobby hands to PublishLifecycle when
|
||||
// a transition needs to be reflected as durable system mail. The
|
||||
// recipient set is derived by the service:
|
||||
//
|
||||
// - For game.* events the message fans out to every active member
|
||||
// of the game except the actor (the actor sees the action in
|
||||
// their own UI through other channels).
|
||||
// - For membership.* events the message addresses exactly
|
||||
// `TargetUser` (the kicked player), regardless of their current
|
||||
// membership status — this is how a kicked player retains read
|
||||
// access to the explanation of the kick.
|
||||
type LifecycleEvent struct {
|
||||
GameID uuid.UUID
|
||||
Kind string
|
||||
Actor string
|
||||
Reason string
|
||||
TargetUser *uuid.UUID
|
||||
}
|
||||
|
||||
// UnreadCount carries a per-game unread-count row returned by
|
||||
// UnreadCountsForUser. The lobby badge UI consumes the slice plus the
|
||||
// derived total.
|
||||
type UnreadCount struct {
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Unread int
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// translationBackoff returns the sleep applied before retry attempt
|
||||
// `attempt`. attempt is 1-indexed (the value the row carries AFTER
|
||||
// the failure is recorded). The schedule mirrors the spec —
|
||||
// 1s → 2s → 4s → 8s → 16s — so 5 failed attempts span ~31 seconds
|
||||
// before the worker falls back to delivering the original.
|
||||
func translationBackoff(attempt int32) time.Duration {
|
||||
if attempt <= 0 {
|
||||
return 0
|
||||
}
|
||||
out := time.Second
|
||||
for i := int32(1); i < attempt; i++ {
|
||||
out *= 2
|
||||
}
|
||||
const cap = 60 * time.Second
|
||||
if out > cap {
|
||||
return cap
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Worker drives the async translation pipeline. Each tick picks a
|
||||
// single (message_id, target_lang) pair from
|
||||
// `diplomail_recipients` where `available_at IS NULL`, asks the
|
||||
// configured Translator to render the body, and either delivers the
|
||||
// pending recipients (success) or schedules a retry (transient
|
||||
// failure) or delivers them with a fallback to the original body
|
||||
// (terminal failure / max attempts).
|
||||
//
|
||||
// The worker is single-threaded by design: one HTTP call to
|
||||
// LibreTranslate at a time. This protects the upstream from spikes
|
||||
// and keeps the implementation reviewable.
|
||||
//
|
||||
// Implements `internal/app.Component` so it plugs into the same
|
||||
// lifecycle as the mail and notification workers.
|
||||
type Worker struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewWorker constructs a Worker bound to svc. Returning a non-nil
|
||||
// Worker even when the translator is the noop fallback is
|
||||
// intentional — the pickup query still works and falls through to
|
||||
// fallback delivery, which is the desired behaviour for setups
|
||||
// without LibreTranslate.
|
||||
func NewWorker(svc *Service) *Worker { return &Worker{svc: svc} }
|
||||
|
||||
// Run drives the worker loop until ctx is cancelled.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
if w == nil || w.svc == nil {
|
||||
return nil
|
||||
}
|
||||
logger := w.svc.deps.Logger.Named("worker")
|
||||
interval := w.svc.deps.Config.WorkerInterval
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Warn("diplomail worker initial tick failed", zap.Error(err))
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Warn("diplomail worker tick failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown is a no-op: every translation outcome is committed inside
|
||||
// tick before returning, so cancelling the parent ctx is enough.
|
||||
func (w *Worker) Shutdown(_ context.Context) error { return nil }
|
||||
|
||||
// Tick exposes the per-tick work for tests so they can drive the
|
||||
// worker without depending on the ticker.
|
||||
func (w *Worker) Tick(ctx context.Context) error { return w.tick(ctx) }
|
||||
|
||||
// tick picks one pair from the queue and applies the result. The
|
||||
// per-tick budget is one pair on purpose: the worker is single
|
||||
// threaded and we do not want a fast LibreTranslate instance to
|
||||
// starve the rest of the backend's I/O behind a long-running batch.
|
||||
func (w *Worker) tick(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
pair, ok, err := w.svc.deps.Store.PickPendingTranslationPair(ctx, w.svc.nowUTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return w.processPair(ctx, pair)
|
||||
}
|
||||
|
||||
// processPair runs the full pipeline for one (message, target_lang).
|
||||
// Steps:
|
||||
//
|
||||
// 1. Load the source message.
|
||||
// 2. Check the translation cache. If a row already exists (another
|
||||
// worker pre-populated it, or two pairs converged on the same
|
||||
// target), reuse it and deliver.
|
||||
// 3. Otherwise call the configured Translator.
|
||||
// 4. Apply the outcome: success → cache + deliver; unsupported
|
||||
// pair → deliver fallback (no cache row); other failure →
|
||||
// schedule retry or deliver fallback after MaxAttempts.
|
||||
// 5. Fan out push events for every recipient whose `available_at`
|
||||
// just transitioned.
|
||||
func (w *Worker) processPair(ctx context.Context, pair PendingTranslationPair) error {
|
||||
logger := w.svc.deps.Logger.Named("worker").With(
|
||||
zap.String("message_id", pair.MessageID.String()),
|
||||
zap.String("target_lang", pair.TargetLang),
|
||||
)
|
||||
msg, err := w.svc.deps.Store.LoadMessage(ctx, pair.MessageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cached, err := w.svc.deps.Store.LoadTranslation(ctx, pair.MessageID, pair.TargetLang); err == nil {
|
||||
t := cached
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, &t, logger)
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
result, callErr := w.svc.deps.Translator.Translate(ctx, msg.BodyLang, pair.TargetLang, msg.Subject, msg.Body)
|
||||
if callErr == nil && result.Engine != "" && result.Engine != translator.NoopEngine {
|
||||
tr := Translation{
|
||||
TranslationID: uuid.New(),
|
||||
MessageID: msg.MessageID,
|
||||
TargetLang: pair.TargetLang,
|
||||
TranslatedSubject: result.Subject,
|
||||
TranslatedBody: result.Body,
|
||||
Translator: result.Engine,
|
||||
}
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, &tr, logger)
|
||||
}
|
||||
if callErr == nil {
|
||||
// Noop translator (or engine returned empty). Treat as
|
||||
// "translation unavailable" — deliver fallback so users
|
||||
// see the original.
|
||||
logger.Debug("translator returned noop, delivering fallback")
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||
}
|
||||
if errors.Is(callErr, translator.ErrUnsupportedLanguagePair) {
|
||||
logger.Info("language pair unsupported, delivering fallback", zap.Error(callErr))
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||
}
|
||||
|
||||
// Transient failure — bump the attempts counter and schedule a
|
||||
// retry. The next attempt timestamp is computed from the
|
||||
// post-increment counter so the spec's 1s→2s→4s→8s→16s schedule
|
||||
// applies between retries of the same pair.
|
||||
maxAttempts := w.svc.deps.Config.TranslatorMaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 5
|
||||
}
|
||||
nextAttempt := pair.CurrentAttempts + 1
|
||||
if int(nextAttempt) >= maxAttempts {
|
||||
logger.Warn("translator max attempts reached, delivering fallback",
|
||||
zap.Int32("attempts", nextAttempt), zap.Error(callErr))
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||
}
|
||||
next := w.svc.nowUTC().Add(translationBackoff(nextAttempt + 1))
|
||||
if _, err := w.svc.deps.Store.SchedulePairRetry(ctx, pair.MessageID, pair.TargetLang, next); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("translator attempt failed, scheduled retry",
|
||||
zap.Int32("attempts", nextAttempt),
|
||||
zap.Time("next_attempt_at", next),
|
||||
zap.Error(callErr))
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverPair flips every still-pending recipient of (messageID,
|
||||
// targetLang) to delivered, optionally inserting the translation row
|
||||
// in the same transaction, and emits push events to the recipients
|
||||
// who were just unblocked.
|
||||
func (w *Worker) deliverPair(ctx context.Context, msg Message, targetLang string, translation *Translation, logger *zap.Logger) error {
|
||||
recipients, err := w.svc.deps.Store.MarkPairDelivered(ctx, msg.MessageID, targetLang, translation, w.svc.nowUTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
logger.Debug("deliver yielded no recipients (already delivered)")
|
||||
return nil
|
||||
}
|
||||
for _, r := range recipients {
|
||||
w.svc.publishMessageReceived(ctx, msg, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) {
|
||||
return g, ok
|
||||
}
|
||||
|
||||
// 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;
|
||||
// terminal statuses (finished, cancelled) cause the entry to be evicted.
|
||||
func (c *Cache) PutGame(game GameRecord) {
|
||||
|
||||
@@ -51,6 +51,37 @@ type NotificationPublisher interface {
|
||||
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.
|
||||
// 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
|
||||
@@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent
|
||||
)
|
||||
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"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CreateGameInput is the parameter struct for Service.CreateGame.
|
||||
@@ -233,6 +234,41 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
|
||||
return s.deps.Store.ListMyGames(ctx, userID)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// applications, invites, runtime_records, player_mappings) via the
|
||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||
@@ -441,9 +477,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle
|
||||
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
||||
}
|
||||
}
|
||||
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
|
||||
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:
|
||||
//
|
||||
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
||||
|
||||
@@ -124,6 +124,7 @@ type Deps struct {
|
||||
Cache *Cache
|
||||
Runtime RuntimeGateway
|
||||
Notification NotificationPublisher
|
||||
Diplomail DiplomailPublisher
|
||||
Entitlement EntitlementProvider
|
||||
Policy *Policy
|
||||
Config config.LobbyConfig
|
||||
@@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) {
|
||||
if deps.Notification == nil {
|
||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Diplomail == nil {
|
||||
deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Policy == nil {
|
||||
policy, err := NewPolicy()
|
||||
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.Error(pubErr))
|
||||
}
|
||||
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
|
||||
_ = game
|
||||
return updated, nil
|
||||
}
|
||||
@@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus(
|
||||
zap.String("kind", notificationKind),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "")
|
||||
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 {
|
||||
if game.Visibility == VisibilityPublic {
|
||||
// Public-game membership management is admin-only.
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
||||
KindGameTurnReady = "game.turn.ready"
|
||||
KindGamePaused = "game.paused"
|
||||
KindDiplomailReceived = "diplomail.message.received"
|
||||
)
|
||||
|
||||
// CatalogEntry describes the per-kind delivery policy: which channels
|
||||
@@ -103,6 +104,9 @@ var catalog = map[string]CatalogEntry{
|
||||
KindGamePaused: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
KindDiplomailReceived: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
}
|
||||
|
||||
// LookupCatalog returns the per-kind policy and a boolean reporting
|
||||
@@ -133,5 +137,6 @@ func SupportedKinds() []string {
|
||||
KindRuntimeStartConfigInvalid,
|
||||
KindGameTurnReady,
|
||||
KindGamePaused,
|
||||
KindDiplomailReceived,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func TestCatalogChannels(t *testing.T) {
|
||||
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
||||
KindGameTurnReady: {ChannelPush},
|
||||
KindGamePaused: {ChannelPush},
|
||||
KindDiplomailReceived: {ChannelPush},
|
||||
}
|
||||
for kind, want := range expect {
|
||||
entry, ok := LookupCatalog(kind)
|
||||
|
||||
@@ -25,9 +25,15 @@ import (
|
||||
// payload is `{game_id, turn, reason}` consumed by the same in-game
|
||||
// shell layout, so there is no value in dragging a FB schema in for
|
||||
// one consumer.
|
||||
//
|
||||
// `diplomail.message.received` (Stage A) carries the message metadata
|
||||
// plus an unread-count snapshot. Stage A intentionally ships the
|
||||
// payload as JSON so the diplomail UI can iterate on the contract
|
||||
// without a FB schema dance; a later stage can promote it.
|
||||
var jsonFriendlyKinds = map[string]bool{
|
||||
KindGameTurnReady: true,
|
||||
KindGamePaused: true,
|
||||
KindGameTurnReady: true,
|
||||
KindGamePaused: true,
|
||||
KindDiplomailReceived: true,
|
||||
}
|
||||
|
||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||
@@ -88,6 +94,17 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
||||
"turn": int32(7),
|
||||
"reason": "generation_failed",
|
||||
}},
|
||||
{"diplomail message received", KindDiplomailReceived, map[string]any{
|
||||
"message_id": gameID.String(),
|
||||
"game_id": gameID.String(),
|
||||
"kind": "personal",
|
||||
"sender_kind": "player",
|
||||
"subject": "Trade deal",
|
||||
"preview": "Care to talk gas mining?",
|
||||
"preview_lang": "en",
|
||||
"unread_total": 3,
|
||||
"unread_game": 1,
|
||||
}},
|
||||
}
|
||||
|
||||
seenKinds := map[string]bool{}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiplomailMessages struct {
|
||||
MessageID uuid.UUID `sql:"primary_key"`
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Kind string
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
BodyLang string
|
||||
BroadcastScope string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,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,114 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var DiplomailMessages = newDiplomailMessagesTable("backend", "diplomail_messages", "")
|
||||
|
||||
type diplomailMessagesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
MessageID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
GameName postgres.ColumnString
|
||||
Kind postgres.ColumnString
|
||||
SenderKind postgres.ColumnString
|
||||
SenderUserID postgres.ColumnString
|
||||
SenderUsername postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
Subject postgres.ColumnString
|
||||
Body postgres.ColumnString
|
||||
BodyLang postgres.ColumnString
|
||||
BroadcastScope postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type DiplomailMessagesTable struct {
|
||||
diplomailMessagesTable
|
||||
|
||||
EXCLUDED diplomailMessagesTable
|
||||
}
|
||||
|
||||
// AS creates new DiplomailMessagesTable with assigned alias
|
||||
func (a DiplomailMessagesTable) AS(alias string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new DiplomailMessagesTable with assigned schema name
|
||||
func (a DiplomailMessagesTable) FromSchema(schemaName string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new DiplomailMessagesTable with assigned table prefix
|
||||
func (a DiplomailMessagesTable) WithPrefix(prefix string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new DiplomailMessagesTable with assigned table suffix
|
||||
func (a DiplomailMessagesTable) WithSuffix(suffix string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newDiplomailMessagesTable(schemaName, tableName, alias string) *DiplomailMessagesTable {
|
||||
return &DiplomailMessagesTable{
|
||||
diplomailMessagesTable: newDiplomailMessagesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newDiplomailMessagesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomailMessagesTable {
|
||||
var (
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
GameNameColumn = postgres.StringColumn("game_name")
|
||||
KindColumn = postgres.StringColumn("kind")
|
||||
SenderKindColumn = postgres.StringColumn("sender_kind")
|
||||
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
||||
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
SubjectColumn = postgres.StringColumn("subject")
|
||||
BodyColumn = postgres.StringColumn("body")
|
||||
BodyLangColumn = postgres.StringColumn("body_lang")
|
||||
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return diplomailMessagesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
MessageID: MessageIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
GameName: GameNameColumn,
|
||||
Kind: KindColumn,
|
||||
SenderKind: SenderKindColumn,
|
||||
SenderUserID: SenderUserIDColumn,
|
||||
SenderUsername: SenderUsernameColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
Subject: SubjectColumn,
|
||||
Body: BodyColumn,
|
||||
BodyLang: BodyLangColumn,
|
||||
BroadcastScope: BroadcastScopeColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
BlockedEmails = BlockedEmails.FromSchema(schema)
|
||||
DeviceSessions = DeviceSessions.FromSchema(schema)
|
||||
DiplomailMessages = DiplomailMessages.FromSchema(schema)
|
||||
DiplomailRecipients = DiplomailRecipients.FromSchema(schema)
|
||||
DiplomailTranslations = DiplomailTranslations.FromSchema(schema)
|
||||
EngineVersions = EngineVersions.FromSchema(schema)
|
||||
EntitlementRecords = EntitlementRecords.FromSchema(schema)
|
||||
EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema)
|
||||
|
||||
@@ -606,7 +606,8 @@ CREATE TABLE notifications (
|
||||
'lobby.race_name.expired',
|
||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||
'runtime.start_config_invalid',
|
||||
'game.turn.ready', 'game.paused'
|
||||
'game.turn.ready', 'game.paused',
|
||||
'diplomail.message.received'
|
||||
))
|
||||
);
|
||||
|
||||
@@ -662,6 +663,114 @@ CREATE TABLE notification_malformed_intents (
|
||||
CREATE INDEX notification_malformed_intents_listing_idx
|
||||
ON notification_malformed_intents (received_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- Diplomail domain
|
||||
-- =====================================================================
|
||||
|
||||
-- diplomail_messages is the canonical record of every diplomatic-mail
|
||||
-- send: one row per personal message, owner/admin send, broadcast, or
|
||||
-- system notification. game_name is captured at insert time so the
|
||||
-- bulk-purge / rename paths still render correctly. sender_username
|
||||
-- carries either accounts.user_name (sender_kind='player') or
|
||||
-- admin_accounts.username (sender_kind='admin'); system senders leave
|
||||
-- it NULL. body and subject are plain UTF-8; length limits are enforced
|
||||
-- in the service layer and may be tuned without a migration.
|
||||
CREATE TABLE diplomail_messages (
|
||||
message_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
game_name text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
sender_kind text NOT NULL,
|
||||
sender_user_id uuid,
|
||||
sender_username text,
|
||||
sender_ip text NOT NULL DEFAULT '',
|
||||
subject text NOT NULL DEFAULT '',
|
||||
body text NOT NULL,
|
||||
body_lang text NOT NULL DEFAULT 'und',
|
||||
broadcast_scope text NOT NULL DEFAULT 'single',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT diplomail_messages_kind_chk
|
||||
CHECK (kind IN ('personal', 'admin')),
|
||||
CONSTRAINT diplomail_messages_sender_kind_chk
|
||||
CHECK (sender_kind IN ('player', 'admin', 'system')),
|
||||
CONSTRAINT diplomail_messages_sender_identity_chk CHECK (
|
||||
(sender_kind = 'player' AND sender_user_id IS NOT NULL AND sender_username IS NOT NULL) OR
|
||||
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
|
||||
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
|
||||
),
|
||||
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
||||
(kind = 'personal' AND sender_kind = 'player') OR
|
||||
(kind = 'admin' AND sender_kind IN ('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
|
||||
-- =====================================================================
|
||||
|
||||
@@ -68,6 +68,10 @@ var expectedBackendTables = []string{
|
||||
"notification_malformed_intents",
|
||||
"notification_routes",
|
||||
"notifications",
|
||||
// Diplomail domain.
|
||||
"diplomail_messages",
|
||||
"diplomail_recipients",
|
||||
"diplomail_translations",
|
||||
// Geo domain.
|
||||
"user_country_counters",
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ var pathParamStubs = map[string]string{
|
||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||
"battle_id": "00000000-0000-0000-0000-000000000009",
|
||||
"message_id": "00000000-0000-0000-0000-00000000000a",
|
||||
"id": "1.2.3",
|
||||
"username": "alice",
|
||||
"turn": "42",
|
||||
@@ -149,6 +150,35 @@ var requestBodyStubs = map[string]map[string]any{
|
||||
"user_id": pathParamStubs["user_id"],
|
||||
"reason": "ToS violation",
|
||||
},
|
||||
"userMailSendPersonal": {
|
||||
"recipient_user_id": pathParamStubs["user_id"],
|
||||
"subject": "Contract test subject",
|
||||
"body": "Contract test body",
|
||||
},
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed
|
||||
// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send /
|
||||
// broadcast). The handler is intentionally separate from
|
||||
// `AdminMailHandlers`, which owns the unrelated email outbox surface
|
||||
// under `/api/v1/admin/mail/*`.
|
||||
type AdminDiplomailHandlers struct {
|
||||
svc *diplomail.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminDiplomailHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented.
|
||||
func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")}
|
||||
}
|
||||
|
||||
// Send handles POST /api/v1/admin/games/{game_id}/mail. The body
|
||||
// shape mirrors the owner route: `target="user"` requires
|
||||
// `recipient_user_id`; `target="all"` accepts an optional
|
||||
// `recipients` scope. The authenticated admin username is captured
|
||||
// from the basicauth context and persisted as `sender_username`.
|
||||
func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailSend")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendAdminRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
switch req.Target {
|
||||
case "", "user":
|
||||
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
if parseErr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindAdmin,
|
||||
CallerUsername: username,
|
||||
RecipientUserID: recipientID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||
case "all":
|
||||
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindAdmin,
|
||||
CallerUsername: username,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
default:
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast handles POST /api/v1/admin/mail/broadcast. Body:
|
||||
//
|
||||
// {
|
||||
// "scope": "selected" | "all_running",
|
||||
// "game_ids": ["..."],
|
||||
// "recipients": "active" | "active_and_removed" | "all_members",
|
||||
// "subject": "...",
|
||||
// "body": "..."
|
||||
// }
|
||||
//
|
||||
// The handler routes through SendAdminMultiGameBroadcast and returns
|
||||
// a fan-out receipt describing the message ids created and the
|
||||
// total recipient count.
|
||||
func (h *AdminDiplomailHandlers) Broadcast() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailBroadcast")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
var req adminDiplomailBroadcastRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
gameIDs := make([]uuid.UUID, 0, len(req.GameIDs))
|
||||
for _, raw := range req.GameIDs {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_ids must be valid UUIDs")
|
||||
return
|
||||
}
|
||||
gameIDs = append(gameIDs, parsed)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msgs, total, err := h.svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{
|
||||
CallerUsername: username,
|
||||
Scope: req.Scope,
|
||||
GameIDs: gameIDs,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail broadcast", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailBroadcastResponseWire{
|
||||
RecipientCount: total,
|
||||
Messages: make([]adminDiplomailBroadcastMessageWire, 0, len(msgs)),
|
||||
}
|
||||
for _, m := range msgs {
|
||||
out.Messages = append(out.Messages, adminDiplomailBroadcastMessageWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup handles POST /api/v1/admin/mail/cleanup. Body:
|
||||
//
|
||||
// { "older_than_years": 1 }
|
||||
//
|
||||
// The endpoint removes every diplomail_messages row whose game
|
||||
// finished more than the supplied number of years ago. The cascade
|
||||
// on the recipient and translation tables prunes the per-user state
|
||||
// in the same transaction. Returns a CleanupResult envelope.
|
||||
func (h *AdminDiplomailHandlers) Cleanup() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailCleanup")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
_ = username
|
||||
var req adminDiplomailCleanupRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: req.OlderThanYears})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail cleanup", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailCleanupResponseWire{
|
||||
MessagesDeleted: result.MessagesDeleted,
|
||||
GameIDs: make([]string, 0, len(result.GameIDs)),
|
||||
}
|
||||
for _, id := range result.GameIDs {
|
||||
out.GameIDs = append(out.GameIDs, id.String())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/mail/messages. Supports pagination
|
||||
// via `page` and `page_size`, plus optional `game_id`, `kind`, and
|
||||
// `sender_kind` filters.
|
||||
func (h *AdminDiplomailHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
filter := diplomail.AdminMessageListing{
|
||||
Page: parsePositiveQueryInt(c.Query("page"), 1),
|
||||
PageSize: parsePositiveQueryInt(c.Query("page_size"), 50),
|
||||
Kind: c.Query("kind"),
|
||||
SenderKind: c.Query("sender_kind"),
|
||||
}
|
||||
if raw := c.Query("game_id"); raw != "" {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
filter.GameID = &parsed
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
page, err := h.svc.ListMessagesForAdmin(ctx, filter)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail list", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailListResponseWire{
|
||||
Total: page.Total,
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Items: make([]adminDiplomailMessageWire, 0, len(page.Items)),
|
||||
}
|
||||
for _, m := range page.Items {
|
||||
entry := adminDiplomailMessageWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
Kind: m.Kind,
|
||||
SenderKind: m.SenderKind,
|
||||
SenderIP: m.SenderIP,
|
||||
Subject: m.Subject,
|
||||
Body: m.Body,
|
||||
BodyLang: m.BodyLang,
|
||||
BroadcastScope: m.BroadcastScope,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if m.SenderUserID != nil {
|
||||
s := m.SenderUserID.String()
|
||||
entry.SenderUserID = &s
|
||||
}
|
||||
if m.SenderUsername != nil {
|
||||
s := *m.SenderUsername
|
||||
entry.SenderUsername = &s
|
||||
}
|
||||
out.Items = append(out.Items, entry)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastRequestWire struct {
|
||||
Scope string `json:"scope"`
|
||||
GameIDs []string `json:"game_ids,omitempty"`
|
||||
Recipients string `json:"recipients,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastMessageWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastResponseWire struct {
|
||||
RecipientCount int `json:"recipient_count"`
|
||||
Messages []adminDiplomailBroadcastMessageWire `json:"messages"`
|
||||
}
|
||||
|
||||
type adminDiplomailCleanupRequestWire struct {
|
||||
OlderThanYears int `json:"older_than_years"`
|
||||
}
|
||||
|
||||
type adminDiplomailCleanupResponseWire struct {
|
||||
MessagesDeleted int `json:"messages_deleted"`
|
||||
GameIDs []string `json:"game_ids"`
|
||||
}
|
||||
|
||||
type adminDiplomailMessageWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
SenderKind string `json:"sender_kind"`
|
||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||
SenderUsername *string `json:"sender_username,omitempty"`
|
||||
SenderIP string `json:"sender_ip,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type adminDiplomailListResponseWire struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []adminDiplomailMessageWire `json:"items"`
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserMailHandlers groups the diplomatic-mail handlers under
|
||||
// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side
|
||||
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the
|
||||
// personal subset; Stage B adds the owner-only admin send path,
|
||||
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
|
||||
// to resolve the owner's `user_name` for the `sender_username` column.
|
||||
type UserMailHandlers struct {
|
||||
svc *diplomail.Service
|
||||
lobby *lobby.Service
|
||||
users *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserMailHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented. lobby and
|
||||
// users are optional: when either is nil the admin-send handler
|
||||
// degrades to 501 (the personal-send and read paths stay functional).
|
||||
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserMailHandlers{
|
||||
svc: svc,
|
||||
lobby: lobbySvc,
|
||||
users: users,
|
||||
logger: logger.Named("http.user.mail"),
|
||||
}
|
||||
}
|
||||
|
||||
// preferredLanguage looks up the caller's `accounts.preferred_language`
|
||||
// so the per-message read can attach the cached translation when
|
||||
// available. Failures are logged at debug level and the function
|
||||
// returns an empty string — translation is best-effort and the
|
||||
// caller still receives the original body.
|
||||
func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string {
|
||||
if h.users == nil {
|
||||
return ""
|
||||
}
|
||||
account, err := h.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Debug("resolve preferred_language failed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
return account.PreferredLanguage
|
||||
}
|
||||
|
||||
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
|
||||
func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSendPersonal")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
recipientID, err := uuid.Parse(req.RecipientUserID)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: userID,
|
||||
RecipientUserID: recipientID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send personal", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/user/games/{game_id}/mail/messages/{message_id}.
|
||||
func (h *UserMailHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
if _, ok := parseGameIDParam(c); !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := parseMessageIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
targetLang := h.preferredLanguage(ctx, userID)
|
||||
entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailMessageDetailToWire(entry, false))
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox handles GET /api/v1/user/games/{game_id}/mail/inbox.
|
||||
func (h *UserMailHandlers) Inbox() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailInbox")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
targetLang := h.preferredLanguage(ctx, userID)
|
||||
items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail inbox", ctx, err)
|
||||
return
|
||||
}
|
||||
out := userMailInboxListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
|
||||
for _, e := range items {
|
||||
out.Items = append(out.Items, mailMessageDetailToWire(e, false))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Sent handles GET /api/v1/user/games/{game_id}/mail/sent.
|
||||
func (h *UserMailHandlers) Sent() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSent")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListSent(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
|
||||
return
|
||||
}
|
||||
out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))}
|
||||
for _, m := range items {
|
||||
out.Items = append(out.Items, mailMessageSummaryToWire(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkRead handles POST /api/v1/user/games/{game_id}/mail/messages/{message_id}/read.
|
||||
func (h *UserMailHandlers) MarkRead() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailMarkRead")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
if _, ok := parseGameIDParam(c); !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := parseMessageIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rcpt, err := h.svc.MarkRead(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail mark read", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/user/games/{game_id}/mail/messages/{message_id}.
|
||||
func (h *UserMailHandlers) Delete() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailDelete")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
if _, ok := parseGameIDParam(c); !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := parseMessageIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rcpt, err := h.svc.DeleteMessage(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail delete", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
|
||||
}
|
||||
}
|
||||
|
||||
// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast.
|
||||
//
|
||||
// The endpoint is the paid-tier player broadcast: any player on a
|
||||
// non-`free` entitlement tier may send one personal message that
|
||||
// fans out to every other active member of the game. The result
|
||||
// rows carry `kind="personal"`, `sender_kind="player"`,
|
||||
// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403.
|
||||
func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSendBroadcast")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendBroadcastRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: userID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
}
|
||||
}
|
||||
|
||||
// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
|
||||
//
|
||||
// Owner-only: the caller must be the owner of the private game. The
|
||||
// handler resolves the owner's `user_name` so the
|
||||
// `sender_username` column carries a useful identity, then routes to
|
||||
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
|
||||
// `target="all"`). Site administrators use the separate admin route
|
||||
// in `handlers_admin_mail_send.go`.
|
||||
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
||||
if h.svc == nil || h.lobby == nil || h.users == nil {
|
||||
return handlers.NotImplemented("userMailSendAdmin")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendAdminRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
game, err := h.lobby.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
|
||||
return
|
||||
}
|
||||
if game.OwnerUserID == nil || *game.OwnerUserID != userID {
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
|
||||
return
|
||||
}
|
||||
account, err := h.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Target {
|
||||
case "", "user":
|
||||
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
if parseErr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
callerUserID := userID
|
||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindOwner,
|
||||
CallerUserID: &callerUserID,
|
||||
CallerUsername: account.UserName,
|
||||
RecipientUserID: recipientID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||
case "all":
|
||||
callerUserID := userID
|
||||
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindOwner,
|
||||
CallerUserID: &callerUserID,
|
||||
CallerUsername: account.UserName,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
default:
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
|
||||
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailUnreadCounts")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.UnreadCountsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail unread counts", ctx, err)
|
||||
return
|
||||
}
|
||||
out := userMailUnreadCountsResponseWire{Items: make([]userMailUnreadCountWire, 0, len(items))}
|
||||
total := 0
|
||||
for _, u := range items {
|
||||
out.Items = append(out.Items, userMailUnreadCountWire{
|
||||
GameID: u.GameID.String(),
|
||||
GameName: u.GameName,
|
||||
Unread: u.Unread,
|
||||
})
|
||||
total += u.Unread
|
||||
}
|
||||
out.Total = total
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// respondDiplomailError maps diplomail-package sentinels to the
|
||||
// standard JSON error envelope. Unknown errors land on a 500.
|
||||
func respondDiplomailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, diplomail.ErrInvalidInput):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, diplomail.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
||||
case errors.Is(err, diplomail.ErrForbidden):
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
|
||||
case errors.Is(err, diplomail.ErrConflict):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
}
|
||||
|
||||
// parseMessageIDParam reads `message_id` from the path. Writes a 400
|
||||
// envelope on invalid input and returns false in that case.
|
||||
func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("message_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "message_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
||||
type userMailSendRequestWire struct {
|
||||
RecipientUserID string `json:"recipient_user_id"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailSendBroadcastRequestWire mirrors the request body for the
|
||||
// paid-tier player broadcast. There is no `target` discriminator —
|
||||
// the recipient set is always "every other active member".
|
||||
type userMailSendBroadcastRequestWire struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailSendAdminRequestWire mirrors the request body for the
|
||||
// owner-only admin send. `target="user"` requires
|
||||
// `recipient_user_id`; `target="all"` accepts the optional
|
||||
// `recipients` scope (default `active`).
|
||||
type userMailSendAdminRequestWire struct {
|
||||
Target string `json:"target"`
|
||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||
Recipients string `json:"recipients,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailBroadcastReceiptWire is the response shape returned after a
|
||||
// successful broadcast. It carries the canonical message metadata
|
||||
// together with the count of materialised recipient rows so the
|
||||
// caller (UI, admin tool) can confirm the fan-out happened.
|
||||
type userMailBroadcastReceiptWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
SenderKind string `json:"sender_kind"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RecipientCount int `json:"recipient_count"`
|
||||
}
|
||||
|
||||
func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
|
||||
return userMailBroadcastReceiptWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
Kind: m.Kind,
|
||||
SenderKind: m.SenderKind,
|
||||
Subject: m.Subject,
|
||||
Body: m.Body,
|
||||
BodyLang: m.BodyLang,
|
||||
BroadcastScope: m.BroadcastScope,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||
RecipientCount: len(recipients),
|
||||
}
|
||||
}
|
||||
|
||||
// userMailMessageDetailWire mirrors the unified response shape for
|
||||
// inbox listings and per-message reads. Sender identifiers are
|
||||
// optional: system messages carry neither user id nor username.
|
||||
// Translation fields are populated when a cached rendering exists
|
||||
// for the caller's `preferred_language`; the UI renders
|
||||
// `body_translated` and surfaces the original through a
|
||||
// "show original" toggle.
|
||||
type userMailMessageDetailWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
SenderKind string `json:"sender_kind"`
|
||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||
SenderUsername *string `json:"sender_username,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RecipientUserID string `json:"recipient_user_id"`
|
||||
RecipientUserName string `json:"recipient_user_name,omitempty"`
|
||||
RecipientRaceName *string `json:"recipient_race_name,omitempty"`
|
||||
ReadAt *string `json:"read_at,omitempty"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
||||
TranslatedBody *string `json:"translated_body,omitempty"`
|
||||
TranslationLang *string `json:"translation_lang,omitempty"`
|
||||
Translator *string `json:"translator,omitempty"`
|
||||
}
|
||||
|
||||
// userMailSentSummaryWire mirrors the response shape for the
|
||||
// sender-side listing. Recipient state is intentionally omitted (one
|
||||
// author may have N recipients per broadcast in later stages).
|
||||
type userMailSentSummaryWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type userMailInboxListWire struct {
|
||||
Items []userMailMessageDetailWire `json:"items"`
|
||||
}
|
||||
|
||||
type userMailSentListWire struct {
|
||||
Items []userMailSentSummaryWire `json:"items"`
|
||||
}
|
||||
|
||||
type userMailUnreadCountWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
type userMailUnreadCountsResponseWire struct {
|
||||
Total int `json:"total"`
|
||||
Items []userMailUnreadCountWire `json:"items"`
|
||||
}
|
||||
|
||||
func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userMailMessageDetailWire {
|
||||
out := userMailMessageDetailWire{
|
||||
MessageID: entry.MessageID.String(),
|
||||
GameID: entry.GameID.String(),
|
||||
GameName: entry.GameName,
|
||||
Kind: entry.Kind,
|
||||
SenderKind: entry.SenderKind,
|
||||
Subject: entry.Subject,
|
||||
Body: entry.Body,
|
||||
BodyLang: entry.BodyLang,
|
||||
BroadcastScope: entry.BroadcastScope,
|
||||
CreatedAt: entry.CreatedAt.UTC().Format(timestampLayout),
|
||||
RecipientUserID: entry.Recipient.UserID.String(),
|
||||
RecipientUserName: entry.Recipient.RecipientUserName,
|
||||
}
|
||||
if entry.SenderUserID != nil {
|
||||
s := entry.SenderUserID.String()
|
||||
out.SenderUserID = &s
|
||||
}
|
||||
if entry.SenderUsername != nil {
|
||||
s := *entry.SenderUsername
|
||||
out.SenderUsername = &s
|
||||
}
|
||||
if entry.Recipient.RecipientRaceName != nil {
|
||||
s := *entry.Recipient.RecipientRaceName
|
||||
out.RecipientRaceName = &s
|
||||
}
|
||||
if entry.Recipient.ReadAt != nil {
|
||||
s := entry.Recipient.ReadAt.UTC().Format(timestampLayout)
|
||||
out.ReadAt = &s
|
||||
}
|
||||
if entry.Recipient.DeletedAt != nil {
|
||||
s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout)
|
||||
out.DeletedAt = &s
|
||||
}
|
||||
if entry.Translation != nil {
|
||||
tr := entry.Translation
|
||||
subj := tr.TranslatedSubject
|
||||
body := tr.TranslatedBody
|
||||
lang := tr.TargetLang
|
||||
engine := tr.Translator
|
||||
out.TranslatedSubject = &subj
|
||||
out.TranslatedBody = &body
|
||||
out.TranslationLang = &lang
|
||||
out.Translator = &engine
|
||||
}
|
||||
_ = justCreated
|
||||
return out
|
||||
}
|
||||
|
||||
func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire {
|
||||
return userMailSentSummaryWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
Kind: m.Kind,
|
||||
Subject: m.Subject,
|
||||
Body: m.Body,
|
||||
BodyLang: m.BodyLang,
|
||||
BroadcastScope: m.BroadcastScope,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
}
|
||||
|
||||
// mailRecipientStateToWire renders the recipient row after a
|
||||
// mark-read or soft-delete call. The caller only needs the per-user
|
||||
// state, not the full message body again.
|
||||
func mailRecipientStateToWire(r diplomail.Recipient) userMailRecipientStateWire {
|
||||
out := userMailRecipientStateWire{
|
||||
MessageID: r.MessageID.String(),
|
||||
}
|
||||
if r.ReadAt != nil {
|
||||
s := r.ReadAt.UTC().Format(timestampLayout)
|
||||
out.ReadAt = &s
|
||||
}
|
||||
if r.DeletedAt != nil {
|
||||
s := r.DeletedAt.UTC().Format(timestampLayout)
|
||||
out.DeletedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type userMailRecipientStateWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ReadAt *string `json:"read_at,omitempty"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
@@ -68,6 +68,7 @@ type RouterDependencies struct {
|
||||
UserLobbyMy *UserLobbyMyHandlers
|
||||
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
||||
UserGames *UserGamesHandlers
|
||||
UserMail *UserMailHandlers
|
||||
UserSessions *UserSessionsHandlers
|
||||
AdminAdminAccounts *AdminAdminAccountsHandlers
|
||||
AdminUsers *AdminUsersHandlers
|
||||
@@ -75,6 +76,7 @@ type RouterDependencies struct {
|
||||
AdminRuntimes *AdminRuntimesHandlers
|
||||
AdminEngineVersions *AdminEngineVersionsHandlers
|
||||
AdminMail *AdminMailHandlers
|
||||
AdminDiplomail *AdminDiplomailHandlers
|
||||
AdminNotifications *AdminNotificationsHandlers
|
||||
AdminGeo *AdminGeoHandlers
|
||||
InternalSessions *InternalSessionsHandlers
|
||||
@@ -163,6 +165,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.UserGames == nil {
|
||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.UserMail == nil {
|
||||
deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.UserSessions == nil {
|
||||
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
||||
}
|
||||
@@ -184,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.AdminMail == nil {
|
||||
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminDiplomail == nil {
|
||||
deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminNotifications == nil {
|
||||
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
||||
}
|
||||
@@ -255,6 +263,9 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
my.GET("/invites", deps.UserLobbyMy.Invites())
|
||||
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
|
||||
|
||||
lobbyMail := lobbyGroup.Group("/mail")
|
||||
lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts())
|
||||
|
||||
raceNames := lobbyGroup.Group("/race-names")
|
||||
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
||||
|
||||
@@ -265,6 +276,16 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
||||
|
||||
userMail := userGames.Group("/:game_id/mail")
|
||||
userMail.POST("/messages", deps.UserMail.SendPersonal())
|
||||
userMail.POST("/broadcast", deps.UserMail.SendBroadcast())
|
||||
userMail.POST("/admin", deps.UserMail.SendAdmin())
|
||||
userMail.GET("/messages/:message_id", deps.UserMail.Get())
|
||||
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
|
||||
userMail.DELETE("/messages/:message_id", deps.UserMail.Delete())
|
||||
userMail.GET("/inbox", deps.UserMail.Inbox())
|
||||
userMail.GET("/sent", deps.UserMail.Sent())
|
||||
|
||||
userSessions := group.Group("/sessions")
|
||||
userSessions.GET("", deps.UserSessions.List())
|
||||
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
|
||||
@@ -299,6 +320,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
||||
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
||||
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
||||
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
||||
games.POST("/:game_id/mail", deps.AdminDiplomail.Send())
|
||||
|
||||
runtimes := group.Group("/runtimes")
|
||||
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
||||
@@ -318,6 +340,9 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
||||
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
||||
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
||||
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
||||
mail.GET("/messages", deps.AdminDiplomail.List())
|
||||
mail.POST("/broadcast", deps.AdminDiplomail.Broadcast())
|
||||
mail.POST("/cleanup", deps.AdminDiplomail.Cleanup())
|
||||
|
||||
notifications := group.Group("/notifications")
|
||||
notifications.GET("", deps.AdminNotifications.List())
|
||||
|
||||
@@ -1144,6 +1144,295 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendPersonal
|
||||
summary: Send a personal diplomatic mail message
|
||||
description: |
|
||||
Sends a replyable personal message from the authenticated user
|
||||
to another active member of the same game. Both sender and
|
||||
recipient must be active members. Body is plain UTF-8 text
|
||||
(no HTML processing on the server); `subject` is optional.
|
||||
Body length is capped at `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||
(default 4096) and subject length at
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256).
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Personal message accepted and persisted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/broadcast:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendBroadcast
|
||||
summary: Send a paid-tier personal broadcast to a game's active members
|
||||
description: |
|
||||
Paid-tier players (`entitlement.is_paid == true`) may send one
|
||||
personal message that fans out to every other active member of
|
||||
the game. Free-tier callers receive 403. The resulting rows
|
||||
carry `kind="personal"`, `sender_kind="player"`,
|
||||
`broadcast_scope="game_broadcast"`. Recipients reply through
|
||||
the regular personal-send endpoint; the reply targets the
|
||||
broadcaster only.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendBroadcastRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Personal broadcast accepted; receipt carries the recipient count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/admin:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendAdmin
|
||||
summary: Send a non-replyable admin notification (owner only)
|
||||
description: |
|
||||
Owner-only: the caller must be the owner of the private game.
|
||||
`target="user"` requires `recipient_user_id`; `target="all"`
|
||||
accepts an optional `recipients` scope (`active` by default,
|
||||
plus `active_and_removed` and `all_members`). The message
|
||||
carries `kind="admin"` and is therefore non-replyable.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailGet
|
||||
summary: Read one diplomatic mail message
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Message addressed to the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
delete:
|
||||
tags: [User]
|
||||
operationId: userMailDelete
|
||||
summary: Soft-delete a previously-read message
|
||||
description: |
|
||||
Marks the caller's recipient row for the message as deleted.
|
||||
The underlying message stays persisted (admin / system mail is
|
||||
retained for the lifetime of the game). The recipient row must
|
||||
have `read_at` set first; otherwise the call returns 409.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Message soft-deleted for the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailRecipientState"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"409":
|
||||
$ref: "#/components/responses/ConflictError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}/read:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailMarkRead
|
||||
summary: Mark a diplomatic mail message as read
|
||||
description: |
|
||||
Idempotent. Sets `read_at` on the caller's recipient row when
|
||||
it is still unread; a second call on an already-read row is a
|
||||
no-op and the existing state is returned.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Recipient state after the mark-read.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailRecipientState"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/inbox:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailInbox
|
||||
summary: List the caller's inbox for a game
|
||||
description: |
|
||||
Returns every non-soft-deleted mail row addressed to the
|
||||
caller in the given game, newest first. Includes the
|
||||
per-recipient read state. Soft access: the caller may not be
|
||||
an active member if every visible row carries
|
||||
`kind="admin"`.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
responses:
|
||||
"200":
|
||||
description: Inbox entries for the caller in the given game.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailInboxList"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/sent:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailSent
|
||||
summary: List the caller's sent personal messages in a game
|
||||
description: |
|
||||
Returns personal messages authored by the caller in the given
|
||||
game, newest first. Admin / system messages are not listed
|
||||
(they have no `sender_user_id`).
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
responses:
|
||||
"200":
|
||||
description: Sent personal messages by the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSentList"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/lobby/mail/unread-counts:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailUnreadCounts
|
||||
summary: Per-game and total unread mail counts for the caller
|
||||
description: |
|
||||
Drives the lobby badge: returns one entry per game the caller
|
||||
has any unread mail in, plus the global total. The response
|
||||
is empty (and `total == 0`) when there is nothing unread.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
responses:
|
||||
"200":
|
||||
description: Per-game unread counts addressed to the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailUnreadCountsResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/sessions:
|
||||
get:
|
||||
tags: [User]
|
||||
@@ -1704,6 +1993,176 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/mail/broadcast:
|
||||
post:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailBroadcast
|
||||
summary: Multi-game admin broadcast
|
||||
description: |
|
||||
Fans out one admin-kind broadcast across the games selected
|
||||
by `scope`. `scope="selected"` requires `game_ids`;
|
||||
`scope="all_running"` enumerates every game whose status is
|
||||
non-terminal. Recipients are resolved per-game via the same
|
||||
scope vocabulary as the per-game admin send. A recipient
|
||||
appearing in multiple addressed games receives one
|
||||
independently-deletable inbox entry per game.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailBroadcastRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Broadcast accepted; per-game message ids and total recipient count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailBroadcastResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/mail/cleanup:
|
||||
post:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailCleanup
|
||||
summary: Bulk-purge diplomail messages from old finished games
|
||||
description: |
|
||||
Removes every `diplomail_messages` row whose game finished
|
||||
more than `older_than_years` years ago. Cascading FKs prune
|
||||
the recipient and translation tables in the same transaction.
|
||||
`older_than_years` must be >= 1.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailCleanupRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Cleanup result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailCleanupResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/mail/messages:
|
||||
get:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailList
|
||||
summary: Paginated admin view of diplomail messages
|
||||
description: |
|
||||
Returns the canonical message rows for admin observability.
|
||||
Optional filters: `game_id`, `kind` (personal / admin),
|
||||
`sender_kind` (player / admin / system). Pagination via
|
||||
`page` and `page_size`.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: game_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: kind
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
- name: sender_kind
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated diplomail messages.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailListResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/games/{game_id}/mail:
|
||||
post:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailSend
|
||||
summary: Send a diplomatic-mail admin notification to one game
|
||||
description: |
|
||||
Site-admin send for the diplomatic-mail subsystem. Body shape
|
||||
mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin`
|
||||
endpoint. `target="user"` requires `recipient_user_id`;
|
||||
`target="all"` accepts an optional `recipients` scope
|
||||
(`active` / `active_and_removed` / `all_members`). The
|
||||
authenticated admin username is persisted as `sender_username`.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/runtimes/{game_id}:
|
||||
get:
|
||||
tags: [Admin]
|
||||
@@ -2247,6 +2706,13 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
MessageID:
|
||||
name: message_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
NotificationID:
|
||||
name: notification_id
|
||||
in: path
|
||||
@@ -3599,6 +4065,405 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeviceSession"
|
||||
UserMailSendRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [recipient_user_id, body]
|
||||
properties:
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
subject:
|
||||
type: string
|
||||
description: |
|
||||
Optional subject. Empty string and missing field are
|
||||
treated the same.
|
||||
body:
|
||||
type: string
|
||||
description: Plain UTF-8 body. HTML is not parsed on the server.
|
||||
UserMailSendAdminRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [target, body]
|
||||
properties:
|
||||
target:
|
||||
type: string
|
||||
enum: [user, all]
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Required when `target="user"`. Identifies the recipient
|
||||
of the personal admin message; the recipient may be in
|
||||
any membership status (admin notifications can reach
|
||||
kicked players).
|
||||
recipients:
|
||||
type: string
|
||||
enum: [active, active_and_removed, all_members]
|
||||
description: |
|
||||
Optional scope when `target="all"`. Defaults to `active`.
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
UserMailBroadcastReceipt:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
- recipient_count
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
recipient_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
UserMailSendBroadcastRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [body]
|
||||
properties:
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
AdminDiplomailBroadcastRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [scope, body]
|
||||
properties:
|
||||
scope:
|
||||
type: string
|
||||
enum: [selected, all_running]
|
||||
game_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
recipients:
|
||||
type: string
|
||||
enum: [active, active_and_removed, all_members]
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
AdminDiplomailBroadcastResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [recipient_count, messages]
|
||||
properties:
|
||||
recipient_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id, game_id]
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
AdminDiplomailCleanupRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [older_than_years]
|
||||
properties:
|
||||
older_than_years:
|
||||
type: integer
|
||||
minimum: 1
|
||||
AdminDiplomailCleanupResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [messages_deleted, game_ids]
|
||||
properties:
|
||||
messages_deleted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
game_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
AdminDiplomailMessage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
sender_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
sender_username:
|
||||
type: string
|
||||
nullable: true
|
||||
sender_ip:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
AdminDiplomailListResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [total, page, page_size, items]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
page:
|
||||
type: integer
|
||||
minimum: 1
|
||||
page_size:
|
||||
type: integer
|
||||
minimum: 1
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AdminDiplomailMessage"
|
||||
UserMailMessageDetail:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
- recipient_user_id
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
sender_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
sender_username:
|
||||
type: string
|
||||
nullable: true
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
description: BCP 47 tag. `und` until Stage D adds detection.
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
recipient_user_name:
|
||||
type: string
|
||||
recipient_race_name:
|
||||
type: string
|
||||
nullable: true
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
translated_subject:
|
||||
type: string
|
||||
description: |
|
||||
Subject rendered into the caller's preferred_language by
|
||||
the translation cache. Absent when the caller's language
|
||||
matches `body_lang` or the translator could not produce
|
||||
a rendering.
|
||||
translated_body:
|
||||
type: string
|
||||
description: |
|
||||
Body rendered into the caller's preferred_language. Same
|
||||
absence semantics as `translated_subject`.
|
||||
translation_lang:
|
||||
type: string
|
||||
description: BCP 47 tag of the rendered translation.
|
||||
translator:
|
||||
type: string
|
||||
description: Identifier of the translation engine that produced the cached row.
|
||||
UserMailSentSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
UserMailInboxList:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
UserMailSentList:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailSentSummary"
|
||||
UserMailUnreadCount:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [game_id, unread]
|
||||
properties:
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
unread:
|
||||
type: integer
|
||||
minimum: 0
|
||||
UserMailUnreadCountsResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [total, items]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailUnreadCount"
|
||||
UserMailRecipientState:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id]
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
NotImplementedError:
|
||||
description: Endpoint is documented but not implemented yet.
|
||||
|
||||
+87
-11
@@ -192,10 +192,12 @@ because they cross domain boundaries:
|
||||
`race_name`) remain `text`.
|
||||
- Foreign keys are intra-domain only: `accounts → entitlement_*` /
|
||||
`sanction_*` / `limit_*`; `games → applications` / `invites` /
|
||||
`memberships` (with `ON DELETE CASCADE`); `mail_payloads →
|
||||
mail_deliveries → mail_recipients` / `mail_attempts` /
|
||||
`mail_dead_letters`; `notifications → notification_routes` /
|
||||
`notification_dead_letters`. Cross-domain references
|
||||
`memberships` / `diplomail_messages` (each with
|
||||
`ON DELETE CASCADE`); `mail_payloads → mail_deliveries →
|
||||
mail_recipients` / `mail_attempts` / `mail_dead_letters`;
|
||||
`notifications → notification_routes` / `notification_dead_letters`;
|
||||
`diplomail_messages → diplomail_recipients` /
|
||||
`diplomail_translations`. Cross-domain references
|
||||
(`memberships.user_id`, `games.owner_user_id`, etc.) are kept as
|
||||
opaque `uuid` columns because each domain runs its own cleanup
|
||||
through the in-process cascade described in [§7](#7-in-process-async-patterns). Adding a database
|
||||
@@ -456,12 +458,15 @@ committed; SMTP completion is asynchronous to the auth request.
|
||||
|
||||
Notifications are an in-process pipeline. The closed catalog is
|
||||
defined in `backend/internal/notification/catalog.go` and currently
|
||||
covers 13 kinds: 10 lobby kinds (invite received/revoked, application
|
||||
covers 16 kinds: 10 lobby kinds (invite received/revoked, application
|
||||
submitted/approved/rejected, membership removed/blocked, race name
|
||||
registered/pending/expired) and 3 admin-recipient runtime kinds
|
||||
(image pull failed, container start failed, start config invalid).
|
||||
Per-kind delivery channels (push, email, or both) and the admin-vs-
|
||||
per-user recipient routing live in the same file.
|
||||
registered/pending/expired), 3 admin-recipient runtime kinds (image
|
||||
pull failed, container start failed, start config invalid), 2 game
|
||||
lifecycle kinds (turn ready, game paused), and the
|
||||
`diplomail.message.received` kind that fans diplomatic-mail send
|
||||
events out to the recipient's push stream. Per-kind delivery channels
|
||||
(push, email, or both) and the admin-vs-per-user recipient routing
|
||||
live in the same file.
|
||||
|
||||
For every intent, `notification.Submit` performs:
|
||||
|
||||
@@ -490,6 +495,34 @@ Notification persistence is the auditable record of "we tried to tell
|
||||
this user about this thing"; clients still derive their actual game
|
||||
state through normal user-facing reads.
|
||||
|
||||
### 12.1 Diplomatic mail subsystem
|
||||
|
||||
`backend/internal/diplomail` owns the player-to-player message channel
|
||||
that the in-game mail view consumes. The data lives in three tables:
|
||||
|
||||
- `diplomail_messages` — one canonical row per send. Captures the
|
||||
game name and the sender IP at insert time so audit rendering
|
||||
survives game renames and bulk purges. `kind` is `personal` (a
|
||||
replyable player→player message) or `admin` (a non-replyable
|
||||
notification produced by an administrator or the system).
|
||||
`sender_kind` distinguishes `player`, `admin`, and `system` senders.
|
||||
`broadcast_scope` carries `single`, `game_broadcast`, or
|
||||
`multi_game_broadcast`.
|
||||
- `diplomail_recipients` — one row per (message, recipient). Holds
|
||||
the per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
||||
state plus snapshot fields (`recipient_user_name`,
|
||||
`recipient_race_name`) so admin search and the inbox listing render
|
||||
correctly even after the source rows are renamed or revoked.
|
||||
- `diplomail_translations` — cached per-language rendering shared
|
||||
across every recipient with the same `accounts.preferred_language`.
|
||||
|
||||
Stage A wires the personal subset (single recipient, no language
|
||||
detection). Lifecycle hooks (paused / cancelled / kicked), paid-tier
|
||||
player broadcasts, multi-game admin broadcasts, bulk purge, and the
|
||||
detection / translation cache land in later stages. The package is
|
||||
the only place that constructs `diplomail.message.received` push
|
||||
intents; the notification pipeline takes it from there.
|
||||
|
||||
## 13. Container Lifecycle (in-process)
|
||||
|
||||
`backend/internal/runtime` owns the lifecycle of game-engine containers
|
||||
@@ -751,7 +784,50 @@ addition.
|
||||
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
|
||||
bound). Probes are excluded from anti-replay and rate limiting.
|
||||
|
||||
## 18. Deployment Topology (informational)
|
||||
## 18. CI and Environments
|
||||
|
||||
The repository is monorepo and intentionally so — semver tags and
|
||||
per-service rollouts are achievable without splitting the code into
|
||||
multiple repositories.
|
||||
|
||||
Branches:
|
||||
|
||||
- `main` — production-track. Direct pushes are disallowed; the only
|
||||
way in is a PR merge from `development`.
|
||||
- `development` — long-lived dev integration branch. Every merge
|
||||
triggers an auto-deploy into the long-lived dev environment on the
|
||||
CI host, reachable through the host Caddy at
|
||||
`https://www.galaxy.lan` and `https://api.galaxy.lan`.
|
||||
- `feature/*` — short-lived branches off `development`. Merged back
|
||||
via PR; PRs run unit + integration checks before merge.
|
||||
|
||||
Workflows under `.gitea/workflows/`:
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|---------|
|
||||
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||
| `integration.yaml` | PR to `development` / `main`; push to `development` | testcontainers integration suite. |
|
||||
| `dev-deploy.yaml` | push to `development` | Build images, seed UI volume, `compose up` against `tools/dev-deploy/`. |
|
||||
| `prod-build.yaml` | push to `main` | Build production images and persist `docker save` bundles as artifacts. |
|
||||
| `deploy-prod.yaml` | manual `workflow_dispatch` | Placeholder for the future SSH-based production rollout. |
|
||||
|
||||
Environments:
|
||||
|
||||
- **`tools/local-dev/`** — single-developer playground. Bound to
|
||||
host ports, Vite dev server runs on the host. Not driven by CI.
|
||||
- **`tools/dev-deploy/`** — long-lived dev environment behind
|
||||
`*.galaxy.lan`, redeployed on every merge into `development`.
|
||||
- **production** — future. Images come from the
|
||||
`galaxy-images-commit-<sha>` artifact produced by `prod-build.yaml`
|
||||
and are shipped to the production host via `docker save` →
|
||||
`ssh prod docker load` → `docker compose up -d`.
|
||||
|
||||
`tools/local-ci/` remains as an opt-in fallback runner for testing
|
||||
workflow changes without `gitea.lan`. It is no longer part of the
|
||||
per-stage CI gate; see `CLAUDE.md` for the gate definition.
|
||||
|
||||
## 19. Deployment Topology (informational)
|
||||
|
||||
- MVP runs three executables: one `gateway` instance, one `backend`
|
||||
instance, and N `galaxy-game-{game_id}` containers managed by backend.
|
||||
@@ -770,7 +846,7 @@ Future scale-out hooks (not in MVP):
|
||||
- mTLS between gateway and backend.
|
||||
- Docker-socket-proxy sidecar fronting Docker daemon access.
|
||||
|
||||
## 19. Glossary
|
||||
## 20. Glossary
|
||||
|
||||
- **device_session_id** — opaque identifier of an authenticated client
|
||||
device; primary key of the device session record.
|
||||
|
||||
@@ -47,6 +47,7 @@ same scenario when they participate in the same business flow.
|
||||
8. [Notifications and mail](#8-notifications-and-mail)
|
||||
9. [Geo signal](#9-geo-signal)
|
||||
10. [Administration](#10-administration)
|
||||
11. [Diplomatic mail](#11-diplomatic-mail)
|
||||
|
||||
---
|
||||
|
||||
@@ -1153,3 +1154,223 @@ counters are populated by the runtime, and operators can only read.
|
||||
- Mail outbox and notification dispatcher:
|
||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||
[§12](ARCHITECTURE.md#12-notification-pipeline) and [Section 8](#8-notifications-and-mail).
|
||||
|
||||
---
|
||||
|
||||
## 11. Diplomatic mail
|
||||
|
||||
This scenario covers the player-to-player and admin-to-player
|
||||
messaging system exposed inside a game. The system is conceptually
|
||||
part of the lobby (messages outlive game runtime restarts), but
|
||||
they are surfaced exclusively inside the in-game UI; the lobby
|
||||
surfaces only an unread counter.
|
||||
|
||||
### 11.1 Scope
|
||||
|
||||
In scope: sending personal mail between active members of the same
|
||||
game; replying to personal mail; reading and marking-read /
|
||||
soft-deleting one's own incoming mail; admin / owner notifications
|
||||
addressed to one player or broadcast to a game; paid-tier player
|
||||
broadcasts; site-admin multi-game broadcasts; bulk purge of
|
||||
messages tied to terminated games; auto-translation of the body
|
||||
into the recipient's `preferred_language` with a cached rendering.
|
||||
|
||||
Out of scope: out-of-game chat, group chats spanning multiple
|
||||
games, file attachments, message editing or unsend, end-to-end
|
||||
encryption.
|
||||
|
||||
### 11.2 The message model
|
||||
|
||||
Every send produces exactly one row in `diplomail_messages` plus
|
||||
one row per recipient in `diplomail_recipients`. A broadcast to N
|
||||
recipients is one message + N recipient rows; the translation row,
|
||||
when materialised, is shared across every recipient with the same
|
||||
target language.
|
||||
|
||||
`diplomail_messages.kind` is the closed set
|
||||
`{personal, admin}`. Personal messages are replyable (the
|
||||
recipient sends back a new personal message); admin messages are
|
||||
non-replyable acknowledgements of a state change or operator
|
||||
action. `sender_kind` is `{player, admin, system}` and identifies
|
||||
the originator's role: a player owns the game (admin notification
|
||||
from owner), a site administrator pushed it (admin notification
|
||||
from operator), or the lobby state machine produced it
|
||||
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||
`membership.blocked`).
|
||||
|
||||
`broadcast_scope` records whether the send was a single-recipient
|
||||
delivery (`single`), a one-game broadcast (`game_broadcast`), or a
|
||||
cross-game admin broadcast (`multi_game_broadcast`). Recipients of
|
||||
a multi-game broadcast see one independently-deletable inbox entry
|
||||
per game they were addressed in.
|
||||
|
||||
Per-row snapshots travel with each message: `game_name`,
|
||||
`sender_username`, `sender_ip`, plus on the recipient row
|
||||
`recipient_user_name`, `recipient_race_name`, and
|
||||
`recipient_preferred_language`. These survive game-name changes,
|
||||
membership revocation, account soft-delete, and the eventual
|
||||
bulk-purge cascade — they let the admin observability surface
|
||||
render correctly long after the live rows have moved on.
|
||||
|
||||
Bodies and subjects are plain UTF-8 text. The server does not
|
||||
parse, sanitise, or escape HTML; the client renders bodies through
|
||||
`textContent`. Maximum body size is
|
||||
`BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default `4096`); maximum
|
||||
subject size is `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default
|
||||
`256`).
|
||||
|
||||
### 11.3 Sending mail
|
||||
|
||||
Personal sends require active membership in the game for both the
|
||||
sender and the recipient. Free-tier players send one personal
|
||||
message per request. Paid-tier players additionally have access to
|
||||
a game-scoped broadcast that addresses every other active member
|
||||
in one call; replies fan back to the broadcast author.
|
||||
|
||||
Game owners (of private games) and site administrators send admin
|
||||
notifications. The owner endpoint lives under the user surface
|
||||
(authenticated by `X-User-ID`, owner check enforced); the admin
|
||||
endpoint lives under the admin surface (HTTP Basic). Both accept
|
||||
`target=user` (single recipient) or `target=all` (game broadcast).
|
||||
Site administrators additionally have a multi-game endpoint that
|
||||
accepts `scope=selected` with a list of game ids or
|
||||
`scope=all_running` that enumerates every game with non-terminal
|
||||
status.
|
||||
|
||||
Broadcast composition is parameterised by `recipients`: `active`
|
||||
(default), `active_and_removed`, or `all_members` (includes
|
||||
blocked rows for audit-style mail). The broadcast author's own
|
||||
recipient row is never created.
|
||||
|
||||
A paid-tier broadcast is rejected with `403 forbidden` when the
|
||||
caller's entitlement tier is `free`.
|
||||
|
||||
### 11.4 Receiving mail
|
||||
|
||||
The recipient sees the message in their in-game inbox once the
|
||||
async translation worker has finished processing it (see
|
||||
[§11.6](#116-translation)). Until then the row stays invisible:
|
||||
absent from the inbox listing, not counted in the unread badge, no
|
||||
push event delivered. This avoids a surprise where the inbox shows
|
||||
a row with no translation and an outdated unread count.
|
||||
|
||||
The unread badge in the lobby aggregates by game. The
|
||||
`/api/v1/user/lobby/mail/unread-counts` endpoint returns one entry
|
||||
per game with non-zero unread plus the global total; the lobby UI
|
||||
renders the total badge and a per-game tile counter without
|
||||
exposing the messages themselves.
|
||||
|
||||
Marking a message as read is idempotent. Soft-deletion requires the
|
||||
message to already be marked read — a client cannot erase an
|
||||
unopened message. Soft-deletion is per-recipient: the underlying
|
||||
message row survives until the admin bulk-purge endpoint removes
|
||||
the entire game's mail tree.
|
||||
|
||||
The message detail response includes both the original body and,
|
||||
when available, the cached translation; the client UI defaults to
|
||||
the translated text and offers a "show original" toggle.
|
||||
|
||||
### 11.5 Lifecycle hooks
|
||||
|
||||
Three lobby transitions land as system mail in the affected
|
||||
players' inboxes:
|
||||
|
||||
- **Game paused / cancelled.** When the game state machine moves
|
||||
through `paused` or `cancelled`, the lobby emits a system mail
|
||||
addressed to every active member. The message explains the
|
||||
transition with a server-rendered template, so even an offline
|
||||
player finds the context the next time they open the inbox.
|
||||
- **Membership removed / blocked.** Manual self-leave, owner-driven
|
||||
removal, and admin ban each emit a system mail addressed to the
|
||||
affected player only. This mail survives the membership going
|
||||
to `removed` / `blocked`, so a kicked player keeps read access
|
||||
to the explanation forever (soft-access rule).
|
||||
|
||||
Future inactivity-driven removal must call the same publisher so
|
||||
the explanation reaches the affected player; the lobby package
|
||||
README pins this contract for the next implementer.
|
||||
|
||||
### 11.6 Translation
|
||||
|
||||
`diplomail_messages.body_lang` is filled at send time by an
|
||||
in-process language detector that operates on the body only.
|
||||
Subject inherits the body's detected language for the translation
|
||||
cache lookup. When detection cannot confidently label the body
|
||||
(too short, empty, mixed scripts) the value is the BCP 47
|
||||
`und` ("undetermined") sentinel and the translation pipeline is
|
||||
short-circuited — recipients receive the original.
|
||||
|
||||
Translation happens asynchronously. Every recipient row stores a
|
||||
snapshot of the addressee's `preferred_language` plus an
|
||||
`available_at` timestamp. A recipient whose language matches the
|
||||
detected `body_lang` (or whose preferred language is empty / the
|
||||
body language is `und`) gets `available_at = now()` on insert and
|
||||
the push event fires immediately. A recipient whose language
|
||||
differs is inserted with `available_at IS NULL` and waits for the
|
||||
translation worker.
|
||||
|
||||
The worker (`internal/diplomail.Worker`) ticks every
|
||||
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`) and processes
|
||||
one `(message_id, target_lang)` pair per tick. It consults the
|
||||
translation cache first; on miss it asks the configured
|
||||
`Translator`. The default deployment ships the LibreTranslate HTTP
|
||||
client; an empty `BACKEND_DIPLOMAIL_TRANSLATOR_URL` falls back to
|
||||
the noop translator that delivers every message in the original
|
||||
language.
|
||||
|
||||
Translation outcomes:
|
||||
|
||||
- **Success.** A row in `diplomail_translations` is inserted (or
|
||||
reused if another worker won the race), every pending recipient
|
||||
of the pair is flipped to `available_at = now()`, and one push
|
||||
event per recipient is published.
|
||||
- **Unsupported language pair** (HTTP 400 from LibreTranslate).
|
||||
No translation row is persisted; recipients are delivered with
|
||||
the original body. Subsequent reads return the original.
|
||||
- **Transient failure** (timeout, 5xx, network error). The
|
||||
attempt counter is bumped and the next attempt is scheduled via
|
||||
exponential backoff `1s → 2s → 4s → 8s → 16s` (capped at 60s).
|
||||
After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
|
||||
the worker falls back to delivering the original body. A
|
||||
prolonged translator outage therefore stalls delivery by at
|
||||
most ~30 seconds per pair before the receiver sees the
|
||||
original.
|
||||
|
||||
The translation cache is shared: a broadcast to N recipients with
|
||||
the same preferred language produces one cache row and one
|
||||
translator call, not N.
|
||||
|
||||
### 11.7 Storage and purge
|
||||
|
||||
Messages live in `diplomail_messages`; per-recipient state lives
|
||||
in `diplomail_recipients` with a foreign-key cascade to the
|
||||
message; translations live in `diplomail_translations` also with a
|
||||
cascade. The sender IP is captured at insert time from
|
||||
`X-Forwarded-For` (forwarded by gateway) for evidence preservation.
|
||||
|
||||
There is no automatic retention. The admin bulk-purge endpoint
|
||||
removes every message whose game finished more than
|
||||
`older_than_years` years ago (minimum `1`); the cascade drops the
|
||||
recipient and translation rows in the same transaction.
|
||||
|
||||
### 11.8 Operator visibility
|
||||
|
||||
The admin surface exposes a paginated listing of every persisted
|
||||
message (`/api/v1/admin/mail/messages`) filterable by `game_id`,
|
||||
`kind`, and `sender_kind`. The bulk-purge endpoint
|
||||
(`/api/v1/admin/mail/cleanup`) accepts the `older_than_years`
|
||||
threshold. Per-game admin sends and multi-game broadcasts live
|
||||
under `/api/v1/admin/games/{game_id}/mail` and
|
||||
`/api/v1/admin/mail/broadcast`.
|
||||
|
||||
### 11.9 Cross-references
|
||||
|
||||
- Package overview and stage map:
|
||||
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||
- LibreTranslate setup recipe for local development:
|
||||
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||
- Storage detail:
|
||||
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||
- Push transport for delivery events: [Section 7](#7-push-channel).
|
||||
- Notification catalog kind `diplomail.message.received`:
|
||||
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||
|
||||
@@ -47,6 +47,7 @@ field-level-валидация — всё это лежит в нижнеуро
|
||||
8. [Уведомления и почта](#8-уведомления-и-почта)
|
||||
9. [Гео-сигнал](#9-гео-сигнал)
|
||||
10. [Администрирование](#10-администрирование)
|
||||
11. [Дипломатическая почта](#11-дипломатическая-почта)
|
||||
|
||||
---
|
||||
|
||||
@@ -1193,3 +1194,220 @@ dead-letters и malformed notification-намерения. Они также м
|
||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||
[§12](ARCHITECTURE.md#12-notification-pipeline) и
|
||||
[Раздел 8](#8-уведомления-и-почта).
|
||||
|
||||
---
|
||||
|
||||
## 11. Дипломатическая почта
|
||||
|
||||
Сценарий описывает обмен сообщениями между игроками одной партии и
|
||||
адресные / широковещательные уведомления от администрации и
|
||||
владельца партии. Подсистема концептуально часть лобби (сообщения
|
||||
переживают рестарты движка), но видна только внутри игрового UI;
|
||||
в лобби виден лишь счётчик непрочитанного.
|
||||
|
||||
### 11.1 Состав
|
||||
|
||||
В составе: отправка персональной почты между активными участниками
|
||||
одной партии; ответы на персональную почту; чтение, отметка
|
||||
«прочитано» и soft-удаление своей входящей почты; адресные и
|
||||
широковещательные уведомления от админов и владельцев; платный
|
||||
broadcast от игроков; мультигеймовая admin-рассылка; ручная
|
||||
массовая чистка почты завершённых партий; авто-перевод тела
|
||||
сообщения на `preferred_language` получателя с кэшированием.
|
||||
|
||||
Вне состава: чат вне партии, групповые чаты с участниками разных
|
||||
партий, вложения, редактирование / отзыв сообщения,
|
||||
end-to-end-шифрование.
|
||||
|
||||
### 11.2 Модель сообщения
|
||||
|
||||
Каждая отправка порождает ровно одну строку в `diplomail_messages`
|
||||
плюс по одной строке на получателя в `diplomail_recipients`.
|
||||
Broadcast на N получателей — одно сообщение и N recipient-строк;
|
||||
строка перевода, если материализована, общая для всех получателей
|
||||
с одинаковым целевым языком.
|
||||
|
||||
`diplomail_messages.kind` — закрытое множество
|
||||
`{personal, admin}`. Персональные сообщения допускают ответ
|
||||
(получатель отправляет новое персональное сообщение);
|
||||
admin-сообщения не предполагают ответа — это уведомления о смене
|
||||
состояния или операторском действии. `sender_kind` — это
|
||||
`{player, admin, system}` и определяет роль отправителя: игрок-
|
||||
владелец партии (admin-уведомление от owner), site-администратор
|
||||
(admin-уведомление от оператора) или собственно автомат лобби
|
||||
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||
`membership.blocked`).
|
||||
|
||||
`broadcast_scope` фиксирует тип отправки: одному получателю
|
||||
(`single`), рассылка по одной партии (`game_broadcast`) или
|
||||
admin-рассылка по нескольким партиям (`multi_game_broadcast`).
|
||||
Получатели multi_game-рассылки видят отдельную, независимо
|
||||
удаляемую запись inbox в каждой адресованной партии.
|
||||
|
||||
Снимки сохраняются прямо в строках сообщения и получателя:
|
||||
`game_name`, `sender_username`, `sender_ip` и на стороне
|
||||
получателя — `recipient_user_name`, `recipient_race_name` и
|
||||
`recipient_preferred_language`. Они переживают переименование
|
||||
партии, отзыв членства, soft-delete аккаунта и итоговый
|
||||
bulk-purge — admin observability отрисовывается корректно даже
|
||||
после исчезновения «живых» строк.
|
||||
|
||||
Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит
|
||||
и не экранирует HTML; клиент рендерит тело через `textContent`.
|
||||
Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||
(по умолчанию `4096`); максимум для subject —
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`).
|
||||
|
||||
### 11.3 Отправка почты
|
||||
|
||||
Персональная отправка требует активного членства в партии и от
|
||||
отправителя, и от получателя. Игроки free-tier отправляют одно
|
||||
персональное сообщение за запрос. Игрокам платных тиров доступен
|
||||
и игровой broadcast — одна отправка на всех остальных активных
|
||||
участников партии; ответы возвращаются автору broadcast.
|
||||
|
||||
Владельцы (приватных партий) и site-администраторы отправляют
|
||||
admin-уведомления. Endpoint владельца находится на user-поверхности
|
||||
(аутентификация по `X-User-ID`, проверка владельца в обработчике);
|
||||
endpoint администратора — на admin-поверхности (HTTP Basic). Оба
|
||||
принимают `target=user` (один получатель) или `target=all`
|
||||
(broadcast в одной партии). Site-администратору доступен
|
||||
дополнительный multi-game endpoint, принимающий
|
||||
`scope=selected` со списком game_id или `scope=all_running` —
|
||||
перебор всех партий в нетерминальных состояниях.
|
||||
|
||||
Состав получателей broadcast параметризуется полем `recipients`:
|
||||
`active` (по умолчанию), `active_and_removed` или `all_members`
|
||||
(включает блокированных, для аудит-уведомлений). Собственная
|
||||
recipient-строка автора broadcast не создаётся.
|
||||
|
||||
Player-broadcast от free-tier пользователя отклоняется кодом
|
||||
`403 forbidden`.
|
||||
|
||||
### 11.4 Получение почты
|
||||
|
||||
Получатель видит сообщение в своём inbox только после того, как
|
||||
асинхронный worker перевода обработал его (см.
|
||||
[§11.6](#116-перевод)). До этого строка невидима: не выводится в
|
||||
inbox-листинге, не учитывается в badge непрочитанного, push-событие
|
||||
не доставляется. Это исключает ситуацию «строка появилась, перевод
|
||||
не подъехал, badge мигает».
|
||||
|
||||
Badge непрочитанного в лобби агрегируется по партиям. Endpoint
|
||||
`/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи
|
||||
на каждую партию с ненулевым unread плюс общий total; UI лобби
|
||||
отображает общий badge и плитки по партиям, не раскрывая самих
|
||||
сообщений.
|
||||
|
||||
Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже
|
||||
было помечено прочитанным — клиент не может стереть неоткрытое
|
||||
сообщение. Soft-удаление действует только для одного получателя:
|
||||
строка самого сообщения переживает удаление вплоть до admin
|
||||
bulk-purge всей почты соответствующей партии.
|
||||
|
||||
Ответ message-detail содержит и оригинальное тело, и (если есть
|
||||
кэш) перевод; UI по умолчанию показывает перевод и предлагает
|
||||
переключение «показать оригинал».
|
||||
|
||||
### 11.5 Хуки жизненного цикла
|
||||
|
||||
Три транзитных перехода в лобби порождают system mail в inbox
|
||||
затронутых игроков:
|
||||
|
||||
- **Пауза / отмена игры.** Когда автомат партии проходит через
|
||||
`paused` или `cancelled`, лобби эмитит system-сообщение всем
|
||||
активным членам. Текст рендерится сервером по шаблону, чтобы
|
||||
игрок, открывший inbox позже, нашёл объяснение даже без
|
||||
одновременной push-сессии.
|
||||
- **Удаление / блокировка членства.** Сам-выход, удаление
|
||||
владельцем и admin-бан порождают system-сообщение только для
|
||||
затронутого игрока. Это письмо переживает переход членства в
|
||||
`removed` / `blocked` — игрок сохраняет к нему read-доступ
|
||||
навсегда (правило soft-доступа).
|
||||
|
||||
Будущее удаление по неактивности должно вызывать тот же publisher,
|
||||
чтобы объяснение дошло до затронутого игрока; README пакета
|
||||
прибивает этот контракт для следующего реализатора.
|
||||
|
||||
### 11.6 Перевод
|
||||
|
||||
`diplomail_messages.body_lang` заполняется на стороне сервера в
|
||||
момент отправки внутрипроцессным детектором языка, работающим
|
||||
только по телу. Subject наследует язык тела для ключа кэша
|
||||
перевода. Когда детектор не может уверенно классифицировать тело
|
||||
(слишком короткое, пустое, смешанные скрипты), значение —
|
||||
плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода
|
||||
обходится стороной — получатели видят оригинал.
|
||||
|
||||
Перевод выполняется асинхронно. Каждая recipient-строка содержит
|
||||
снимок `preferred_language` получателя плюс метку `available_at`.
|
||||
Получатель, чей язык совпадает с детектированным `body_lang` (или
|
||||
чей preferred_language пуст / язык тела — `und`), получает
|
||||
`available_at = now()` сразу при вставке, и push-событие
|
||||
отправляется в момент `POST`. Получатель с отличающимся языком
|
||||
вставляется с `available_at IS NULL` и ждёт worker.
|
||||
|
||||
Worker (`internal/diplomail.Worker`) тикает каждые
|
||||
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и
|
||||
обрабатывает по одной паре `(message_id, target_lang)` за тик. Он
|
||||
сначала смотрит в кэш переводов; на miss дёргает настроенный
|
||||
`Translator`. Дефолт production-сборки — LibreTranslate HTTP
|
||||
клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет
|
||||
noop-translator, который доставляет сообщение в оригинале.
|
||||
|
||||
Исходы перевода:
|
||||
|
||||
- **Успех.** Строка в `diplomail_translations` создаётся (или
|
||||
переиспользуется, если параллельная попытка успела раньше),
|
||||
все pending-получатели пары переключаются на
|
||||
`available_at = now()`, и по каждому отправляется push.
|
||||
- **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate).
|
||||
Строка перевода не сохраняется; получатели доставляются с
|
||||
оригинальным телом. Последующие чтения возвращают оригинал.
|
||||
- **Транзитный сбой** (timeout, 5xx, network error). Счётчик
|
||||
попыток увеличивается, следующая попытка планируется по
|
||||
экспоненциальному backoff `1s → 2s → 4s → 8s → 16s`
|
||||
(с потолком 60s). После
|
||||
`BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`)
|
||||
worker fallback'ит на оригинальное тело. Длительный отказ
|
||||
переводчика тормозит доставку максимум на ~30 секунд на пару
|
||||
до того, как получатель увидит оригинал.
|
||||
|
||||
Кэш переводов общий: broadcast на N получателей с одинаковым
|
||||
preferred_language порождает одну строку кэша и один вызов
|
||||
переводчика, не N.
|
||||
|
||||
### 11.7 Хранение и purge
|
||||
|
||||
Сообщения живут в `diplomail_messages`; per-recipient state — в
|
||||
`diplomail_recipients` с FK-каскадом на сообщение; переводы — в
|
||||
`diplomail_translations` тоже с каскадом. IP-адрес отправителя
|
||||
снимается из `X-Forwarded-For` (форвардит gateway) и хранится в
|
||||
сообщении для сохранения доказательств.
|
||||
|
||||
Автоматического retention нет. Admin bulk-purge endpoint удаляет
|
||||
все сообщения, чья партия завершилась более `older_than_years`
|
||||
лет назад (минимум `1`); каскад удаляет recipient- и
|
||||
translation-строки той же транзакцией.
|
||||
|
||||
### 11.8 Видимость для оператора
|
||||
|
||||
Admin-поверхность экспонирует постраничный листинг всех сообщений
|
||||
(`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind`
|
||||
и `sender_kind`. Bulk-purge endpoint
|
||||
(`/api/v1/admin/mail/cleanup`) принимает порог
|
||||
`older_than_years`. Per-game admin-отправки и multi-game
|
||||
broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail`
|
||||
и `/api/v1/admin/mail/broadcast`.
|
||||
|
||||
### 11.9 Перекрёстные ссылки
|
||||
|
||||
- Обзор пакета и карта стадий:
|
||||
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||
- Рецепт развёртывания LibreTranslate для локальной разработки:
|
||||
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||
- Детали хранения:
|
||||
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||
- Push-транспорт для событий доставки: [Раздел 7](#7-канал-push).
|
||||
- Notification-каталог: kind `diplomail.message.received`:
|
||||
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||
|
||||
@@ -40,6 +40,12 @@ const (
|
||||
// the keep-alive idle timeout for the public REST listener.
|
||||
publicHTTPIdleTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
// publicHTTPCORSAllowedOriginsEnvVar names the environment variable that
|
||||
// configures the comma-separated list of browser origins permitted to
|
||||
// call the public REST surface. An empty value disables CORS entirely;
|
||||
// requests without an Origin header still pass through normally.
|
||||
publicHTTPCORSAllowedOriginsEnvVar = "GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS"
|
||||
|
||||
// publicAuthUpstreamTimeoutEnvVar names the environment variable that
|
||||
// configures the timeout budget used for public auth upstream calls.
|
||||
publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT"
|
||||
@@ -457,6 +463,12 @@ type PublicHTTPConfig struct {
|
||||
|
||||
// AntiAbuse configures the public REST anti-abuse middleware.
|
||||
AntiAbuse PublicHTTPAntiAbuseConfig
|
||||
|
||||
// CORSAllowedOrigins is the exact-match list of browser origins
|
||||
// permitted to call the public REST surface. Empty disables CORS:
|
||||
// requests without an Origin header continue to work, cross-origin
|
||||
// requests are subject to the browser's default same-origin policy.
|
||||
CORSAllowedOrigins []string
|
||||
}
|
||||
|
||||
// BackendConfig describes the consolidated backend service the gateway
|
||||
@@ -814,6 +826,16 @@ func LoadFromEnv() (Config, error) {
|
||||
}
|
||||
cfg.PublicHTTP.AuthUpstreamTimeout = publicAuthUpstreamTimeout
|
||||
|
||||
if v, ok := os.LookupEnv(publicHTTPCORSAllowedOriginsEnvVar); ok {
|
||||
origins := make([]string, 0)
|
||||
for part := range strings.SplitSeq(v, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
origins = append(origins, trimmed)
|
||||
}
|
||||
}
|
||||
cfg.PublicHTTP.CORSAllowedOrigins = origins
|
||||
}
|
||||
|
||||
if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok {
|
||||
cfg.Backend.HTTPBaseURL = v
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) {
|
||||
assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout)
|
||||
assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout)
|
||||
assert.Equal(t, defaultPublicAuthUpstreamTimeout, cfg.PublicHTTP.AuthUpstreamTimeout)
|
||||
assert.Empty(t, cfg.PublicHTTP.CORSAllowedOrigins, "default disables CORS")
|
||||
|
||||
assert.Equal(t, defaultAuthenticatedGRPCAddr, cfg.AuthenticatedGRPC.Addr)
|
||||
assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout)
|
||||
@@ -165,6 +166,22 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) {
|
||||
assert.Equal(t, defaultAuthenticatedGRPCFreshnessWindow, cfg.AuthenticatedGRPC.FreshnessWindow)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvParsesCORSAllowedOrigins(t *testing.T) {
|
||||
configEnvMu.Lock()
|
||||
defer configEnvMu.Unlock()
|
||||
|
||||
resetEnv(t)
|
||||
setBaseRequiredEnv(t)
|
||||
t.Setenv(publicHTTPCORSAllowedOriginsEnvVar, "https://www.galaxy.lan, , https://staging.galaxy.lan")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
[]string{"https://www.galaxy.lan", "https://staging.galaxy.lan"},
|
||||
cfg.PublicHTTP.CORSAllowedOrigins,
|
||||
"comma-separated list is split, whitespace-trimmed, and empty segments dropped")
|
||||
}
|
||||
|
||||
// resetEnv clears every env var the gateway config might read so that
|
||||
// individual tests can build the exact environment they need without
|
||||
// leakage from a previous test.
|
||||
@@ -179,6 +196,7 @@ func resetEnv(t *testing.T) {
|
||||
publicHTTPReadTimeoutEnvVar,
|
||||
publicHTTPIdleTimeoutEnvVar,
|
||||
publicAuthUpstreamTimeoutEnvVar,
|
||||
publicHTTPCORSAllowedOriginsEnvVar,
|
||||
backendHTTPURLEnvVar,
|
||||
backendGRPCPushURLEnvVar,
|
||||
backendGatewayClientIDEnvVar,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// withCORS returns a gin middleware that handles browser CORS preflight and
|
||||
// attaches Access-Control-Allow-* response headers when the request's Origin
|
||||
// is on the configured allow-list. Origins are compared exactly: scheme,
|
||||
// host, and port must match. An empty allow-list disables the middleware —
|
||||
// requests pass through untouched. Requests without an Origin header always
|
||||
// pass through, the middleware only acts when a browser actually asks.
|
||||
//
|
||||
// The middleware mounts before the anti-abuse layer so OPTIONS preflights
|
||||
// do not count against the rate-limit buckets for the eventual real call.
|
||||
func withCORS(allowedOrigins []string) gin.HandlerFunc {
|
||||
allowed := make(map[string]struct{}, len(allowedOrigins))
|
||||
for _, origin := range allowedOrigins {
|
||||
allowed[origin] = struct{}{}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if _, ok := allowed[origin]; !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
if reqHeaders := c.GetHeader("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
c.Header("Access-Control-Allow-Headers", reqHeaders)
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
}
|
||||
c.Header("Access-Control-Max-Age", "3600")
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func newCORSRouter(allowedOrigins []string) *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(withCORS(allowedOrigins))
|
||||
router.GET("/api/v1/public/probe", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func TestWithCORSAllowsListedOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, "https://www.galaxy.lan", recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Equal(t, "Origin", recorder.Header().Get("Vary"))
|
||||
assert.Equal(t, "true", recorder.Header().Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
|
||||
func TestWithCORSPreflightShortCircuits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/v1/public/probe", nil)
|
||||
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Galaxy-Trace")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
assert.Equal(t, "https://www.galaxy.lan", recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Contains(t, recorder.Header().Get("Access-Control-Allow-Methods"), "POST")
|
||||
assert.Equal(t, "Content-Type, X-Galaxy-Trace", recorder.Header().Get("Access-Control-Allow-Headers"))
|
||||
assert.Equal(t, "3600", recorder.Header().Get("Access-Control-Max-Age"))
|
||||
assert.Empty(t, recorder.Body.String(), "preflight must not return a body")
|
||||
}
|
||||
|
||||
func TestWithCORSPreflightFallbackHeadersWhenRequestHeadersMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/v1/public/probe", nil)
|
||||
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
assert.Equal(t, "Content-Type, Authorization", recorder.Header().Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
|
||||
func TestWithCORSRejectsUnknownOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "real call must still succeed; the browser is the one that blocks the response")
|
||||
assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"), "no allow-origin header for rejected origin")
|
||||
}
|
||||
|
||||
func TestWithCORSPassThroughWithoutOriginHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newCORSRouter([]string{"https://www.galaxy.lan"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
func TestWithCORSDisabledByEmptyConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newCORSRouter(nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil)
|
||||
req.Header.Set("Origin", "https://www.galaxy.lan")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
@@ -278,6 +278,10 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen
|
||||
}))
|
||||
router.Use(otelgin.Middleware("galaxy-edge-gateway-public"))
|
||||
router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry))
|
||||
// CORS runs before the route classifier and anti-abuse layers so
|
||||
// preflight OPTIONS calls answer with 204 immediately and never
|
||||
// count against any rate-limit bucket.
|
||||
router.Use(withCORS(cfg.CORSAllowedOrigins))
|
||||
router.Use(withPublicRouteClass(deps.Classifier))
|
||||
router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer))
|
||||
|
||||
|
||||
+16
-4
@@ -282,12 +282,24 @@ func TestAppendRandomSuffixGenerator(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRandomSuffixGenerator(t *testing.T) {
|
||||
var last string
|
||||
for range 100 {
|
||||
// The generator draws from a ~10 000 element space (Intn(9999)
|
||||
// formatted as four digits). Comparing each sample against the
|
||||
// previous one with NotEqual flaked ~1 % per 100-iteration run on
|
||||
// natural collisions. Count unique values instead — if the
|
||||
// generator ever gets stuck on a tiny range we still catch it,
|
||||
// without depending on the birthday paradox not firing today.
|
||||
const samples = 200
|
||||
seen := make(map[string]struct{}, samples)
|
||||
for range samples {
|
||||
s := util.RandomSuffixGenerator()
|
||||
assert.Len(t, s, 4)
|
||||
assert.NotEqual(t, last, s)
|
||||
assert.True(t, strings.ContainsFunc(s, func(r rune) bool { return r >= '0' && r <= '9' }))
|
||||
last = s
|
||||
seen[s] = struct{}{}
|
||||
}
|
||||
// In 200 draws from ~10 000 the expected number of unique values
|
||||
// is ~198; a stuck generator (single value) would land at 1, a
|
||||
// 256-value range at ~196. 150 is well above the floor either
|
||||
// way and well below the expected mean.
|
||||
assert.GreaterOrEqual(t, len(seen), 150,
|
||||
"RandomSuffixGenerator drew from too small a range over %d samples", samples)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Defaults for the long-lived dev stack. Copy to `.env` and edit
|
||||
# per-environment overrides. Everything in this file is non-secret;
|
||||
# real credentials would go through Gitea Actions secrets and never
|
||||
# this file.
|
||||
#
|
||||
# The compose `${VAR:-default}` expansions fall back to the values
|
||||
# baked into `docker-compose.yml`, so this file documents the knobs
|
||||
# rather than driving them.
|
||||
|
||||
# Auto-provisioned sandbox bootstrap. Empty disables the bootstrap.
|
||||
BACKEND_DEV_SANDBOX_EMAIL=dev@galaxy.lan
|
||||
BACKEND_DEV_SANDBOX_ENGINE_IMAGE=galaxy-engine:dev
|
||||
BACKEND_DEV_SANDBOX_ENGINE_VERSION=0.1.0
|
||||
BACKEND_DEV_SANDBOX_PLAYER_COUNT=20
|
||||
|
||||
# `123456` short-circuits the email-code path for the dev account.
|
||||
# Leave empty in environments where real Mailpit codes must be used.
|
||||
BACKEND_AUTH_DEV_FIXED_CODE=123456
|
||||
|
||||
# Name of the external Docker bridge the host Caddy is attached to.
|
||||
GALAXY_EDGE_NETWORK=edge
|
||||
@@ -0,0 +1,25 @@
|
||||
# Application-routing Caddy for the long-lived dev environment.
|
||||
# Listens only on the `edge` Docker network; TLS termination and the
|
||||
# real `:80`/`:443` listeners belong to the host Caddy in front of us.
|
||||
#
|
||||
# `/srv/galaxy-ui` is mounted from the `galaxy-dev-ui-dist` named volume,
|
||||
# refreshed on every dev-deploy run.
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:80 {
|
||||
@frontend host www.galaxy.lan
|
||||
handle @frontend {
|
||||
root * /srv/galaxy-ui
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
@api host api.galaxy.lan
|
||||
handle @api {
|
||||
reverse_proxy galaxy-api:8080
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Production placeholder. Mirrors `Caddyfile.dev` but uses real
|
||||
# hostnames and lets Caddy auto-provision TLS certificates. Not used
|
||||
# until prod-deploy plumbing exists; kept under version control so the
|
||||
# dev/prod surface stays symmetric.
|
||||
|
||||
www.galaxy.com {
|
||||
root * /srv/galaxy-ui
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
api.galaxy.com {
|
||||
reverse_proxy galaxy-api:8080
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
.PHONY: help up down rebuild logs status clean-data health psql build-engine seed-ui
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
REPO_ROOT := $(realpath $(CURDIR)/../..)
|
||||
ENGINE_IMAGE := galaxy-engine:dev
|
||||
ENGINE_LABEL := org.opencontainers.image.title=galaxy-game-engine
|
||||
# Game-state root lives under the invoking user's home by default so
|
||||
# `make up` works without sudo. Override `GALAXY_DEV_GAME_STATE_DIR`
|
||||
# in the environment or `.env` to relocate (e.g. /var/lib/galaxy-dev/
|
||||
# game-state in a production-shaped host). The value flows through to
|
||||
# both the compose bind-mount and the backend's
|
||||
# `BACKEND_GAME_STATE_ROOT`.
|
||||
export GALAXY_DEV_GAME_STATE_DIR ?= $(HOME)/.galaxy-dev/game-state
|
||||
|
||||
COMPOSE := docker compose
|
||||
|
||||
help:
|
||||
@echo "Long-lived Galaxy dev environment (https://*.galaxy.lan):"
|
||||
@echo " make up Build images, ensure engine image, bring stack up"
|
||||
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
|
||||
@echo " make build-engine Build $(ENGINE_IMAGE) from game/Dockerfile (no-op if present)"
|
||||
@echo " make seed-ui Build ui/frontend and load into galaxy-dev-ui-dist volume"
|
||||
@echo " make down Stop containers, keep named volumes"
|
||||
@echo " make logs Tail all logs"
|
||||
@echo " make status docker compose ps"
|
||||
@echo " make health Probe the stack through the host Caddy"
|
||||
@echo " make psql Open a psql shell as galaxy@galaxy_backend"
|
||||
@echo " make clean-data Stop everything and wipe named volumes + game-state"
|
||||
@echo ""
|
||||
@echo "Requires:"
|
||||
@echo " - external Docker network '$${GALAXY_EDGE_NETWORK:-edge}'"
|
||||
@echo " (docker network create edge)"
|
||||
@echo " - host Caddy proxying *.galaxy.lan into that network"
|
||||
@echo " - game-state dir: $(GALAXY_DEV_GAME_STATE_DIR) (auto-created)"
|
||||
|
||||
up: build-engine
|
||||
mkdir -p "$(GALAXY_DEV_GAME_STATE_DIR)"
|
||||
$(COMPOSE) up -d --wait
|
||||
|
||||
rebuild: build-engine
|
||||
$(COMPOSE) build --no-cache galaxy-backend galaxy-api
|
||||
mkdir -p "$(GALAXY_DEV_GAME_STATE_DIR)"
|
||||
$(COMPOSE) up -d --wait
|
||||
|
||||
build-engine:
|
||||
@if docker image inspect $(ENGINE_IMAGE) >/dev/null 2>&1; then \
|
||||
echo "$(ENGINE_IMAGE) already built; skipping (use 'docker rmi $(ENGINE_IMAGE)' to force a rebuild)."; \
|
||||
else \
|
||||
echo "building $(ENGINE_IMAGE)…"; \
|
||||
docker build -t $(ENGINE_IMAGE) -f $(REPO_ROOT)/game/Dockerfile $(REPO_ROOT); \
|
||||
fi
|
||||
|
||||
# Build the UI frontend and load the resulting build/ directory into
|
||||
# the named volume Caddy serves from. Used by the dev-deploy workflow
|
||||
# and by anyone bringing the stack up by hand.
|
||||
seed-ui:
|
||||
@if [ ! -d $(REPO_ROOT)/ui/frontend/node_modules ]; then \
|
||||
echo "installing UI dependencies…"; \
|
||||
(cd $(REPO_ROOT)/ui && pnpm install --frozen-lockfile); \
|
||||
fi
|
||||
@echo "building UI (vite build)…"
|
||||
(cd $(REPO_ROOT)/ui/frontend && \
|
||||
VITE_GATEWAY_BASE_URL=https://api.galaxy.lan \
|
||||
VITE_GATEWAY_RESPONSE_PUBLIC_KEY=$$(cat $(REPO_ROOT)/ui/frontend/.env.development \
|
||||
| sed -n 's/^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=//p') \
|
||||
pnpm build)
|
||||
@echo "loading build/ into galaxy-dev-ui-dist volume…"
|
||||
docker volume create galaxy-dev-ui-dist >/dev/null
|
||||
docker run --rm \
|
||||
-v galaxy-dev-ui-dist:/dst \
|
||||
-v $(REPO_ROOT)/ui/frontend/build:/src:ro \
|
||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||
|
||||
down:
|
||||
$(COMPOSE) down
|
||||
|
||||
logs:
|
||||
$(COMPOSE) logs -f --tail=100
|
||||
|
||||
status:
|
||||
$(COMPOSE) ps
|
||||
|
||||
health:
|
||||
@echo "Frontend (https://www.galaxy.lan):"
|
||||
@curl -sS -o /dev/null -w " HTTP %{http_code}\n" https://www.galaxy.lan/ || echo " unreachable"
|
||||
@echo "API healthz (https://api.galaxy.lan/healthz):"
|
||||
@curl -sS -o /dev/null -w " HTTP %{http_code}\n" https://api.galaxy.lan/healthz || echo " unreachable"
|
||||
|
||||
psql:
|
||||
$(COMPOSE) exec galaxy-postgres psql -U galaxy -d galaxy_backend
|
||||
|
||||
clean-data:
|
||||
@echo "Stopping containers and engines, then wiping volumes + game-state…"
|
||||
@ids=$$(docker ps -aq --filter label=$(ENGINE_LABEL)); \
|
||||
if [ -n "$$ids" ]; then \
|
||||
echo "stopping engine containers…"; \
|
||||
docker rm -f $$ids >/dev/null; \
|
||||
fi
|
||||
$(COMPOSE) down -v
|
||||
@if [ -d "$(GALAXY_DEV_GAME_STATE_DIR)" ]; then \
|
||||
echo "wiping $(GALAXY_DEV_GAME_STATE_DIR)…"; \
|
||||
docker run --rm -v "$(GALAXY_DEV_GAME_STATE_DIR):/state" alpine sh -c 'rm -rf /state/*' 2>/dev/null \
|
||||
|| rm -rf "$(GALAXY_DEV_GAME_STATE_DIR)"/* 2>/dev/null || true; \
|
||||
fi
|
||||
@@ -0,0 +1,188 @@
|
||||
# `tools/dev-deploy/` — long-lived Galaxy dev environment
|
||||
|
||||
A docker-compose stack that runs the Galaxy backend, gateway, supporting
|
||||
services, and a small Caddy in front of them, reachable through the host
|
||||
Caddy at `https://www.galaxy.lan` and `https://api.galaxy.lan`. Used by
|
||||
the `dev-deploy.yaml` Gitea Actions workflow as the canonical dev target
|
||||
on every merge into the `development` branch, and runnable by hand
|
||||
through this Makefile for local debugging of the deploy plumbing
|
||||
itself.
|
||||
|
||||
This stack is **not** the developer's primary playground for UI work —
|
||||
that role still belongs to [`tools/local-dev/`](../local-dev/README.md),
|
||||
which is faster (Vite HMR, host-side dev server) and isolated to one
|
||||
developer. The two stacks coexist on the same host because every name
|
||||
is distinct:
|
||||
|
||||
| | `tools/local-dev/` | `tools/dev-deploy/` |
|
||||
|------------------|------------------------------|-----------------------------|
|
||||
| Compose project | `local-dev` | `galaxy-dev` |
|
||||
| Container prefix | `galaxy-local-dev-*` | `galaxy-dev-*` |
|
||||
| Network | `galaxy-local-dev-net` | `galaxy-dev-internal`, `edge` |
|
||||
| Volumes | `galaxy-local-dev-*` | `galaxy-dev-*` |
|
||||
| Host ports | 5433/6380/8025/8080/9090 | none (only `edge` network) |
|
||||
| Game state | `/tmp/galaxy-game-state` | `/var/lib/galaxy-dev/game-state` |
|
||||
| Engine image | `galaxy-engine:local-dev` | `galaxy-engine:dev` |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The host must already provide:
|
||||
|
||||
- Docker daemon reachable as the user running `make` (member of the
|
||||
`docker` group, no sudo).
|
||||
- An external bridge network named `edge` (or whatever
|
||||
`GALAXY_EDGE_NETWORK` overrides to):
|
||||
|
||||
```sh
|
||||
docker network create edge
|
||||
```
|
||||
|
||||
- A host Caddy listening on `:80`/`:443`, attached to the `edge`
|
||||
network, and proxying `www.galaxy.lan` and `api.galaxy.lan` to
|
||||
`galaxy-caddy:80`. Example fragment for the host Caddyfile:
|
||||
|
||||
```caddy
|
||||
www.galaxy.lan, api.galaxy.lan {
|
||||
tls internal
|
||||
reverse_proxy galaxy-caddy:80
|
||||
}
|
||||
```
|
||||
|
||||
- Game-state directory writable by the user running `make`. Default
|
||||
is `${HOME}/.galaxy-dev/game-state`; `make up` creates it on demand.
|
||||
Override by exporting `GALAXY_DEV_GAME_STATE_DIR` (e.g. to
|
||||
`/var/lib/galaxy-dev/game-state` once the host is provisioned for
|
||||
it).
|
||||
|
||||
## Bring it up
|
||||
|
||||
```sh
|
||||
make -C tools/dev-deploy up
|
||||
```
|
||||
|
||||
`up` (re)builds the local-dev backend and gateway images, makes sure the
|
||||
engine image `galaxy-engine:dev` exists, and waits for healthchecks. It
|
||||
does **not** seed the UI volume — that is normally done by CI. The first
|
||||
time you run by hand:
|
||||
|
||||
```sh
|
||||
make -C tools/dev-deploy seed-ui
|
||||
make -C tools/dev-deploy up
|
||||
make -C tools/dev-deploy health
|
||||
```
|
||||
|
||||
`seed-ui` runs `pnpm build` in `ui/frontend/`, then copies the resulting
|
||||
`build/` tree into the `galaxy-dev-ui-dist` volume. Subsequent CI deploys
|
||||
overwrite this volume automatically.
|
||||
|
||||
## Daily flow
|
||||
|
||||
```sh
|
||||
make -C tools/dev-deploy rebuild # rebuild backend/gateway images + up
|
||||
make -C tools/dev-deploy logs # tail compose logs
|
||||
make -C tools/dev-deploy health # probe https://*.galaxy.lan
|
||||
make -C tools/dev-deploy down # stop, keep state
|
||||
```
|
||||
|
||||
State persists in named volumes between `up`/`down` cycles. The
|
||||
`development` branch keeps the dev environment continuously usable —
|
||||
games created last week survive into this week unless somebody
|
||||
calls `make clean-data`.
|
||||
|
||||
## Logging in
|
||||
|
||||
The same dev-mode email-code override as `tools/local-dev/` applies:
|
||||
|
||||
1. Enter `dev@galaxy.lan` (or whatever `BACKEND_DEV_SANDBOX_EMAIL`
|
||||
resolves to) in the login form.
|
||||
2. Submit `123456` as the code if `BACKEND_AUTH_DEV_FIXED_CODE` is
|
||||
non-empty. Otherwise open Mailpit at
|
||||
`http://galaxy-mailpit:8025/` from inside the network or proxy it
|
||||
through the host Caddy when needed.
|
||||
|
||||
The fixed-code override is rejected by production env loaders, so it
|
||||
cannot leak into the prod environment.
|
||||
|
||||
## Networking
|
||||
|
||||
```
|
||||
Browser
|
||||
│ https://www.galaxy.lan, https://api.galaxy.lan
|
||||
▼
|
||||
host-Caddy (:80, :443, TLS, attached to `edge` network)
|
||||
│ reverse_proxy *.galaxy.lan → galaxy-caddy:80
|
||||
▼
|
||||
galaxy-caddy (networks: edge + galaxy-dev-internal)
|
||||
│ www.galaxy.lan → file_server /srv/galaxy-ui (volume galaxy-dev-ui-dist)
|
||||
│ api.galaxy.lan → reverse_proxy galaxy-api:8080
|
||||
▼
|
||||
galaxy-dev-internal
|
||||
├─ galaxy-api (gateway: :8080 REST, :9090 gRPC)
|
||||
├─ galaxy-backend (backend: :8080 HTTP, :8081 gRPC push)
|
||||
├─ galaxy-postgres (postgres: :5432)
|
||||
├─ galaxy-redis (redis: :6379)
|
||||
├─ galaxy-mailpit (mailpit: :8025 UI, :1025 SMTP)
|
||||
└─ engine containers (spawned by backend on demand)
|
||||
```
|
||||
|
||||
The compose project deliberately exposes no host ports. Diagnostics
|
||||
that used to go through `localhost:8025` etc. now go through the
|
||||
container network: `docker compose -f tools/dev-deploy/docker-compose.yml
|
||||
exec galaxy-mailpit wget -qO- localhost:8025/messages` and similar.
|
||||
|
||||
## Persistent state and schema changes
|
||||
|
||||
The dev Postgres volume `galaxy-dev-postgres-data` survives redeploys.
|
||||
Until the pre-production migration rule is lifted, every
|
||||
backward-incompatible change to `backend/internal/postgres/migrations/00001_init.sql`
|
||||
needs a manual wipe before the next deploy succeeds:
|
||||
|
||||
```sh
|
||||
make -C tools/dev-deploy clean-data
|
||||
make -C tools/dev-deploy up
|
||||
```
|
||||
|
||||
This is the same caveat as `tools/local-dev/`, just with a different
|
||||
volume name.
|
||||
|
||||
## Make targets
|
||||
|
||||
```text
|
||||
make up Build images, ensure engine image, bring stack up (waits for health)
|
||||
make rebuild Rebuild backend / gateway images (ignores cache), then up
|
||||
make seed-ui pnpm build + load build/ into galaxy-dev-ui-dist volume
|
||||
make build-engine Build galaxy-engine:dev (no-op if image already present)
|
||||
make down Stop containers, keep named volumes
|
||||
make logs Tail compose logs
|
||||
make status docker compose ps
|
||||
make health curl https://www.galaxy.lan + https://api.galaxy.lan/healthz
|
||||
make psql psql as galaxy@galaxy_backend
|
||||
make clean-data Stop everything and wipe volumes + game-state dir
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml` — six services: postgres, redis, mailpit,
|
||||
galaxy-backend, galaxy-api, galaxy-caddy. Reuses the alpine-runtime
|
||||
Dockerfiles from `../local-dev/` so the backend healthcheck can run
|
||||
`wget`. Reuses the dev keypair from `../local-dev/keys/`.
|
||||
- `Caddyfile.dev` — the application-routing Caddy config, mounted into
|
||||
`galaxy-caddy` at `/etc/caddy/Caddyfile`.
|
||||
- `Caddyfile.prod` — placeholder for a future prod deployment; not used
|
||||
by this compose.
|
||||
- `Makefile` — wrapper over `docker compose` with helpers for engine,
|
||||
UI seeding, health probes, and full wipe.
|
||||
- `.env.example` — non-secret defaults for the compose `${VAR:-}`
|
||||
expansions. Copy to `.env` if you want host-local overrides.
|
||||
|
||||
## Relationship to other infrastructure
|
||||
|
||||
- `tools/local-dev/` — single-developer playground, host-port mapped,
|
||||
Vite dev server on the side. Recommended for active UI work.
|
||||
- `tools/local-ci/` — Gitea + act runner for **fallback** workflow
|
||||
testing without `gitea.lan`. Optional, not part of the per-stage CI
|
||||
gate anymore.
|
||||
- `.gitea/workflows/dev-deploy.yaml` — the CI side of this stack:
|
||||
builds images, seeds the UI volume, runs `docker compose up -d` on
|
||||
every merge into `development`. The Makefile in this directory is
|
||||
what that workflow ultimately calls into.
|
||||
@@ -0,0 +1,227 @@
|
||||
# Long-lived dev environment for the Galaxy stack, deployed by the
|
||||
# `dev-deploy.yaml` Gitea Actions workflow on every merge into the
|
||||
# `development` branch and (optionally) by `make -C tools/dev-deploy up`
|
||||
# from a developer shell on the same host.
|
||||
#
|
||||
# The stack is reachable from a browser only through the host Caddy on
|
||||
# the machine, which terminates TLS and forwards `*.galaxy.lan` into the
|
||||
# external `edge` Docker network where `galaxy-caddy` does app-routing.
|
||||
# No service in this compose project binds a host port — coexistence
|
||||
# with `tools/local-dev/` (which listens on localhost:5433/6380/8025/...)
|
||||
# is achieved by distinct names, networks, and volumes.
|
||||
#
|
||||
# Browser → host-Caddy (:80/:443) → galaxy-caddy → {galaxy-api, /srv/galaxy-ui}
|
||||
#
|
||||
# Persistent state lives in named volumes under the `galaxy-dev-*`
|
||||
# prefix; surviving redeploys across compose rebuilds.
|
||||
|
||||
name: galaxy-dev
|
||||
|
||||
services:
|
||||
galaxy-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: galaxy-dev-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: galaxy
|
||||
POSTGRES_PASSWORD: galaxy
|
||||
POSTGRES_DB: galaxy_backend
|
||||
volumes:
|
||||
- galaxy-dev-postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- galaxy-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U galaxy -d galaxy_backend"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
galaxy-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: galaxy-dev-redis
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --requirepass
|
||||
- galaxy-dev
|
||||
- --appendonly
|
||||
- "no"
|
||||
- --save
|
||||
- ""
|
||||
networks:
|
||||
- galaxy-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "galaxy-dev", "PING"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
galaxy-mailpit:
|
||||
image: axllent/mailpit:v1.21
|
||||
container_name: galaxy-dev-mailpit
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- galaxy-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/livez"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
galaxy-backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tools/local-dev/backend.Dockerfile
|
||||
image: galaxy/backend:dev
|
||||
container_name: galaxy-dev-backend
|
||||
restart: unless-stopped
|
||||
user: "0:0"
|
||||
depends_on:
|
||||
galaxy-postgres:
|
||||
condition: service_healthy
|
||||
galaxy-mailpit:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BACKEND_LOGGING_LEVEL: info
|
||||
BACKEND_HTTP_LISTEN_ADDR: ":8080"
|
||||
BACKEND_GRPC_PUSH_LISTEN_ADDR: ":8081"
|
||||
BACKEND_POSTGRES_DSN: "postgres://galaxy:galaxy@galaxy-postgres:5432/galaxy_backend?search_path=backend&sslmode=disable"
|
||||
BACKEND_SMTP_HOST: galaxy-mailpit
|
||||
BACKEND_SMTP_PORT: "1025"
|
||||
BACKEND_SMTP_FROM: "galaxy-backend@galaxy.lan"
|
||||
BACKEND_SMTP_TLS_MODE: none
|
||||
BACKEND_DOCKER_NETWORK: galaxy-dev-internal
|
||||
BACKEND_GAME_STATE_ROOT: ${GALAXY_DEV_GAME_STATE_DIR}
|
||||
BACKEND_GEOIP_DB_PATH: /var/lib/galaxy/geoip.mmdb
|
||||
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.lan
|
||||
BACKEND_MAIL_WORKER_INTERVAL: 500ms
|
||||
BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms
|
||||
BACKEND_OTEL_TRACES_EXPORTER: none
|
||||
BACKEND_OTEL_METRICS_EXPORTER: none
|
||||
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-}
|
||||
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-}
|
||||
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-galaxy-engine:dev}
|
||||
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-0.1.0}
|
||||
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-20}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Per-game state directories live under the same absolute path
|
||||
# both inside the backend container and on the Docker daemon host,
|
||||
# so the bind-mount source the backend hands to the daemon
|
||||
# resolves correctly when spawning engine containers. The dev
|
||||
# environment uses a distinct prefix from `tools/local-dev/` so
|
||||
# the two stacks do not collide on the same host.
|
||||
# Game-state root must resolve to the same absolute path inside
|
||||
# the backend container and on the Docker daemon host, because
|
||||
# backend hands that path to the daemon when it spawns engine
|
||||
# containers. The Makefile exports `GALAXY_DEV_GAME_STATE_DIR`
|
||||
# to `${HOME}/.galaxy-dev/game-state` by default, so a non-root
|
||||
# runner user can write to it without sudo.
|
||||
- type: bind
|
||||
source: ${GALAXY_DEV_GAME_STATE_DIR}
|
||||
target: ${GALAXY_DEV_GAME_STATE_DIR}
|
||||
bind:
|
||||
create_host_path: true
|
||||
- ../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb:/var/lib/galaxy/geoip.mmdb:ro
|
||||
networks:
|
||||
- galaxy-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 10s
|
||||
|
||||
galaxy-api:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tools/local-dev/gateway.Dockerfile
|
||||
image: galaxy/gateway:dev
|
||||
container_name: galaxy-dev-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
galaxy-backend:
|
||||
condition: service_healthy
|
||||
galaxy-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GATEWAY_LOG_LEVEL: info
|
||||
GATEWAY_PUBLIC_HTTP_ADDR: ":8080"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ADDR: ":9090"
|
||||
GATEWAY_BACKEND_HTTP_URL: "http://galaxy-backend:8080"
|
||||
GATEWAY_BACKEND_GRPC_PUSH_URL: "galaxy-backend:8081"
|
||||
GATEWAY_BACKEND_GATEWAY_CLIENT_ID: dev-gateway-1
|
||||
GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH: /run/secrets/gateway-response.pem
|
||||
GATEWAY_REDIS_MASTER_ADDR: "galaxy-redis:6379"
|
||||
GATEWAY_REDIS_PASSWORD: galaxy-dev
|
||||
# UI lives on https://www.galaxy.lan; the API is on
|
||||
# https://api.galaxy.lan. Browsers therefore issue cross-origin
|
||||
# requests to the gateway and need an explicit allow-list.
|
||||
GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS: "https://www.galaxy.lan"
|
||||
# Anti-abuse defaults are looser than production: the dev
|
||||
# environment is shared by a handful of trusted testers who
|
||||
# frequently hammer the same identity to reproduce flows.
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_MAX_BODY_BYTES: "131072"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES: "65536"
|
||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES: "65536"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_BURST: "1000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS: "10000"
|
||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000"
|
||||
volumes:
|
||||
- ../local-dev/keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro
|
||||
networks:
|
||||
- galaxy-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
galaxy-caddy:
|
||||
image: caddy:2.11.2-alpine
|
||||
container_name: galaxy-dev-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
galaxy-api:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
|
||||
- galaxy-dev-caddy-data:/data
|
||||
- galaxy-dev-ui-dist:/srv/galaxy-ui:ro
|
||||
networks:
|
||||
- galaxy-internal
|
||||
- edge
|
||||
|
||||
networks:
|
||||
galaxy-internal:
|
||||
name: galaxy-dev-internal
|
||||
driver: bridge
|
||||
internal: false
|
||||
edge:
|
||||
name: ${GALAXY_EDGE_NETWORK:-edge}
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
galaxy-dev-postgres-data:
|
||||
name: galaxy-dev-postgres-data
|
||||
galaxy-dev-caddy-data:
|
||||
name: galaxy-dev-caddy-data
|
||||
galaxy-dev-ui-dist:
|
||||
name: galaxy-dev-ui-dist
|
||||
@@ -1,9 +1,17 @@
|
||||
# Local Gitea CI
|
||||
# Local Gitea CI (fallback)
|
||||
|
||||
> **Status:** fallback / opt-in. The primary CI target is now
|
||||
> `gitea.lan` with its host-mode `act_runner`. The per-stage CI gate
|
||||
> closes against `gitea.lan`, not against this stack. Use this
|
||||
> directory when you want to validate `.gitea/workflows/*` without
|
||||
> reaching `gitea.lan` — for example, when iterating on a workflow
|
||||
> file from a flight without LAN access — or when isolating a runner
|
||||
> issue from production-shaped infrastructure.
|
||||
|
||||
Self-contained Gitea + Actions runner for verifying
|
||||
`.gitea/workflows/*` honestly before pushing to a real Gitea instance.
|
||||
Runs natively on arm64 (Apple Silicon) — every image below has an
|
||||
arm64 variant, so Docker pulls the right architecture and the runner
|
||||
`.gitea/workflows/*` honestly before pushing to `gitea.lan`. Runs
|
||||
natively on arm64 (Apple Silicon) — every image below has an arm64
|
||||
variant, so Docker pulls the right architecture and the runner
|
||||
executes workflow steps without QEMU emulation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -10,11 +10,15 @@ FlatBuffers wire, every authenticated call verifies the response
|
||||
signature against the dev keypair, and every email passes through
|
||||
Mailpit's web UI for inspection.
|
||||
|
||||
This stack is **not** a CI gate (that role belongs to
|
||||
[`tools/local-ci/`](../local-ci/README.md), which boots a Gitea +
|
||||
Actions runner and replays workflow files). The two stacks are
|
||||
independent and can coexist on the same machine; they bind different
|
||||
ports and use different networks.
|
||||
This stack is **not** a CI gate (the per-stage CI gate now lives on
|
||||
`gitea.lan`; see project-level `CLAUDE.md`). It is also distinct from
|
||||
the **long-lived dev environment** at
|
||||
[`tools/dev-deploy/`](../dev-deploy/README.md), which is redeployed on
|
||||
every merge into `development` and is reachable as
|
||||
`https://www.galaxy.lan` / `https://api.galaxy.lan`. The three stacks
|
||||
(`tools/local-dev/`, `tools/dev-deploy/`, and the fallback
|
||||
`tools/local-ci/`) coexist on the same host because every name —
|
||||
compose project, container, network, volume — is distinct.
|
||||
|
||||
## Bring it up
|
||||
|
||||
|
||||
@@ -153,6 +153,12 @@ The stack accepts a fixed dev code (`123456`) in addition to the
|
||||
real Mailpit-delivered one. Full runbook in
|
||||
[`../tools/local-dev/README.md`](../tools/local-dev/README.md).
|
||||
|
||||
For testing the production-shaped surface — Caddy in front of the
|
||||
gateway, statically served UI bundle, real `https://*.galaxy.lan`
|
||||
hostnames — use the long-lived dev environment at
|
||||
[`../tools/dev-deploy/`](../tools/dev-deploy/README.md). It is
|
||||
redeployed by Gitea Actions on every merge into `development`.
|
||||
|
||||
## Per-phase docs
|
||||
|
||||
Topic docs live under `ui/docs/` and are added per phase as they're
|
||||
|
||||
@@ -5,7 +5,13 @@ export default defineConfig({
|
||||
testDir: "tests/e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
// host-mode CI runner shares CPU/IO with the long-lived dev stack,
|
||||
// gitea, and the user's host Caddy. The default 6 workers + 1
|
||||
// retry produced ~7 flakies + 1 hard fail per ui-test run; cap at
|
||||
// 4 workers (still parallel) and allow 4 retries to ride out
|
||||
// transient timing hiccups without inflating wall time.
|
||||
workers: 4,
|
||||
retries: process.env.CI ? 4 : 0,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: "http://localhost:5173",
|
||||
|
||||
Reference in New Issue
Block a user