pf_app/routes/plans.js
Paul Trowbridge fe456d4e90 Drop Shift years
Nobody asked for it. It was my own addition to the plans panel, guessing
that cloning a definition forward a year would be the common case; the
actual edit wanted was two dates moved by five days, typed by hand. A bulk
rewrite of filter values is a lot of machinery to leave sitting next to Run
all on the chance it is ever right.

Never used, so nothing to unwind: every plan on the cloned version still
carries the template's filters verbatim apart from the two dates edited in
the form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:39:07 -04:00

126 lines
5.3 KiB
JavaScript

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 });
}
});
return router;
};