Phase 5 — Thread tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+175
-1
@@ -1,4 +1,178 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
const STATUSES = ['active', 'resolved', 'suspended', 'complicated'];
|
||||
|
||||
const OPTIONAL_TEXT_FIELDS = [
|
||||
'notes',
|
||||
'related_npcs',
|
||||
'related_location',
|
||||
'origin',
|
||||
'stakes',
|
||||
'last_development',
|
||||
'next_beat',
|
||||
'suspected_resolution',
|
||||
];
|
||||
|
||||
const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?');
|
||||
const listThreadsStmt = db.prepare('SELECT * FROM threads WHERE campaign_id = ? ORDER BY created_at DESC');
|
||||
const getThreadStmt = db.prepare('SELECT * FROM threads WHERE id = ? AND campaign_id = ?');
|
||||
|
||||
const insertThreadStmt = db.prepare(`
|
||||
INSERT INTO threads (
|
||||
campaign_id, title, status, notes, related_npcs, related_location,
|
||||
origin, stakes, last_development, next_beat, suspected_resolution
|
||||
) VALUES (
|
||||
@campaign_id, @title, @status, @notes, @related_npcs, @related_location,
|
||||
@origin, @stakes, @last_development, @next_beat, @suspected_resolution
|
||||
)
|
||||
`);
|
||||
|
||||
const updateThreadStmt = db.prepare(`
|
||||
UPDATE threads SET
|
||||
title = @title,
|
||||
status = @status,
|
||||
notes = @notes,
|
||||
related_npcs = @related_npcs,
|
||||
related_location = @related_location,
|
||||
origin = @origin,
|
||||
stakes = @stakes,
|
||||
last_development = @last_development,
|
||||
next_beat = @next_beat,
|
||||
suspected_resolution = @suspected_resolution
|
||||
WHERE id = @id AND campaign_id = @campaign_id
|
||||
`);
|
||||
|
||||
const deleteThreadStmt = db.prepare('DELETE FROM threads WHERE id = ? AND campaign_id = ?');
|
||||
|
||||
function parsePositiveInt(raw, res, label) {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
res.status(400).json({ error: `${label} must be a positive integer` });
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadCampaignId(req, res) {
|
||||
const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id');
|
||||
if (campaignId === null) return null;
|
||||
|
||||
if (!getCampaignByIdStmt.get(campaignId)) {
|
||||
res.status(404).json({ error: 'campaign not found' });
|
||||
return null;
|
||||
}
|
||||
return campaignId;
|
||||
}
|
||||
|
||||
// Shared by create and update — PUT sends the full form, so both treat
|
||||
// title/status the same way rather than supporting partial patches.
|
||||
function parseThreadPayload(body, res) {
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
if (!title) {
|
||||
res.status(400).json({ error: 'title is required' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = body.status === undefined ? 'active' : body.status;
|
||||
if (!STATUSES.includes(status)) {
|
||||
res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` });
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = { title, status };
|
||||
|
||||
for (const field of OPTIONAL_TEXT_FIELDS) {
|
||||
const value = body[field];
|
||||
if (value === undefined || value === null) {
|
||||
payload[field] = null;
|
||||
} else if (typeof value === 'string') {
|
||||
payload[field] = value;
|
||||
} else {
|
||||
res.status(400).json({ error: `${field} must be a string or null` });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function withErrorHandling(handler) {
|
||||
return (req, res) => {
|
||||
try {
|
||||
handler(req, res);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'unexpected server error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', withErrorHandling((req, res) => {
|
||||
const campaignId = loadCampaignId(req, res);
|
||||
if (campaignId === null) return;
|
||||
|
||||
res.json({ data: listThreadsStmt.all(campaignId) });
|
||||
}));
|
||||
|
||||
router.get('/:id', withErrorHandling((req, res) => {
|
||||
const campaignId = loadCampaignId(req, res);
|
||||
if (campaignId === null) return;
|
||||
|
||||
const id = parsePositiveInt(req.params.id, res, 'id');
|
||||
if (id === null) return;
|
||||
|
||||
const thread = getThreadStmt.get(id, campaignId);
|
||||
if (!thread) {
|
||||
return res.status(404).json({ error: 'thread not found' });
|
||||
}
|
||||
res.json({ data: thread });
|
||||
}));
|
||||
|
||||
router.post('/', withErrorHandling((req, res) => {
|
||||
const campaignId = loadCampaignId(req, res);
|
||||
if (campaignId === null) return;
|
||||
|
||||
const payload = parseThreadPayload(req.body, res);
|
||||
if (payload === null) return;
|
||||
|
||||
const result = insertThreadStmt.run({ ...payload, campaign_id: campaignId });
|
||||
res.status(201).json({ data: getThreadStmt.get(result.lastInsertRowid, campaignId) });
|
||||
}));
|
||||
|
||||
router.put('/:id', withErrorHandling((req, res) => {
|
||||
const campaignId = loadCampaignId(req, res);
|
||||
if (campaignId === null) return;
|
||||
|
||||
const id = parsePositiveInt(req.params.id, res, 'id');
|
||||
if (id === null) return;
|
||||
|
||||
if (!getThreadStmt.get(id, campaignId)) {
|
||||
return res.status(404).json({ error: 'thread not found' });
|
||||
}
|
||||
|
||||
const payload = parseThreadPayload(req.body, res);
|
||||
if (payload === null) return;
|
||||
|
||||
updateThreadStmt.run({ ...payload, id, campaign_id: campaignId });
|
||||
res.json({ data: getThreadStmt.get(id, campaignId) });
|
||||
}));
|
||||
|
||||
router.delete('/:id', withErrorHandling((req, res) => {
|
||||
const campaignId = loadCampaignId(req, res);
|
||||
if (campaignId === null) return;
|
||||
|
||||
const id = parsePositiveInt(req.params.id, res, 'id');
|
||||
if (id === null) return;
|
||||
|
||||
if (!getThreadStmt.get(id, campaignId)) {
|
||||
return res.status(404).json({ error: 'thread not found' });
|
||||
}
|
||||
|
||||
deleteThreadStmt.run(id, campaignId);
|
||||
res.json({ data: { id } });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user