Files
website/server/src/model/announceJobs/announceJobs.db.js
Claude 986a8d5d86 News post → town crier + Discord announcement pipeline
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-<id>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 16:25:25 -05:00

69 lines
2.3 KiB
JavaScript

const { query } = require('../../utils/db')
const COLS =
'id, post_id, status, ' +
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
'created_at, updated_at'
// Whitelist so a `leg` value can be interpolated into a column name safely — it
// never comes from raw user input, but keep the guard explicit.
const LEGS = ['towncrier', 'discord']
function assertLeg(leg) {
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
}
async function create(postId) {
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
return res.insertId
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findByPostId(postId) {
const rows = await query(
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
[postId],
)
return rows[0] || null
}
// Jobs with at least one leg that is due now: pending and either never scheduled
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
async function findDue(now = new Date(), limit = 25) {
return query(
`SELECT ${COLS} FROM announce_jobs
WHERE (towncrier_status = 'pending'
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
OR (discord_status = 'pending'
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
ORDER BY id ASC
LIMIT ?`,
[now, now, limit],
)
}
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
assertLeg(leg)
await query(
`UPDATE announce_jobs SET
${leg}_status = ?,
${leg}_attempts = ?,
${leg}_last_error = ?,
${leg}_next_attempt_at = ?
WHERE id = ?`,
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
)
}
async function setStatus(id, status) {
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
}
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }