diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s

Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 19:02:46 +02:00
parent b3f24cc440
commit 362f92e520
14 changed files with 1423 additions and 4 deletions
+305
View File
@@ -1183,6 +1183,45 @@ paths:
$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]
@@ -1954,6 +1993,133 @@ 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]
@@ -3983,6 +4149,145 @@ components:
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