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>
126 lines
5.3 KiB
JavaScript
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;
|
|
};
|