Files
website/server/src/model/posts/posts.model.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

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,
}