const express = require('express'); const { sessionUser, requireAdmin } = require('../lib/auth'); // Planned load segments — a version's definition, before it has been run. // // A plan carries exactly what POST /versions/:id/{baseline|reference} takes, so // running one is that same call with the plan's fields; the client then stamps // run_logid. It is two round trips rather than one because the alternative is a // second copy of the load route's token substitution, and a plan that reads // unrun while its rows exist is visible and fixable, where a divergent copy of // the load SQL is neither. module.exports = function(pool) { const router = express.Router(); const FIELDS = ['seq', 'kind', 'label', 'bucket', 'tag', 'note', 'filters', 'raw_where', 'date_offset']; // A plan whose log entry has been undone is a plan again: the FK is // ON DELETE SET NULL, so run_logid clears itself. The join is for the // row count, which is what makes "already run" worth seeing. const SELECT_PLANS = ` SELECT p.* ,l.row_count AS run_rows ,l.stamp AS run_at FROM pf.load_plan p LEFT JOIN pf.log l ON l.id = p.run_logid WHERE TRUE AND p.version_id = $1 ORDER BY p.seq ,p.id`; router.get('/versions/:id/plans', async (req, res) => { try { const { rows } = await pool.query(SELECT_PLANS, [parseInt(req.params.id)]); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); router.post('/versions/:id/plans', requireAdmin, async (req, res) => { const versionId = parseInt(req.params.id); const b = req.body || {}; if (!['baseline', 'reference'].includes(b.kind)) { return res.status(400).json({ error: 'kind must be baseline or reference' }); } try { // Append by default: a new plan runs after the ones already there. const next = await pool.query( `SELECT COALESCE(max(seq), -1) + 1 AS seq FROM pf.load_plan WHERE version_id = $1`, [versionId] ); const { rows } = await pool.query( `INSERT INTO pf.load_plan (version_id, seq, kind, label, bucket, tag, note, filters, raw_where, date_offset, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, '0 days'), $11) RETURNING *`, [versionId, b.seq ?? next.rows[0].seq, b.kind, b.label || null, b.bucket || null, b.tag || null, b.note || null, b.filters ? JSON.stringify(b.filters) : null, b.raw_where || null, b.date_offset || null, sessionUser(req)] ); res.status(201).json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); // Any subset of the fields, plus run_logid when a run has just happened. // The flag-and-value pair rather than COALESCE on the value: clearing a // label to blank has to be distinguishable from not mentioning it. router.patch('/plans/:pid', requireAdmin, async (req, res) => { const pid = parseInt(req.params.pid); const b = req.body || {}; const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined); if (!keys.length) return res.status(400).json({ error: 'Nothing to update' }); if (b.kind !== undefined && !['baseline', 'reference'].includes(b.kind)) { return res.status(400).json({ error: 'kind must be baseline or reference' }); } try { const sets = []; const params = [pid]; for (const k of keys) { let v = k === 'filters' ? (b[k] === null ? null : JSON.stringify(b[k])) : (typeof b[k] === 'string' ? (b[k].trim() || null) : b[k]); // date_offset is NOT NULL, so a blank one means the default. // Coerced here rather than in the statement: a second assignment // to the same column is an error, not a fallback. if (k === 'date_offset' && !v) v = '0 days'; params.push(v); sets.push(`${k} = $${params.length}${k === 'filters' ? '::jsonb' : ''}`); } const { rows } = await pool.query( `UPDATE pf.load_plan SET ${sets.join(', ')}, updated_at = now() WHERE id = $1 RETURNING *`, params ); if (!rows.length) return res.status(404).json({ error: 'Plan not found' }); res.json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); router.delete('/plans/:pid', requireAdmin, async (req, res) => { try { const { rows } = await pool.query( `DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)] ); if (!rows.length) return res.status(404).json({ error: 'Plan not found' }); res.json({ deleted: rows[0].id }); } catch (err) { res.status(500).json({ error: err.message }); } }); return router; };