Files
website/server/src/router/v1/admin/admin.controller.js
Claude 55a3adea99 feat(news): auto-push published news to the in-game Town Cryer News gump (2.1)
Phase 4: sync the site's published news posts into the Protocol 2.1 News gump.

- uoLinkClient.postNews / deleteNews.
- utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article
  stays in the gump while its post is published news and is pulled when it leaves
  that state. buildArticle renders a compact gump-HTML block (centred title +
  plain-text excerpt — the gump supports only a small HTML subset) with a
  "more info" link to /site/news and an optional gump image from the
  `news_gump_image` setting. Every call is best-effort / never-throws.
- Hooked into the posts pipeline alongside the existing announce enqueue:
  syncPost on create/update/publish (fresh publish announces; edits refresh
  silently; leaving published-news pulls the article), removePost on delete.
- reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect —
  reconciles the gump to our source of truth and recovers any article whose
  original live push failed (silent, so a reconnect never re-proclaims old news).

Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:40:47 -05:00

662 lines
24 KiB
JavaScript

const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const newsGump = require('../../../utils/newsGump')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const log = require('../../../utils/logger')('admin')
// Fire the announcement pipeline the moment a post transitions INTO
// "published news" — a false→true publish while in news, or a category change
// into news while already published. Enqueues one announce_jobs row whose two
// legs (in-game town crier + Discord #news) are then delivered with independent
// retry by the dispatcher worker (utils/announceWorker). Fire-and-forget and
// self-guarding (enqueueIfNeeded never throws and de-dupes via the post's
// existing announce_job_id) so a pipeline hiccup never breaks saving a post.
// Awaited (not fire-and-forget) because enqueue is purely local DB work — one
// INSERT + a back-pointer UPDATE, no network — so it never blocks on the sidecar
// or Discord (that happens later in the worker). Awaiting keeps the de-dup guard
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
async function announceIfNewlyPublished(post, transition) {
await announceJobs.enqueueIfNeeded(post, transition)
// Keep the in-game Town Cryer News gump in sync with the same transition: push
// the article when it becomes published news, refresh it silently on an edit,
// and pull it when it leaves published-news. Best-effort (never throws), so a
// sidecar hiccup never breaks saving a post — same guarantee as the enqueue.
await newsGump.syncPost(post, transition)
}
// ── Dashboard & site mode ─────────────────────────────────────────────
async function dashboard(req, res) {
try {
return res.json({
site_mode: (await settings.get('site_mode')) || 'live',
last_change: {
at: await settings.get('site_mode_changed_at'),
by: await settings.get('site_mode_changed_by'),
},
counts: {
posts: await posts.counts(),
users: await users.count(),
},
recent_activity: await activity.list({ limit: 10 }),
})
} catch (err) {
log.error('dashboard', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function setSiteMode(req, res) {
const { mode } = req.body
try {
const changedAt = new Date().toISOString()
await settings.setMany(
{
site_mode: mode,
site_mode_changed_at: changedAt,
site_mode_changed_by: req.user.username,
},
req.user.id,
)
await activity.log({ req, action: 'site_mode.change', detail: { mode } })
log.info('site mode changed', { mode, by: req.user.username, ip: req.ip })
return res.json({ site_mode: mode, changed_at: changedAt, changed_by: req.user.username })
} catch (err) {
log.error('setSiteMode', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Posts ─────────────────────────────────────────────────────────────
async function listPosts(req, res) {
try {
let dbCategory = null
if (req.query.category) {
dbCategory = posts.toDbCategory(req.query.category)
if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
}
return res.json(await posts.listAll(dbCategory))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getPost(req, res) {
try {
const post = await posts.getById(Number(req.params.id))
if (!post) return res.status(404).json({ message: 'Not found' })
return res.json(post)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createPost(req, res) {
const dbCategory = posts.toDbCategory(req.body.category)
if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
if (dbCategory === 'screenshot' && !req.body.image_url) {
return res.status(400).json({ message: 'Screenshots require an image_url' })
}
try {
const created = await posts.create({
category: dbCategory,
title: req.body.title,
slug: req.body.slug || null,
excerpt: req.body.excerpt || null,
body: req.body.body || null,
image_url: req.body.image_url || null,
published: Boolean(req.body.published),
author_id: req.user.id,
})
await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
await announceIfNewlyPublished(created, { wasPublished: false, wasNews: false })
return res.status(201).json(created)
} catch (err) {
log.error('createPost', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updatePost(req, res) {
const id = Number(req.params.id)
try {
const current = await posts.getById(id)
if (!current) return res.status(404).json({ message: 'Not found' })
const fields = {}
for (const key of ['title', 'slug', 'excerpt', 'body', 'image_url']) {
if (key in req.body) fields[key] = req.body[key] || null
}
if ('category' in req.body) {
const dbCategory = posts.toDbCategory(req.body.category)
if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
fields.category = dbCategory
}
if ('published' in req.body) {
fields.published = req.body.published ? 1 : 0
if (req.body.published && !current.published_at) fields.published_at = new Date()
}
const updated = await posts.update(id, fields)
await activity.log({ req, action: 'post.update', detail: { id } })
await announceIfNewlyPublished(updated, {
wasPublished: Boolean(current.published),
wasNews: current.category === 'news',
})
return res.json(updated)
} catch (err) {
log.error('updatePost', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function publishPost(req, res) {
const id = Number(req.params.id)
try {
const current = await posts.getById(id)
if (!current) return res.status(404).json({ message: 'Not found' })
const updated = await posts.setPublished(id, Boolean(req.body.published))
await activity.log({
req,
action: 'post.publish',
detail: { id, published: Boolean(req.body.published) },
})
await announceIfNewlyPublished(updated, {
wasPublished: Boolean(current.published),
wasNews: current.category === 'news',
})
return res.json(updated)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deletePost(req, res) {
const id = Number(req.params.id)
try {
const current = await posts.getById(id)
await posts.remove(id)
await activity.log({ req, action: 'post.delete', detail: { id } })
// If it was live in the News gump, pull it (best-effort).
if (newsGump.inGump(current)) await newsGump.removePost(id)
return res.json({ id })
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/posts/:id/announce — the announcement job for a post (or null if it
// was never announced), for the status panel on the post editor.
async function getAnnounceStatus(req, res) {
const id = Number(req.params.id)
try {
const job = await announceJobs.getByPostId(id)
return res.json(job || null)
} catch (err) {
log.error('getAnnounceStatus', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/posts/:id/announce/retry — reset one delivery leg to pending so
// the dispatcher re-attempts it (e.g. after fixing the news channel / sidecar).
async function retryAnnounceLeg(req, res) {
const id = Number(req.params.id)
const leg = req.body.leg
try {
const job = await announceJobs.resetLeg(id, leg)
if (!job) return res.status(404).json({ message: 'No announcement job for this post' })
await activity.log({ req, action: 'post.announce.retry', detail: { id, leg } })
return res.json(job)
} catch (err) {
log.error('retryAnnounceLeg', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function uploadImage(req, res) {
if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
const imageUrl = `/uploads/${req.file.filename}`
await activity.log({ req, action: 'post.upload', detail: { image_url: imageUrl } })
return res.status(201).json({ image_url: imageUrl })
}
// Generalized upload used by rich-text editors (wiki, etc.). Same multer config
// as the screenshot upload; returns a neutral { url }.
async function uploadFile(req, res) {
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
const url = `/uploads/${req.file.filename}`
await activity.log({ req, action: 'upload', detail: { url } })
return res.status(201).json({ url })
}
// ── Wiki pages ─────────────────────────────────────────────────────────
async function listWiki(req, res) {
try {
const q = (req.query.q || '').trim()
if (q) return res.json(await wiki.search(q, { publishedOnly: false }))
const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
filters.categoryId = category ? category.id : -1 // unknown → match nothing
}
if (req.query.tag) {
const tag = await wiki.getTagBySlug(req.query.tag)
filters.tagId = tag ? tag.id : -1 // unknown → match nothing
}
if (req.query.status === 'draft') filters.published = false
if (req.query.status === 'published') filters.published = true
return res.json(await wiki.listAll(filters))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWiki(req, res) {
try {
const page = await wiki.getBySlug(req.params.slug)
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Resolve a category_id from the request, validating it exists. Returns
// { ok, value } so the caller can distinguish "not provided" from "invalid".
async function resolveCategoryId(body) {
if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
return { ok: true, value: null }
}
const category = await wiki.getCategoryById(Number(body.category_id))
if (!category) return { ok: false }
return { ok: true, value: category.id }
}
async function createWiki(req, res) {
try {
if (await wiki.getBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A page with that slug already exists' })
}
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
const page = await wiki.create({
slug: req.body.slug,
title: req.body.title,
body: req.body.body || null,
excerpt: req.body.excerpt || null,
categoryId: cat.value,
published: req.body.published !== false,
tags: Array.isArray(req.body.tags) ? req.body.tags : undefined,
updatedBy: req.user.id,
})
await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
return res.status(201).json(page)
} catch (err) {
log.error('createWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateWiki(req, res) {
try {
const existing = await wiki.getBySlug(req.params.slug)
if (!existing) return res.status(404).json({ message: 'Not found' })
const input = { updatedBy: req.user.id }
if ('title' in req.body) input.title = req.body.title
if ('body' in req.body) input.body = req.body.body || null
if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
if ('published' in req.body) input.published = Boolean(req.body.published)
if ('change_note' in req.body) input.changeNote = req.body.change_note
if ('tags' in req.body) input.tags = Array.isArray(req.body.tags) ? req.body.tags : []
if ('category_id' in req.body) {
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
input.categoryId = cat.value
}
const page = await wiki.update(req.params.slug, input)
await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
return res.json(page)
} catch (err) {
log.error('updateWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function publishWiki(req, res) {
try {
const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.publish',
detail: { slug: req.params.slug, published: Boolean(req.body.published) },
})
return res.json(page)
} catch (err) {
log.error('publishWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWiki(req, res) {
try {
await wiki.remove(req.params.slug)
await activity.log({ req, action: 'wiki.delete', detail: { slug: req.params.slug } })
return res.json({ slug: req.params.slug })
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki revisions ─────────────────────────────────────────────────────
async function listWikiRevisions(req, res) {
try {
const revisions = await wiki.listRevisions(req.params.slug)
if (revisions == null) return res.status(404).json({ message: 'Not found' })
return res.json(revisions)
} catch (err) {
log.error('listWikiRevisions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiRevision(req, res) {
try {
const rev = await wiki.getRevision(req.params.slug, Number(req.params.id))
if (!rev) return res.status(404).json({ message: 'Not found' })
return res.json(rev)
} catch (err) {
log.error('getWikiRevision', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function restoreWikiRevision(req, res) {
try {
const page = await wiki.restoreRevision(req.params.slug, Number(req.params.id), req.user.id)
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.revision.restore',
detail: { slug: req.params.slug, revision: Number(req.params.id) },
})
return res.json(page)
} catch (err) {
log.error('restoreWikiRevision', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki tags ──────────────────────────────────────────────────────────
async function listWikiTags(req, res) {
try {
return res.json(await wiki.listTags())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Wiki categories ────────────────────────────────────────────────────
async function listWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createWikiCategory(req, res) {
try {
if (await wiki.getCategoryBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A category with that slug already exists' })
}
const category = await wiki.createCategory({
slug: req.body.slug,
title: req.body.title,
description: req.body.description || null,
sortOrder: Number(req.body.sort_order) || 0,
})
await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
return res.status(201).json(category)
} catch (err) {
log.error('createWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
const input = {}
if ('title' in req.body) input.title = req.body.title
if ('description' in req.body) input.description = req.body.description || null
if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
if ('slug' in req.body && req.body.slug !== existing.slug) {
const clash = await wiki.getCategoryBySlug(req.body.slug)
if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
input.slug = req.body.slug
}
const category = await wiki.updateCategory(id, input)
await activity.log({ req, action: 'wiki.category.update', detail: { id } })
return res.json(category)
} catch (err) {
log.error('updateWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
await wiki.removeCategory(id) // pages in it become uncategorized
await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Settings ──────────────────────────────────────────────────────────
async function getSettings(req, res) {
try {
return res.json(await settings.getAll())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateSettings(req, res) {
const updates = req.body
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ message: 'Expected an object of key/value settings' })
}
// Enum-constrained keys are validated here (the store itself is schemaless).
if (
settings.REGISTRATION_KEY in updates &&
!settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
// against the same allowlist as post/wiki bodies so a stored value is safe (the
// client re-sanitizes on render as defense in depth).
if (typeof updates.homepage_teaser === 'string') {
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
}
try {
await settings.setMany(updates, req.user.id)
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
return res.json(await settings.getAll())
} catch (err) {
log.error('updateSettings', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Activity log ──────────────────────────────────────────────────────
async function listActivity(req, res) {
const limit = Math.min(Number(req.query.limit) || 50, 200)
const offset = Number(req.query.offset) || 0
try {
return res.json(await activity.list({ limit, offset }))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── User management ───────────────────────────────────────────────────
async function listUsers(req, res) {
try {
return res.json(await users.list())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createUser(req, res) {
try {
if (await users.getRawByUsername(req.body.username)) {
return res.status(409).json({ message: 'Username already taken' })
}
const user = await users.createUser({
username: req.body.username,
password: req.body.password,
role: req.body.role || 'admin',
email: req.body.email || null,
status: req.body.status || 'active',
})
await activity.log({
req,
action: 'user.create',
detail: { id: user.id, username: user.username, role: user.role },
})
return res.status(201).json(user)
} catch (err) {
log.error('createUser', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateUser(req, res) {
const id = Number(req.params.id)
try {
const target = await users.getById(id)
if (!target) return res.status(404).json({ message: 'Not found' })
// If the username is changing, make sure no other user already has it —
// return 409 rather than letting the DB unique constraint throw a 500.
if (req.body.username && req.body.username !== target.username) {
const clash = await users.getRawByUsername(req.body.username)
if (clash) return res.status(409).json({ message: 'Username already taken' })
}
// Don't let the last admin demote themselves out of admin access.
if (target.role === 'admin' && req.body.role && req.body.role !== 'admin') {
if ((await users.countAdmins()) <= 1) {
return res.status(400).json({ message: 'Cannot demote the last admin' })
}
}
const user = await users.update(id, {
username: req.body.username,
password: req.body.password,
role: req.body.role,
email: req.body.email,
status: req.body.status,
})
await activity.log({ req, action: 'user.update', detail: { id } })
// Distinct audit trail for the security-sensitive fields (role & status),
// so a promotion/ban is greppable beyond the generic user.update entry.
if (req.body.role && req.body.role !== target.role) {
await activity.log({
req,
action: 'admin.user.role_change',
detail: { id, from: target.role, to: req.body.role },
})
}
if (req.body.status && req.body.status !== target.status) {
await activity.log({
req,
action: 'admin.user.status_change',
detail: { id, from: target.status, to: req.body.status },
})
}
return res.json(user)
} catch (err) {
log.error('updateUser', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteUser(req, res) {
const id = Number(req.params.id)
try {
if (id === req.user.id) {
return res.status(400).json({ message: 'You cannot delete your own account' })
}
const target = await users.getById(id)
if (!target) return res.status(404).json({ message: 'Not found' })
if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
return res.status(400).json({ message: 'Cannot delete the last admin' })
}
await users.remove(id)
await activity.log({ req, action: 'user.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteUser', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
dashboard,
setSiteMode,
listPosts,
getPost,
createPost,
updatePost,
publishPost,
deletePost,
getAnnounceStatus,
retryAnnounceLeg,
uploadImage,
uploadFile,
listWiki,
getWiki,
createWiki,
updateWiki,
publishWiki,
deleteWiki,
listWikiRevisions,
getWikiRevision,
restoreWikiRevision,
listWikiTags,
listWikiCategories,
createWikiCategory,
updateWikiCategory,
deleteWikiCategory,
getSettings,
updateSettings,
listActivity,
listUsers,
createUser,
updateUser,
deleteUser,
}