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 { 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) } // ── 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 { await posts.remove(id) await activity.log({ req, action: 'post.delete', detail: { 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, }