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>
671 lines
30 KiB
JavaScript
671 lines
30 KiB
JavaScript
const express = require('express');
|
|
const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
|
|
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
|
|
const { buildTerritoryClause } = require('../lib/sql_generator');
|
|
|
|
// Copy a version's definition onto a freshly created one: its load segments as
|
|
// plans, and its saved layouts.
|
|
//
|
|
// The plans come from pf.load_plan when the template has them, and are derived
|
|
// from its baseline/reference log entries when it does not — which is every
|
|
// version created before plans existed, so a definition can be lifted out of
|
|
// one that was built by hand. `params` on a load entry already stores the
|
|
// filters, the raw WHERE when one was used, and the date offset, so the
|
|
// derivation is a read rather than a reconstruction.
|
|
async function cloneDefinition(client, template, version, created_by) {
|
|
const existing = await client.query(
|
|
`SELECT * FROM pf.load_plan WHERE version_id = $1 ORDER BY seq, id`,
|
|
[template.id]
|
|
);
|
|
|
|
let source = existing.rows.map(p => ({
|
|
kind: p.kind, label: p.label, bucket: p.bucket, tag: p.tag, note: p.note,
|
|
filters: p.filters, raw_where: p.raw_where, date_offset: p.date_offset
|
|
}));
|
|
|
|
if (!source.length) {
|
|
const log = await client.query(
|
|
`SELECT operation, label, bucket, tag, note, params
|
|
FROM pf.log
|
|
WHERE TRUE
|
|
AND version_id = $1
|
|
AND operation IN ('baseline', 'reference')
|
|
ORDER BY id`,
|
|
[template.id]
|
|
);
|
|
source = log.rows.map(e => ({
|
|
kind: e.operation,
|
|
label: e.label, bucket: e.bucket, tag: e.tag, note: e.note,
|
|
filters: e.params?.filters || null,
|
|
raw_where: e.params?.raw_where || null,
|
|
date_offset: e.params?.date_offset || '0 days'
|
|
}));
|
|
}
|
|
|
|
for (const [i, p] of source.entries()) {
|
|
await client.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::jsonb, $9, COALESCE($10, '0 days'), $11)`,
|
|
[version.id, i, p.kind, p.label, p.bucket, p.tag, p.note,
|
|
p.filters ? JSON.stringify(p.filters) : null, p.raw_where,
|
|
p.date_offset, created_by]
|
|
);
|
|
}
|
|
|
|
// Version-scoped layouts only. The source-wide ones (version_id IS NULL)
|
|
// already apply to the new version, so copying them would duplicate them
|
|
// under a name that now exists twice.
|
|
const layouts = await client.query(
|
|
`INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default)
|
|
SELECT source_id, $2, name, config, owner, visibility, is_default
|
|
FROM pf.layout
|
|
WHERE TRUE
|
|
AND version_id = $1
|
|
RETURNING id`,
|
|
[template.id, version.id]
|
|
);
|
|
|
|
return { from: template.id, from_name: template.name,
|
|
plans: source.length, layouts: layouts.rowCount };
|
|
}
|
|
|
|
module.exports = function(pool) {
|
|
const router = express.Router();
|
|
|
|
// list versions for a source
|
|
router.get('/sources/:id/versions', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT * FROM pf.version WHERE source_id = $1 ORDER BY created_at DESC`,
|
|
[req.params.id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// create a new version
|
|
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
|
|
router.post('/sources/:id/versions', requireAdmin, async (req, res) => {
|
|
const sourceId = parseInt(req.params.id);
|
|
const { name, description, exclude_iters, clone_from } = req.body;
|
|
const created_by = sessionUser(req);
|
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
// fetch source
|
|
const srcResult = await client.query(
|
|
`SELECT * FROM pf.source WHERE id = $1`, [sourceId]
|
|
);
|
|
if (srcResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Source not found' });
|
|
}
|
|
const source = srcResult.rows[0];
|
|
|
|
// col_meta joined to the source's real columns, for the data types
|
|
const colResult = await client.query(`
|
|
WITH cols AS (${RELATION_COLUMNS_SQL})
|
|
SELECT
|
|
m.cname,
|
|
m.role,
|
|
m.opos,
|
|
i.data_type,
|
|
i.numeric_precision,
|
|
i.numeric_scale
|
|
FROM pf.col_meta m
|
|
JOIN cols i ON i.column_name = m.cname
|
|
WHERE m.source_id = $3
|
|
AND m.role NOT IN ('ignore')
|
|
ORDER BY m.opos
|
|
`, [source.schema, source.tname, sourceId]);
|
|
|
|
if (colResult.rows.length === 0) {
|
|
return res.status(400).json({
|
|
error: 'No usable columns in col_meta — configure roles before creating a version'
|
|
});
|
|
}
|
|
|
|
// clone_from copies a version's *definition* -- its settings, its
|
|
// saved layouts, and its load segments as plans -- and stops there.
|
|
// The loads are deliberately not run: the reason to clone a
|
|
// definition is to change the dates before it loads anything, and a
|
|
// clone that fetched two million rows first would have to be undone
|
|
// before it could be edited.
|
|
//
|
|
// Adjustments are not copied in any form. A scale's target is a
|
|
// figure sized against the rows that were there, so replaying one
|
|
// against a different load is arithmetic nobody asked for.
|
|
let template = null;
|
|
if (clone_from) {
|
|
const t = await client.query(
|
|
`SELECT * FROM pf.version WHERE id = $1 AND source_id = $2`,
|
|
[parseInt(clone_from), sourceId]
|
|
);
|
|
if (!t.rows.length) {
|
|
return res.status(404).json({
|
|
error: 'Version to clone not found, or it belongs to a different source'
|
|
});
|
|
}
|
|
template = t.rows[0];
|
|
}
|
|
|
|
await client.query('BEGIN');
|
|
|
|
// insert version to get id
|
|
const verResult = await client.query(`
|
|
INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters,
|
|
adjustment_segment, adjustment_bucket, unlabeled_load,
|
|
bucket_order)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *
|
|
`, [
|
|
sourceId,
|
|
name,
|
|
// An explicit description wins; otherwise the template's is
|
|
// carried, since it describes the definition being copied.
|
|
description || template?.description || null,
|
|
created_by || null,
|
|
exclude_iters ? JSON.stringify(exclude_iters)
|
|
: (template ? JSON.stringify(template.exclude_iters) : '["reference"]'),
|
|
template?.adjustment_segment || null,
|
|
template?.adjustment_bucket || null,
|
|
template?.unlabeled_load || null,
|
|
template?.bucket_order ? JSON.stringify(template.bucket_order) : null
|
|
]);
|
|
const version = verResult.rows[0];
|
|
|
|
// build CREATE TABLE DDL using col_meta + mapped data types
|
|
const table = fcTable(source.tname, version.id);
|
|
const systemCols = new Set(['pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at']);
|
|
const colDefs = colResult.rows
|
|
.filter(c => !systemCols.has(c.cname))
|
|
.map(c => {
|
|
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
|
|
const quoted = `"${c.cname}"`;
|
|
return ` ${quoted.padEnd(26)}${pgType}`;
|
|
}).join(',\n');
|
|
|
|
const ddl = `
|
|
CREATE TABLE ${table} (
|
|
pf_id bigserial PRIMARY KEY,
|
|
${colDefs},
|
|
pf_iter text NOT NULL,
|
|
pf_logid bigint NOT NULL,
|
|
pf_user text,
|
|
pf_created_at timestamptz NOT NULL DEFAULT now()
|
|
)
|
|
`;
|
|
await client.query(ddl);
|
|
|
|
// pf_logid is how every entry-level operation finds its rows: undo
|
|
// deletes by it, the change log aggregates by it, and it is part of the
|
|
// grain key. Without an index each of those is a sequential scan of the
|
|
// whole forecast table -- 2.5M rows to total two adjustments.
|
|
await client.query(
|
|
`CREATE INDEX ${table.split('.').pop()}_logid_idx ON ${table} (pf_logid)`
|
|
);
|
|
|
|
let cloned = null;
|
|
if (template) cloned = await cloneDefinition(client, template, version, created_by);
|
|
|
|
await client.query('COMMIT');
|
|
res.status(201).json({ ...version, fc_table: table, cloned });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
if (err.code === '23505') {
|
|
return res.status(409).json({ error: 'A version with that name already exists for this source' });
|
|
}
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// where this version's writes actually land: the physical forecast table,
|
|
// its current row count, and the source table rows are read from.
|
|
// Surfaced in the status bar so the write target is never a mystery.
|
|
// Distinct values of a dimension as they appear in one version's forecast
|
|
// table, for completing recode and clone.
|
|
//
|
|
// Deliberately not the source: the source view reaches back over all of
|
|
// history, so completing from it offers parts discontinued years ago. The
|
|
// version holds what was actually loaded, which is what a forecast is being
|
|
// written against.
|
|
//
|
|
// Held in memory because the scan is not cheap -- 2.0s for 11,290 parts across
|
|
// 2.5M rows on fc_osm_skinny_29 -- and completion is typed into. Keyed on the
|
|
// version's latest log id, so any load, adjustment or undo rebuilds it on the
|
|
// next request without anything having to remember to invalidate.
|
|
const valueCache = new Map();
|
|
|
|
router.get('/versions/:id/values/:col', async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
const col = req.params.col;
|
|
try {
|
|
const verResult = await pool.query(`
|
|
SELECT v.id, s.tname, s.id AS source_id
|
|
FROM pf.version v JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [versionId]);
|
|
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
|
|
const { tname, source_id } = verResult.rows[0];
|
|
|
|
// the column name is interpolated, so it has to be one col_meta names
|
|
const okCol = await pool.query(`
|
|
SELECT 1 FROM pf.col_meta
|
|
WHERE source_id = $1 AND cname = $2 AND role IN ('dimension', 'date')
|
|
`, [source_id, col]);
|
|
if (!okCol.rows.length) {
|
|
return res.status(400).json({ error: `"${col}" is not a dimension on this source` });
|
|
}
|
|
|
|
const table = fcTable(tname, versionId);
|
|
const { rows: [{ rev }] } = await pool.query(
|
|
`SELECT coalesce(max(id), 0)::text AS rev FROM pf.log WHERE version_id = $1`,
|
|
[versionId]
|
|
);
|
|
|
|
const key = `${versionId}:${col}`;
|
|
let entry = valueCache.get(key);
|
|
if (!entry || entry.rev !== rev) {
|
|
const { rows } = await pool.query(
|
|
`SELECT DISTINCT "${col}"::text AS val FROM ${table}
|
|
WHERE "${col}" IS NOT NULL ORDER BY 1`
|
|
);
|
|
entry = { rev, values: rows.map(r => r.val) };
|
|
valueCache.set(key, entry);
|
|
}
|
|
|
|
const q = (req.query.q || '').trim().toLowerCase();
|
|
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
|
|
const picked = q
|
|
? entry.values.filter(v => v.toLowerCase().includes(q))
|
|
: entry.values;
|
|
res.json(picked.slice(0, limit));
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(err.status || 500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/versions/:id/table-info', async (req, res) => {
|
|
try {
|
|
const verResult = await pool.query(`
|
|
SELECT v.id, v.name, v.status, s.schema, s.tname, s.id AS source_id
|
|
FROM pf.version v
|
|
JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [req.params.id]);
|
|
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
|
|
|
|
const v = verResult.rows[0];
|
|
const fc = fcTable(v.tname, v.id);
|
|
const [schema, table] = fc.split('.');
|
|
|
|
const existsResult = await pool.query(
|
|
`SELECT to_regclass($1) IS NOT NULL AS exists`, [fc]
|
|
);
|
|
const exists = existsResult.rows[0].exists;
|
|
|
|
// Scoped like every other read. Unscoped it reported the whole
|
|
// table to an account that can see a fraction of it -- the status
|
|
// bar's row count, and the figure the load progress promises, both
|
|
// came from here: a rep with 330k rows was told the load was
|
|
// fetching 2.8M.
|
|
const terrCol = (await pool.query(
|
|
`SELECT cname FROM pf.col_meta WHERE source_id = $1 AND is_territory LIMIT 1`,
|
|
[v.source_id]
|
|
)).rows[0]?.cname || null;
|
|
const terr = buildTerritoryClause(sessionTerritory(req), terrCol);
|
|
|
|
let rows = null, byIter = [];
|
|
if (exists) {
|
|
const countResult = await pool.query(
|
|
`SELECT pf_iter, count(*)::int AS n FROM ${fc}
|
|
${terr ? `WHERE ${terr}` : ''}
|
|
GROUP BY pf_iter ORDER BY pf_iter`
|
|
);
|
|
byIter = countResult.rows;
|
|
rows = byIter.reduce((a, r) => a + r.n, 0);
|
|
}
|
|
|
|
res.json({
|
|
version_id: v.id,
|
|
version_name: v.name,
|
|
status: v.status,
|
|
source: `${v.schema}.${v.tname}`,
|
|
fc_table: fc,
|
|
fc_schema: schema,
|
|
fc_tname: table,
|
|
exists,
|
|
rows,
|
|
by_iter: byIter
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Tags already used on this source, newest first — feeds the tag autocomplete.
|
|
// Scoped to the source rather than the version so an initiative name carries
|
|
// across versions, which is the point of naming it.
|
|
// Tags already used on one version, for completing the next adjustment.
|
|
//
|
|
// Scoped to the version, not the source. A tag names an initiative inside a
|
|
// scenario -- what this plan is doing -- so offering every tag anyone has
|
|
// ever used on the source put last year's work in the way of this year's,
|
|
// and the `uses` count read as activity on a version it did not belong to.
|
|
// The cost is that a fresh version completes from nothing until it has a
|
|
// tag of its own, which is the honest state of it: cloning a definition
|
|
// copies no adjustments, so it has no initiatives yet.
|
|
router.get('/versions/:id/tags', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT l.tag,
|
|
count(*)::int AS uses,
|
|
max(l.stamp) AS last_used,
|
|
array_agg(DISTINCT l.pf_user) AS owners
|
|
FROM pf.log l
|
|
WHERE TRUE
|
|
AND l.version_id = $1
|
|
AND l.tag IS NOT NULL
|
|
AND l.tag <> ''
|
|
GROUP BY l.tag
|
|
ORDER BY max(l.stamp) DESC
|
|
`, [parseInt(req.params.id)]);
|
|
|
|
// Renaming a tag rewrites every entry that carries it, so the right
|
|
// to do it is the right to edit all of them -- not most of them.
|
|
// The tag manager greys the row out rather than offering a rename
|
|
// that would half-apply.
|
|
const me = sessionUser(req);
|
|
const admin = !!req.session?.user?.is_admin;
|
|
res.json(result.rows.map(r => ({
|
|
...r,
|
|
can_edit: admin || (r.owners || []).every(o => o === me)
|
|
})));
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Rename a tag across the whole version, or clear it. `to` empty means
|
|
// clear; `to` naming a tag that already exists merges the two, which is the
|
|
// point -- "Pull Forward" and "Pulled Forward" are one initiative reported
|
|
// as two, and the bridge groups on this column.
|
|
//
|
|
// A PATCH on the version rather than on a tag: a tag is not a row anywhere,
|
|
// only a value repeated across pf.log, so there is no /tags/:tag to address.
|
|
router.patch('/versions/:id/tags', async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
const from = req.body.from === undefined ? null : String(req.body.from).trim();
|
|
const to = req.body.to === undefined ? null : String(req.body.to).trim();
|
|
if (!from) return res.status(400).json({ error: 'Which tag? Send `from`' });
|
|
if (to === from) return res.status(400).json({ error: 'That is the same tag' });
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
// Lock the rows before counting them, so the owner check and the
|
|
// rewrite see the same set.
|
|
const rows = await client.query(
|
|
`SELECT id, pf_user FROM pf.log
|
|
WHERE version_id = $1 AND tag = $2
|
|
FOR UPDATE`,
|
|
[versionId, from]
|
|
);
|
|
if (!rows.rows.length) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(404).json({ error: `No entry on this version is tagged "${from}"` });
|
|
}
|
|
|
|
// Same rule as undo and PATCH /log: your own entries, or an admin's
|
|
// override. Applied to every entry carrying the tag, because a
|
|
// partial rename leaves the initiative split in two.
|
|
const me = sessionUser(req);
|
|
if (!req.session?.user?.is_admin) {
|
|
const others = [...new Set(rows.rows.map(r => r.pf_user).filter(u => u !== me))];
|
|
if (others.length) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(403).json({
|
|
error: `"${from}" is also on entries made by ${others.join(', ')} — only they or an administrator can change those`
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = await client.query(
|
|
`UPDATE pf.log SET tag = $3 WHERE version_id = $1 AND tag = $2`,
|
|
[versionId, from, to || null]
|
|
);
|
|
await client.query('COMMIT');
|
|
res.json({ from, to: to || null, rows_affected: result.rowCount });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// Bridge: how this version got from its baseline to where it stands, grouped by
|
|
// initiative. Amounts come from the version's own forecast table, so the figures
|
|
// reconcile with the pivot rather than being recomputed from the log's params.
|
|
router.get('/versions/:id/bridge', async (req, res) => {
|
|
try {
|
|
const verResult = await pool.query(`
|
|
SELECT v.id, v.exclude_iters, s.schema, s.tname, s.id AS source_id
|
|
FROM pf.version v JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [req.params.id]);
|
|
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
|
|
const v = verResult.rows[0];
|
|
const fc = fcTable(v.tname, v.id);
|
|
|
|
const exists = await pool.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [fc]);
|
|
if (!exists.rows[0].ok) return res.json({ fc_table: fc, exists: false, rows: [] });
|
|
|
|
const colResult = await pool.query(
|
|
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1`, [v.source_id]);
|
|
const valueCol = colResult.rows.find(c => c.role === 'value')?.cname;
|
|
const unitsCol = colResult.rows.find(c => c.role === 'units')?.cname;
|
|
if (!valueCol) return res.status(400).json({ error: 'No value column configured' });
|
|
|
|
const excl = (v.exclude_iters || []).length
|
|
? `t.pf_iter NOT IN (${v.exclude_iters.map(i => `'${String(i).replace(/'/g, "''")}'`).join(', ')})`
|
|
: 'TRUE';
|
|
|
|
const result = await pool.query(`
|
|
SELECT CASE WHEN t.pf_iter = 'baseline' THEN '(baseline)'
|
|
ELSE coalesce(nullif(l.tag, ''), '(untagged)') END AS tag,
|
|
bool_or(t.pf_iter = 'baseline') AS is_baseline,
|
|
count(DISTINCT l.id)::int AS entries,
|
|
count(*)::int AS row_count,
|
|
round(sum(t."${valueCol}")::numeric, 2) AS value
|
|
${unitsCol ? `, round(sum(t."${unitsCol}")::numeric, 2) AS units` : ''}
|
|
FROM ${fc} t
|
|
LEFT JOIN pf.log l ON l.id = t.pf_logid
|
|
WHERE ${excl}
|
|
GROUP BY 1
|
|
ORDER BY bool_or(t.pf_iter = 'baseline') DESC, min(l.id)
|
|
`);
|
|
res.json({ fc_table: fc, exists: true, value_col: valueCol, units_col: unitsCol, rows: result.rows });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// update version name, description, exclude_iters, or the fallback display
|
|
// names.
|
|
//
|
|
// bucket_order is deliberately not settable: the bucket column order is the
|
|
// text in pf.log.bucket now, so a stored order would be a second answer to
|
|
// the same question, and a silent one -- nothing reads it.
|
|
//
|
|
// The three name columns are flag-and-value pairs rather than COALESCE:
|
|
// clearing one back to the built-in means writing null, which COALESCE on
|
|
// the value alone cannot tell from "not mentioned".
|
|
router.put('/versions/:id', requireAdmin, async (req, res) => {
|
|
const { name, description, exclude_iters,
|
|
adjustment_segment, adjustment_bucket, unlabeled_load,
|
|
is_default } = req.body;
|
|
const set = (v) => (v === undefined ? null : (String(v).trim() || null));
|
|
|
|
// is_default decides where every account without a saved selection
|
|
// lands, so it is not the version's own setting the way its name is:
|
|
// it follows the pf.log rule, an admin override and nothing else. The
|
|
// UI greys the box out rather than offering a click that answers 403.
|
|
if (is_default !== undefined && !req.session?.user?.is_admin) {
|
|
return res.status(403).json({
|
|
error: 'Only an admin can set the default forecast'
|
|
});
|
|
}
|
|
|
|
// The write is two statements when the default moves -- clear the old
|
|
// one, set the new -- and the partial unique index refuses the moment
|
|
// in between, so they share a transaction.
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
if (is_default === true) {
|
|
await client.query(`
|
|
UPDATE pf.version SET is_default = false
|
|
WHERE source_id = (SELECT source_id FROM pf.version WHERE id = $1)
|
|
AND id <> $1
|
|
AND is_default
|
|
`, [req.params.id]);
|
|
}
|
|
const result = await client.query(`
|
|
UPDATE pf.version SET
|
|
name = COALESCE($2, name),
|
|
description = COALESCE($3, description),
|
|
exclude_iters = COALESCE($4, exclude_iters),
|
|
adjustment_segment = CASE WHEN $5::bool THEN $6::text ELSE adjustment_segment END,
|
|
adjustment_bucket = CASE WHEN $7::bool THEN $8::text ELSE adjustment_bucket END,
|
|
unlabeled_load = CASE WHEN $9::bool THEN $10::text ELSE unlabeled_load END,
|
|
is_default = CASE WHEN $11::bool THEN $12::bool ELSE is_default END
|
|
WHERE id = $1
|
|
RETURNING *
|
|
`, [
|
|
req.params.id,
|
|
name || null,
|
|
description || null,
|
|
exclude_iters ? JSON.stringify(exclude_iters) : null,
|
|
adjustment_segment !== undefined, set(adjustment_segment),
|
|
adjustment_bucket !== undefined, set(adjustment_bucket),
|
|
unlabeled_load !== undefined, set(unlabeled_load),
|
|
is_default !== undefined, is_default === true
|
|
]);
|
|
if (result.rows.length === 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(404).json({ error: 'Version not found' });
|
|
}
|
|
await client.query('COMMIT');
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
console.error(err);
|
|
// (source_id, name) is unique, so a rename can collide. The POST has
|
|
// said so for as long as it has existed; this said it with a raw
|
|
// Postgres message, which reads as a server fault rather than a name
|
|
// that is already taken.
|
|
if (err.code === '23505') {
|
|
return res.status(409).json({
|
|
// Two unique constraints raise 23505 here, and the name is
|
|
// only one of them -- version_one_default is the other,
|
|
// reachable only if two requests set the default at once.
|
|
error: err.constraint === 'version_one_default'
|
|
? 'Another forecast was just made the default — reload and try again'
|
|
: 'A version with that name already exists for this source'
|
|
});
|
|
}
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// close a version — blocks further edits
|
|
router.post('/versions/:id/close', requireAdmin, async (req, res) => {
|
|
const pf_user = sessionUser(req);
|
|
try {
|
|
const result = await pool.query(`
|
|
UPDATE pf.version
|
|
SET status = 'closed', closed_at = now(), closed_by = $2
|
|
WHERE id = $1 AND status = 'open'
|
|
RETURNING *
|
|
`, [req.params.id, pf_user || null]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found or already closed' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// reopen a closed version
|
|
router.post('/versions/:id/reopen', requireAdmin, async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
UPDATE pf.version
|
|
SET status = 'open', closed_at = NULL, closed_by = NULL
|
|
WHERE id = $1 AND status = 'closed'
|
|
RETURNING *
|
|
`, [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found or already open' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// delete a version — drops forecast table then deletes version record
|
|
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
|
|
router.delete('/versions/:id', requireAdmin, async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
const client = await pool.connect();
|
|
try {
|
|
const verResult = await client.query(`
|
|
SELECT v.*, s.tname
|
|
FROM pf.version v
|
|
JOIN pf.source s ON s.id = v.source_id
|
|
WHERE v.id = $1
|
|
`, [versionId]);
|
|
if (verResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Version not found' });
|
|
}
|
|
const { tname } = verResult.rows[0];
|
|
const table = fcTable(tname, versionId);
|
|
|
|
await client.query('BEGIN');
|
|
await client.query(`DROP TABLE IF EXISTS ${table}`);
|
|
await client.query(`DELETE FROM pf.version WHERE id = $1`, [versionId]);
|
|
await client.query('COMMIT');
|
|
|
|
res.json({ message: 'Version deleted', fc_table: table });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
return router;
|
|
};
|