const express = require('express'); const { sessionUser } = 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', 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', 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', 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 }); } }); // Shift every unrun plan's date_offset and any four-digit year in its filter // values by a whole number of years. Cloning a definition forward is the // reason plans exist, and doing it by hand across eight segments is where // the mistakes come from. // // Years in filter *values* only, and only a bare four-digit 20xx: a season // reads "2027", a month reads "04 - Sep", and a part number that happens to // look like a year would be corrupted by anything looser. router.post('/versions/:id/plans/shift-years', async (req, res) => { const versionId = parseInt(req.params.id); const years = parseInt(req.body?.years); if (!Number.isInteger(years) || years === 0) { return res.status(400).json({ error: 'years must be a non-zero whole number' }); } const bumpYear = (v) => (typeof v === 'string' && /^20\d{2}$/.test(v.trim())) ? String(parseInt(v, 10) + years) : v; const bumpOffset = (off) => { // '0 days' stays put: an offset of nothing is not a date to shift. const m = /^\s*(-?\d+)\s+year/.exec(off || ''); if (!m) return off; return `${parseInt(m[1], 10) + years} years`; }; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `SELECT * FROM pf.load_plan WHERE version_id = $1 AND run_logid IS NULL ORDER BY seq, id`, [versionId] ); const out = []; for (const p of rows) { const filters = Array.isArray(p.filters) ? p.filters.map(group => group.map(f => ({ ...f, values: Array.isArray(f.values) ? f.values.map(bumpYear) : f.values }))) : p.filters; const upd = await client.query( `UPDATE pf.load_plan SET filters = $2::jsonb, date_offset = $3, updated_at = now() WHERE id = $1 RETURNING *`, [p.id, filters ? JSON.stringify(filters) : null, bumpOffset(p.date_offset)] ); out.push(upd.rows[0]); } await client.query('COMMIT'); res.json({ shifted: out.length, plans: out }); } catch (err) { await client.query('ROLLBACK').catch(() => {}); res.status(500).json({ error: err.message }); } finally { client.release(); } }); return router; };