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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user