pf_app/routes/plans.js
Paul Trowbridge 915981b4a7 Put the forecasts in the nav, and lock the admin pages down
Nav. The source and version dropdowns sat in a status bar that is
otherwise read-only, so they did not read as the controls they were.
The forecasts are now listed in the sidebar above the three steps, each
row carrying its status and a star for the default. Not nested under
the Forecast button, which is where this started: Baseline works on the
same version, so hanging the list off one step would either
misrepresent the scope or duplicate it under two.

The source is gone from the nav entirely -- gs.osm_skinny means nothing
to someone editing a forecast. The sidebar lists every source's
versions and sets the source from the one picked, since a version
carries its source_id; `versions` stays scoped to the selected source,
because Baseline and Forecast are, so the sidebar gets its own list
rather than widening what versions means everywhere. The picker only
appears on Setup now, which is the one place the raw table name is the
right label.

The status bar keeps what follows FROM the selection -- the forecast's
name and its row count. The table it writes to moved into the hover
panel, still click-to-copy, since pasting it into psql is why it was
ever on screen.

Permissions. requireAdmin existed in lib/auth.js and was applied to
nothing, so all 26 non-admin accounts could call every Setup and
Baseline endpoint directly. It is now on source register/deregister,
col_meta writes, Generate SQL, dim refresh, version create/delete/
close/reopen, PUT /versions/:id, all of plans.js, and the baseline and
reference loads. Deliberately not on any read, nor on the forecast
operations, which carry their own owner and territory rules.

Both pages stay visible and go read-only, via one <fieldset disabled>
each rather than a flag threaded through forty-five controls, with a
banner saying why. The Tags panel on Baseline is excluded: its
permission is ownership, not admin, and a rep may rename the
initiatives they named themselves.

The table browser reads are open to everyone, because gating what a
page needs in order to render turned Setup into a 403 -- which it then
rendered as a crash, since the error body was passed to .map during
render and unmounting React blanks the app. Setup's list fetches now
coerce to an array, so an unexpected response costs an empty panel and
not the session. That fragility predated the gating.

Toolbar. The zone labels were the same visual weight as the buttons
they name, and the rules between groups were the same weight as the
zone borders, so nothing separated at the level it belonged to. Labels
are heavier and darker, group rules are stronger and taller, and the
forecast zone has a container of its own instead of floating.

Fit widths is removed, button and function. It measured the leaf header
over viewport rows only, so anything below the fold never counted; it
passed an option draw() does not have and raced draw()'s throttle,
leaving columns cleared but unmeasured at their floor widths; and the
persist that followed saved nothing, because saveColumnSizes returns
only overrides, which resetAutoSize had just emptied. Recoverable from
git if it is ever worth fixing rather than dropping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:57:24 -04:00

126 lines
5.3 KiB
JavaScript

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;
};