feat: Phase 3 campaign management

Campaign CRUD API with cascading delete across all campaign-scoped
tables, a systems endpoint to seed the create-campaign form, active
campaign state wired to the sidebar chaos factor, and a campaign
management view for creating/switching/deleting campaigns.
This commit is contained in:
claudecode
2026-06-30 23:22:34 -04:00
parent 2faa168847
commit 3a7340975f
9 changed files with 438 additions and 13 deletions
+160
View File
@@ -1,4 +1,164 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
const CHAOS_MIN = 1;
const CHAOS_MAX = 9;
const listCampaigns = db.prepare(`
SELECT campaigns.*, systems.name AS system_name, systems.slug AS system_slug
FROM campaigns
JOIN systems ON systems.id = campaigns.system_id
ORDER BY campaigns.created_at DESC
`);
const getCampaignById = db.prepare(`
SELECT campaigns.*, systems.name AS system_name, systems.slug AS system_slug
FROM campaigns
JOIN systems ON systems.id = campaigns.system_id
WHERE campaigns.id = ?
`);
const getSystemById = db.prepare('SELECT id FROM systems WHERE id = ?');
const insertCampaign = db.prepare(`
INSERT INTO campaigns (name, system_id)
VALUES (@name, @system_id)
`);
const updateCampaignStmt = db.prepare(`
UPDATE campaigns
SET name = @name, chaos_factor = @chaos_factor, updated_at = datetime('now')
WHERE id = @id
`);
const deleteCampaignCascade = db.transaction((id) => {
const cairnCharacterIds = db
.prepare('SELECT id FROM characters_cairn WHERE campaign_id = ?')
.all(id)
.map((row) => row.id);
for (const characterId of cairnCharacterIds) {
db.prepare('DELETE FROM cairn_inventory WHERE character_id = ?').run(characterId);
}
const ironswornCharacterIds = db
.prepare('SELECT id FROM characters_ironsworn WHERE campaign_id = ?')
.all(id)
.map((row) => row.id);
for (const characterId of ironswornCharacterIds) {
db.prepare('DELETE FROM ironsworn_assets WHERE character_id = ?').run(characterId);
}
const shadowrunCharacterIds = db
.prepare('SELECT id FROM characters_shadowrun WHERE campaign_id = ?')
.all(id)
.map((row) => row.id);
for (const characterId of shadowrunCharacterIds) {
db.prepare('DELETE FROM shadowrun_skills WHERE character_id = ?').run(characterId);
db.prepare('DELETE FROM shadowrun_contacts WHERE character_id = ?').run(characterId);
db.prepare('DELETE FROM shadowrun_qualities WHERE character_id = ?').run(characterId);
}
db.prepare('DELETE FROM characters_dnd5e WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_morkborg WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_cairn WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_chaalt WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_ironsworn WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_shadowrun WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM ironsworn_vows WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM threads WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM npcs WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM session_logs WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM campaign_docs WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM custom_tables WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM campaigns WHERE id = ?').run(id);
});
function parseId(rawId, res) {
const id = Number(rawId);
if (!Number.isInteger(id)) {
res.status(400).json({ error: 'id must be an integer' });
return null;
}
return id;
}
router.get('/', (req, res) => {
res.json(listCampaigns.all());
});
router.post('/', (req, res) => {
const { name, system_id: systemId } = req.body;
if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name is required' });
}
if (!Number.isInteger(systemId)) {
return res.status(400).json({ error: 'system_id is required' });
}
if (!getSystemById.get(systemId)) {
return res.status(400).json({ error: 'system_id does not exist' });
}
const result = insertCampaign.run({ name: name.trim(), system_id: systemId });
res.status(201).json(getCampaignById.get(result.lastInsertRowid));
});
router.get('/:id', (req, res) => {
const id = parseId(req.params.id, res);
if (id === null) return;
const campaign = getCampaignById.get(id);
if (!campaign) {
return res.status(404).json({ error: 'campaign not found' });
}
res.json(campaign);
});
router.patch('/:id', (req, res) => {
const id = parseId(req.params.id, res);
if (id === null) return;
const existing = getCampaignById.get(id);
if (!existing) {
return res.status(404).json({ error: 'campaign not found' });
}
let name = existing.name;
if (req.body.name !== undefined) {
name = String(req.body.name).trim();
if (!name) {
return res.status(400).json({ error: 'name cannot be empty' });
}
}
let chaosFactor = existing.chaos_factor;
if (req.body.chaos_factor !== undefined) {
const parsed = Number(req.body.chaos_factor);
if (!Number.isInteger(parsed)) {
return res.status(400).json({ error: 'chaos_factor must be an integer' });
}
chaosFactor = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, parsed));
}
updateCampaignStmt.run({ id, name, chaos_factor: chaosFactor });
res.json(getCampaignById.get(id));
});
router.delete('/:id', (req, res) => {
const id = parseId(req.params.id, res);
if (id === null) return;
const existing = getCampaignById.get(id);
if (!existing) {
return res.status(404).json({ error: 'campaign not found' });
}
deleteCampaignCascade(id);
res.status(204).end();
});
module.exports = router;