const express = require('express'); const db = require('../db'); const router = express.Router({ mergeParams: true }); const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing']; const OPTIONAL_TEXT_FIELDS = [ 'description', 'notes', 'motivations', 'appearance', 'age', 'gender', 'pronouns', 'voice', 'distinguishing_features', 'faction', 'occupation', 'social_status', 'relationship_to_pc', 'loyalty', 'personality_traits', 'fears', 'desires', 'secrets', 'first_encountered', 'last_seen', 'current_location', 'current_goal', 'role_in_threads', 'disposition', ]; const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?'); const listNpcsStmt = db.prepare('SELECT * FROM npcs WHERE campaign_id = ? ORDER BY created_at DESC'); const getNpcStmt = db.prepare('SELECT * FROM npcs WHERE id = ? AND campaign_id = ?'); const insertNpcStmt = db.prepare(` INSERT INTO npcs ( campaign_id, name, description, notes, motivations, appearance, age, gender, pronouns, voice, distinguishing_features, faction, occupation, social_status, relationship_to_pc, loyalty, personality_traits, fears, desires, secrets, first_encountered, last_seen, current_location, current_goal, role_in_threads, alive_status, disposition ) VALUES ( @campaign_id, @name, @description, @notes, @motivations, @appearance, @age, @gender, @pronouns, @voice, @distinguishing_features, @faction, @occupation, @social_status, @relationship_to_pc, @loyalty, @personality_traits, @fears, @desires, @secrets, @first_encountered, @last_seen, @current_location, @current_goal, @role_in_threads, @alive_status, @disposition ) `); const updateNpcStmt = db.prepare(` UPDATE npcs SET name = @name, description = @description, notes = @notes, motivations = @motivations, appearance = @appearance, age = @age, gender = @gender, pronouns = @pronouns, voice = @voice, distinguishing_features = @distinguishing_features, faction = @faction, occupation = @occupation, social_status = @social_status, relationship_to_pc = @relationship_to_pc, loyalty = @loyalty, personality_traits = @personality_traits, fears = @fears, desires = @desires, secrets = @secrets, first_encountered = @first_encountered, last_seen = @last_seen, current_location = @current_location, current_goal = @current_goal, role_in_threads = @role_in_threads, alive_status = @alive_status, disposition = @disposition WHERE id = @id AND campaign_id = @campaign_id `); const deleteNpcStmt = db.prepare('DELETE FROM npcs 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 // name/alive_status the same way rather than supporting partial patches. function parseNpcPayload(body, res) { const name = typeof body.name === 'string' ? body.name.trim() : ''; if (!name) { res.status(400).json({ error: 'name is required' }); return null; } const aliveStatus = body.alive_status === undefined ? 'alive' : body.alive_status; if (!ALIVE_STATUSES.includes(aliveStatus)) { res.status(400).json({ error: `alive_status must be one of: ${ALIVE_STATUSES.join(', ')}` }); return null; } const payload = { name, alive_status: aliveStatus }; for (const field of OPTIONAL_TEXT_FIELDS) { if (field === 'disposition') continue; 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; } } const disposition = body.disposition; if (disposition === undefined) { payload.disposition = 'unknown'; } else if (disposition === null || typeof disposition === 'string') { payload.disposition = disposition; } else { res.status(400).json({ error: 'disposition 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: listNpcsStmt.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 npc = getNpcStmt.get(id, campaignId); if (!npc) { return res.status(404).json({ error: 'npc not found' }); } res.json({ data: npc }); })); router.post('/', withErrorHandling((req, res) => { const campaignId = loadCampaignId(req, res); if (campaignId === null) return; const payload = parseNpcPayload(req.body, res); if (payload === null) return; const result = insertNpcStmt.run({ ...payload, campaign_id: campaignId }); res.status(201).json({ data: getNpcStmt.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 (!getNpcStmt.get(id, campaignId)) { return res.status(404).json({ error: 'npc not found' }); } const payload = parseNpcPayload(req.body, res); if (payload === null) return; updateNpcStmt.run({ ...payload, id, campaign_id: campaignId }); res.json({ data: getNpcStmt.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 (!getNpcStmt.get(id, campaignId)) { return res.status(404).json({ error: 'npc not found' }); } deleteNpcStmt.run(id, campaignId); res.json({ data: { id } }); })); module.exports = router;