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:
@@ -73,7 +73,8 @@ mythic-oracle/
|
|||||||
│ ├── threads.js
|
│ ├── threads.js
|
||||||
│ ├── npcs.js
|
│ ├── npcs.js
|
||||||
│ ├── notes.js
|
│ ├── notes.js
|
||||||
│ └── tables.js
|
│ ├── tables.js
|
||||||
|
│ └── systems.js
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── index.html # Shell only — sidebar + view containers
|
│ ├── index.html # Shell only — sidebar + view containers
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
@@ -89,7 +90,8 @@ mythic-oracle/
|
|||||||
│ ├── npcs.js
|
│ ├── npcs.js
|
||||||
│ ├── notes.js
|
│ ├── notes.js
|
||||||
│ ├── character.js
|
│ ├── character.js
|
||||||
│ └── tables.js
|
│ ├── tables.js
|
||||||
|
│ └── campaigns.js # Campaign management view UI
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── tables/ # Static JSON tables — version controlled
|
│ ├── tables/ # Static JSON tables — version controlled
|
||||||
│ └── mythic-oracle.db # SQLite database — gitignored
|
│ └── mythic-oracle.db # SQLite database — gitignored
|
||||||
|
|||||||
@@ -156,6 +156,22 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-active-campaign {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-active-campaign {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-active-campaign" id="activeCampaignName">No active campaign</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<div class="nav-section-label">Oracle</div>
|
<div class="nav-section-label">Oracle</div>
|
||||||
@@ -147,4 +149,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<script src="js/app.js" type="module"></script>
|
<script src="js/app.js" type="module"></script>
|
||||||
|
<script src="js/campaigns.js" type="module"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1 +1,51 @@
|
|||||||
// Mythic Oracle — shared fetch wrapper, all API calls
|
// Mythic Oracle — shared fetch wrapper, all API calls
|
||||||
|
|
||||||
|
const BASE_URL = '/api';
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Request failed: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) message = body.error;
|
||||||
|
} catch {
|
||||||
|
// no JSON body on the error response
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampaigns() {
|
||||||
|
return request('/campaigns');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCampaign(data) {
|
||||||
|
return request('/campaigns', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampaign(id) {
|
||||||
|
return request(`/campaigns/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCampaign(id, data) {
|
||||||
|
return request(`/campaigns/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCampaign(id) {
|
||||||
|
return request(`/campaigns/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystems() {
|
||||||
|
return request('/systems');
|
||||||
|
}
|
||||||
|
|||||||
+73
-11
@@ -1,14 +1,20 @@
|
|||||||
// Mythic Oracle — entry, sidebar routing, campaign context
|
// Mythic Oracle — entry, sidebar routing, campaign context
|
||||||
|
|
||||||
|
import { getCampaign, updateCampaign } from './api.js';
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
sidebarCollapsed: 'mo-sidebar-collapsed',
|
sidebarCollapsed: 'mo-sidebar-collapsed',
|
||||||
activeView: 'mo-active-view',
|
activeView: 'mo-active-view',
|
||||||
theme: 'mo-theme',
|
theme: 'mo-theme',
|
||||||
|
activeCampaignId: 'mo-active-campaign-id',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHAOS_MIN = 1;
|
const CHAOS_MIN = 1;
|
||||||
const CHAOS_MAX = 9;
|
const CHAOS_MAX = 9;
|
||||||
|
|
||||||
|
let activeCampaign = null;
|
||||||
|
let navigateToView = null;
|
||||||
|
|
||||||
function initSidebarCollapse() {
|
function initSidebarCollapse() {
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
const toggle = document.getElementById('collapseToggle');
|
const toggle = document.getElementById('collapseToggle');
|
||||||
@@ -33,16 +39,15 @@ function initNavRouting() {
|
|||||||
views.forEach((view) => {
|
views.forEach((view) => {
|
||||||
view.classList.toggle('active', view.id === `view-${viewId}`);
|
view.classList.toggle('active', view.id === `view-${viewId}`);
|
||||||
});
|
});
|
||||||
|
localStorage.setItem(STORAGE_KEYS.activeView, viewId);
|
||||||
}
|
}
|
||||||
|
|
||||||
navItems.forEach((item) => {
|
navItems.forEach((item) => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => activateView(item.dataset.view));
|
||||||
const viewId = item.dataset.view;
|
|
||||||
activateView(viewId);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.activeView, viewId);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
navigateToView = activateView;
|
||||||
|
|
||||||
const savedView = localStorage.getItem(STORAGE_KEYS.activeView);
|
const savedView = localStorage.getItem(STORAGE_KEYS.activeView);
|
||||||
const validView = savedView && document.getElementById(`view-${savedView}`);
|
const validView = savedView && document.getElementById(`view-${savedView}`);
|
||||||
activateView(validView ? savedView : 'oracle');
|
activateView(validView ? savedView : 'oracle');
|
||||||
@@ -62,24 +67,81 @@ function initThemeToggle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initChaosFactorControls() {
|
function renderChaosFactor() {
|
||||||
const value = document.getElementById('chaosValue');
|
const value = document.getElementById('chaosValue');
|
||||||
|
value.textContent = activeCampaign ? activeCampaign.chaos_factor : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveCampaignName() {
|
||||||
|
const el = document.getElementById('activeCampaignName');
|
||||||
|
el.textContent = activeCampaign ? activeCampaign.name : 'No active campaign';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChaosFactorControls() {
|
||||||
const minus = document.getElementById('chaosMinus');
|
const minus = document.getElementById('chaosMinus');
|
||||||
const plus = document.getElementById('chaosPlus');
|
const plus = document.getElementById('chaosPlus');
|
||||||
|
|
||||||
function setValue(next) {
|
async function adjust(delta) {
|
||||||
value.textContent = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, next));
|
if (!activeCampaign) return;
|
||||||
|
|
||||||
|
const next = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, activeCampaign.chaos_factor + delta));
|
||||||
|
if (next === activeCampaign.chaos_factor) return;
|
||||||
|
|
||||||
|
const updated = await updateCampaign(activeCampaign.id, { chaos_factor: next });
|
||||||
|
activeCampaign = updated;
|
||||||
|
renderChaosFactor();
|
||||||
}
|
}
|
||||||
|
|
||||||
minus.addEventListener('click', () => setValue(Number(value.textContent) - 1));
|
minus.addEventListener('click', () => adjust(-1));
|
||||||
plus.addEventListener('click', () => setValue(Number(value.textContent) + 1));
|
plus.addEventListener('click', () => adjust(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
export async function setActiveCampaign(campaign) {
|
||||||
|
if (!campaign) {
|
||||||
|
activeCampaign = null;
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.activeCampaignId);
|
||||||
|
renderChaosFactor();
|
||||||
|
renderActiveCampaignName();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always re-fetch: the passed-in object (e.g. from a cached list) may be
|
||||||
|
// stale if the chaos factor was changed elsewhere since it was loaded.
|
||||||
|
const fresh = await getCampaign(campaign.id);
|
||||||
|
activeCampaign = fresh;
|
||||||
|
localStorage.setItem(STORAGE_KEYS.activeCampaignId, fresh.id);
|
||||||
|
renderChaosFactor();
|
||||||
|
renderActiveCampaignName();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveCampaign() {
|
||||||
|
return activeCampaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initActiveCampaign() {
|
||||||
|
const savedId = localStorage.getItem(STORAGE_KEYS.activeCampaignId);
|
||||||
|
|
||||||
|
if (!savedId) {
|
||||||
|
renderChaosFactor();
|
||||||
|
renderActiveCampaignName();
|
||||||
|
navigateToView('campaign-management');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setActiveCampaign({ id: savedId });
|
||||||
|
} catch {
|
||||||
|
await setActiveCampaign(null);
|
||||||
|
navigateToView('campaign-management');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
initSidebarCollapse();
|
initSidebarCollapse();
|
||||||
initNavRouting();
|
initNavRouting();
|
||||||
initThemeToggle();
|
initThemeToggle();
|
||||||
initChaosFactorControls();
|
initChaosFactorControls();
|
||||||
|
await initActiveCampaign();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Mythic Oracle — campaign management view
|
||||||
|
|
||||||
|
import { getCampaigns, createCampaign, deleteCampaign, getSystems } from './api.js';
|
||||||
|
import { setActiveCampaign, getActiveCampaign } from './app.js';
|
||||||
|
|
||||||
|
const container = document.getElementById('view-campaign-management');
|
||||||
|
|
||||||
|
let campaignsCache = [];
|
||||||
|
let systemsCache = [];
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = value;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function campaignItemHtml(campaign) {
|
||||||
|
const active = getActiveCampaign();
|
||||||
|
const isActive = Boolean(active) && active.id === campaign.id;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li class="campaign-item${isActive ? ' active' : ''}" data-id="${campaign.id}">
|
||||||
|
<button type="button" class="campaign-select" data-id="${campaign.id}">
|
||||||
|
<span class="campaign-name">${escapeHtml(campaign.name)}</span>
|
||||||
|
<span class="campaign-system">${escapeHtml(campaign.system_name)}</span>
|
||||||
|
${isActive ? '<span class="campaign-active-badge">Active</span>' : ''}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="campaign-delete" data-id="${campaign.id}" aria-label="Delete ${escapeHtml(campaign.name)}">×</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const systemOptions = systemsCache
|
||||||
|
.map((system) => `<option value="${system.id}">${escapeHtml(system.name)}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const campaignItems = campaignsCache.length
|
||||||
|
? campaignsCache.map(campaignItemHtml).join('')
|
||||||
|
: '<li class="campaign-empty">No campaigns yet — create one below to get started.</li>';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<h2>Campaign Management</h2>
|
||||||
|
|
||||||
|
<ul class="campaign-list">
|
||||||
|
${campaignItems}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form id="createCampaignForm" class="campaign-form">
|
||||||
|
<input type="text" id="campaignNameInput" placeholder="Campaign name" autocomplete="off" required>
|
||||||
|
<select id="campaignSystemSelect" required>
|
||||||
|
${systemOptions}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Create Campaign</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAndRender() {
|
||||||
|
[campaignsCache, systemsCache] = await Promise.all([getCampaigns(), getSystems()]);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
container.addEventListener('submit', async (event) => {
|
||||||
|
if (event.target.id !== 'createCampaignForm') return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const nameInput = document.getElementById('campaignNameInput');
|
||||||
|
const systemSelect = document.getElementById('campaignSystemSelect');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
const systemId = Number(systemSelect.value);
|
||||||
|
|
||||||
|
if (!name || !systemId) return;
|
||||||
|
|
||||||
|
const campaign = await createCampaign({ name, system_id: systemId });
|
||||||
|
await setActiveCampaign(campaign);
|
||||||
|
await loadAndRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('click', async (event) => {
|
||||||
|
const selectBtn = event.target.closest('.campaign-select');
|
||||||
|
if (selectBtn) {
|
||||||
|
const id = Number(selectBtn.dataset.id);
|
||||||
|
const campaign = campaignsCache.find((c) => c.id === id);
|
||||||
|
if (campaign) {
|
||||||
|
await setActiveCampaign(campaign);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBtn = event.target.closest('.campaign-delete');
|
||||||
|
if (deleteBtn) {
|
||||||
|
const id = Number(deleteBtn.dataset.id);
|
||||||
|
const campaign = campaignsCache.find((c) => c.id === id);
|
||||||
|
if (!campaign) return;
|
||||||
|
|
||||||
|
const confirmed = confirm(
|
||||||
|
`Delete "${campaign.name}"? This permanently removes all of its threads, NPCs, notes, and character data.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await deleteCampaign(id);
|
||||||
|
if (getActiveCampaign()?.id === id) {
|
||||||
|
await setActiveCampaign(null);
|
||||||
|
}
|
||||||
|
await loadAndRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
bindEvents();
|
||||||
|
loadAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
@@ -8,6 +8,7 @@ const threadsRouter = require('./routes/threads');
|
|||||||
const npcsRouter = require('./routes/npcs');
|
const npcsRouter = require('./routes/npcs');
|
||||||
const notesRouter = require('./routes/notes');
|
const notesRouter = require('./routes/notes');
|
||||||
const tablesRouter = require('./routes/tables');
|
const tablesRouter = require('./routes/tables');
|
||||||
|
const systemsRouter = require('./routes/systems');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 4000;
|
const PORT = 4000;
|
||||||
@@ -25,6 +26,7 @@ app.use('/api/threads', threadsRouter);
|
|||||||
app.use('/api/npcs', npcsRouter);
|
app.use('/api/npcs', npcsRouter);
|
||||||
app.use('/api/notes', notesRouter);
|
app.use('/api/notes', notesRouter);
|
||||||
app.use('/api/tables', tablesRouter);
|
app.use('/api/tables', tablesRouter);
|
||||||
|
app.use('/api/systems', systemsRouter);
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Mythic Oracle running at http://localhost:${PORT}`);
|
console.log(`Mythic Oracle running at http://localhost:${PORT}`);
|
||||||
|
|||||||
@@ -1,4 +1,164 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
const router = express.Router();
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const listSystems = db.prepare('SELECT * FROM systems ORDER BY name');
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.json(listSystems.all());
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user