A tag was only ever a value typed on a pf.log entry, so the only way to change one was to find every entry carrying it in the change log and edit each. Nothing showed what the list was, and the bridge groups on this column -- so "Pull Forward" and "Pulled Forward" read as two initiatives with no way to make them one. PATCH /versions/:id/tags rewrites every entry carrying `from`. A `to` that already exists merges the two, an empty one clears the tag; the panel confirms both, since neither is visible as a diff afterwards. Permission is the pf.log rule applied to every entry the tag is on rather than most of them: a half-applied rename leaves the initiative split, which is what the rename was for. GET /versions/:id/tags now returns owners and can_edit so the panel can grey the row out instead of offering a click that answers 403. On the Baseline page, next to the other version-level settings -- the Forecast page edits one entry's tag, this edits the column. Co-Authored-By: Claude Opus 5 (1M context) <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 } = 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', 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', 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', 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', 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', 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;
|
|
};
|