Phase 5 — Thread tracker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudecode
2026-07-01 14:41:45 -04:00
parent 2e8de105b2
commit 06abde1471
7 changed files with 867 additions and 9 deletions
+175 -1
View File
@@ -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;