85adbbf084
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
6.6 KiB
JavaScript
228 lines
6.6 KiB
JavaScript
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;
|