From 986a8d5d8618ace507acd47e804815e61b4cfc77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 16:25:25 -0500 Subject: [PATCH] =?UTF-8?q?News=20post=20=E2=86=92=20town=20crier=20+=20Di?= =?UTF-8?q?scord=20announcement=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fire-and-forget Discord-only announce on publish with a retry-safe, two-leg pipeline. When a post transitions into published-news (false→true publish while in news, or category→news while published), an announce_jobs row is enqueued with two INDEPENDENT delivery legs: • town crier — sidecar POST /towncrier via uoLinkClient (stable id `post-` so a retry replaces rather than duplicates) • discord — bot POST /internal/announce via botInternalClient (single source of truth for the #news channel stays in the bot) An in-process poller (utils/announceWorker) sweeps the table every ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential backoff (30s→2h, 6 attempts). A leg is retried on transient failures (503/504/network) and failed fast on data/config errors (400 over-cap, 401/409). Publishing never blocks on the sidecar or Discord — enqueue is local DB only. Parent `status` is a done/partial/failed rollup of the two legs; posts.announced_at is stamped once both deliver. Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry (POST .../announce/retry) surfaced in the PostEditor for news posts. Pure decisions (text build/caps, classification, backoff, rollup) live in announceJobs.logic and are unit-tested (server/test/announceJobs.test.js, 10 tests). The old manual /admin/uo-link/towncrier form is untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- README.md | 2 + client/src/api/client.js | 4 + client/src/routes/admin/views/PostEditor.jsx | 95 ++++++++++++- server/.env.example | 8 ++ server/db/schema.sql | 41 ++++++ .../src/model/announceJobs/announceJobs.db.js | 68 +++++++++ .../model/announceJobs/announceJobs.logic.js | 127 +++++++++++++++++ .../model/announceJobs/announceJobs.model.js | 116 +++++++++++++++ server/src/model/posts/posts.db.js | 2 +- server/src/model/posts/posts.model.js | 11 ++ .../src/router/v1/admin/admin.controller.js | 85 +++++++---- server/src/router/v1/admin/admin.routes.js | 26 ++++ server/src/server.js | 7 + server/src/utils/announceWorker.js | 132 ++++++++++++++++++ server/src/utils/botInternalClient.js | 15 +- server/test/announceJobs.test.js | 86 ++++++++++++ 16 files changed, 790 insertions(+), 35 deletions(-) create mode 100644 server/src/model/announceJobs/announceJobs.db.js create mode 100644 server/src/model/announceJobs/announceJobs.logic.js create mode 100644 server/src/model/announceJobs/announceJobs.model.js create mode 100644 server/src/utils/announceWorker.js create mode 100644 server/test/announceJobs.test.js diff --git a/README.md b/README.md index a22b14a..e002f51 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,8 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`. | `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only | | `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) | +| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) | +| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) | --- diff --git a/client/src/api/client.js b/client/src/api/client.js index a880c5f..8fde9b1 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -112,6 +112,10 @@ export const api = { deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }), publishPost: (id, published) => req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }), + // News announcement pipeline (town crier + Discord) status + per-leg retry. + getAnnounce: (id) => req(`/admin/posts/${id}/announce`), + retryAnnounceLeg: (id, leg) => + req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }), uploadImage: (file) => { const fd = new FormData() fd.append('image', file) diff --git a/client/src/routes/admin/views/PostEditor.jsx b/client/src/routes/admin/views/PostEditor.jsx index 5747b45..fb7e68c 100644 --- a/client/src/routes/admin/views/PostEditor.jsx +++ b/client/src/routes/admin/views/PostEditor.jsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState } from 'react' +import { lazy, Suspense, useEffect, useState } from 'react' import Modal from '../../../components/Modal.jsx' import { api } from '../../../api/client.js' @@ -105,6 +105,8 @@ export default function PostEditor({ post, onClose, onSaved }) {
{error &&

{error}

} + {isEdit && post.category === 'news' && } +