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
120 lines
3.2 KiB
JavaScript
120 lines
3.2 KiB
JavaScript
const postsDb = require('./posts.db')
|
|
const { cleanBody, deriveExcerpt } = require('../../utils/sanitizeHtml')
|
|
|
|
// Sanitize body HTML and treat an empty editor (TipTap emits `<p></p>`) as null
|
|
// so we never store a meaningless empty paragraph.
|
|
function normalizeBody(body) {
|
|
const clean = cleanBody(body)
|
|
if (clean == null) return null
|
|
return String(clean).trim() === '<p></p>' ? null : clean
|
|
}
|
|
|
|
// URL category (kebab) <-> DB enum value.
|
|
const CATEGORY_MAP = {
|
|
news: 'news',
|
|
'five-on-friday': 'five_on_friday',
|
|
newsletter: 'newsletter',
|
|
screenshots: 'screenshot',
|
|
}
|
|
const URL_CATEGORIES = Object.keys(CATEGORY_MAP)
|
|
const DB_CATEGORIES = Object.values(CATEGORY_MAP)
|
|
|
|
function toDbCategory(urlCategory) {
|
|
return CATEGORY_MAP[urlCategory] || null
|
|
}
|
|
|
|
function isValidUrlCategory(urlCategory) {
|
|
return Boolean(CATEGORY_MAP[urlCategory])
|
|
}
|
|
|
|
function isValidDbCategory(dbCategory) {
|
|
return DB_CATEGORIES.includes(dbCategory)
|
|
}
|
|
|
|
async function listPublished(urlCategory) {
|
|
return postsDb.listPublished(toDbCategory(urlCategory))
|
|
}
|
|
|
|
async function getPublished(urlCategory, idOrSlug) {
|
|
const id = Number.isInteger(Number(idOrSlug)) ? Number(idOrSlug) : -1
|
|
return postsDb.findPublished(toDbCategory(urlCategory), id, String(idOrSlug))
|
|
}
|
|
|
|
async function listAll(dbCategory) {
|
|
return postsDb.listAll(dbCategory || null)
|
|
}
|
|
|
|
async function getById(id) {
|
|
return postsDb.findById(id)
|
|
}
|
|
|
|
async function create(post) {
|
|
const body = normalizeBody(post.body)
|
|
// Auto-fill the excerpt from the first of the body when left blank.
|
|
const excerpt = post.excerpt && String(post.excerpt).trim() ? post.excerpt : deriveExcerpt(body)
|
|
const id = await postsDb.insert({ ...post, body, excerpt: excerpt || null })
|
|
return postsDb.findById(id)
|
|
}
|
|
|
|
async function update(id, fields) {
|
|
const next = { ...fields }
|
|
if ('body' in next) next.body = normalizeBody(next.body)
|
|
// If the body is being updated and no non-empty excerpt was supplied,
|
|
// derive one from the new body.
|
|
if ('body' in next && !(next.excerpt && String(next.excerpt).trim())) {
|
|
next.excerpt = deriveExcerpt(next.body) || null
|
|
}
|
|
await postsDb.update(id, next)
|
|
return postsDb.findById(id)
|
|
}
|
|
|
|
async function setPublished(id, published) {
|
|
const current = await postsDb.findById(id)
|
|
if (!current) return null
|
|
const fields = { published: published ? 1 : 0 }
|
|
// Stamp published_at the first time a post goes live.
|
|
if (published && !current.published_at) fields.published_at = new Date()
|
|
await postsDb.update(id, fields)
|
|
return postsDb.findById(id)
|
|
}
|
|
|
|
// Announcement pipeline back-pointers (see model/announceJobs).
|
|
async function linkAnnounceJob(id, jobId) {
|
|
await postsDb.update(id, { announce_job_id: jobId })
|
|
}
|
|
|
|
async function markAnnounced(id, at = new Date()) {
|
|
await postsDb.update(id, { announced_at: at })
|
|
}
|
|
|
|
async function remove(id) {
|
|
return postsDb.remove(id)
|
|
}
|
|
|
|
async function counts() {
|
|
const rows = await postsDb.countByCategory()
|
|
return rows.reduce((acc, row) => {
|
|
acc[row.category] = Number(row.c)
|
|
return acc
|
|
}, {})
|
|
}
|
|
|
|
module.exports = {
|
|
URL_CATEGORIES,
|
|
DB_CATEGORIES,
|
|
toDbCategory,
|
|
isValidUrlCategory,
|
|
isValidDbCategory,
|
|
listPublished,
|
|
getPublished,
|
|
listAll,
|
|
getById,
|
|
create,
|
|
update,
|
|
setPublished,
|
|
linkAnnounceJob,
|
|
markAnnounced,
|
|
remove,
|
|
counts,
|
|
}
|