diff --git a/CLAUDE.md b/CLAUDE.md index 535f36c..02509e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ routes/ versions.js Version CRUD, baseline/reference load, data stream operations.js scale, recode, clone, undo — the core forecast ops log.js GET /api/versions/:id/log, DELETE /api/log/:logid + layouts.js Named pivot layouts — list per version, create, patch, delete lib/ sql_generator.js buildFilterClause, token substitution helpers auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI @@ -45,6 +46,7 @@ ui/src/ Baseline.jsx Version management, baseline workbench, reference load Forecast.jsx Perspective pivot, selection handling, operation dispatch components/ + LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps) Sidebar.jsx 3-step collapsible nav @@ -61,6 +63,7 @@ ui/src/ - **`pf.version`** — named forecast scenarios; `exclude_iters` (default `["reference"]`) blocks those iter values from all operations - **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`). Indexed on `pf_logid`, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it. - **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb +- **`pf.layout`** — named Perspective view configs; see §Pivot layouts - **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time - **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at` - **`pf.session`** — express-session store (connect-pg-simple layout) @@ -313,6 +316,58 @@ together. `columns` selects which *measures* appear, not individual split combinations. +## Pivot layouts + +A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an +account. Two kinds, one table: + +- **published** — everyone on the forecast lists it and can apply it; only its + owner or an admin may change it +- **private** — yours, nobody else lists it + +Scope is the **version** by default, because that is where people enter the app. +`version_id IS NULL` means the layout applies to every version of the source; +that is where the old source default went, and what a brand-new version picks up +before anyone has published anything for it. `is_default` (at most one per scope, +by partial unique index) is what `initViewer()` restores on a first load — a +version-scoped default beating a source-wide one, the narrower answer winning. + +**Permissions are the `pf.log` rule verbatim** — your own, or an admin's +override, and the UI greys out the rest rather than offering a click that answers +403. `can_edit` rides on every row so the menu knows which. Applying is never +restricted: the guarantee is that a published layout cannot be *changed* out from +under people, not that it cannot be adapted — Save is withheld on a layout that +isn't yours, Save as… forks it into your own. + +**No territory clause.** A layout is display config, and territory restricts rows, +not columns; `cleanLayout()` already drops anything the live schema lacks. + +**What is still local.** `LAYOUT_KEY` (`pf_layout_v{vid}`) — the unnamed +last-used config — stays in `localStorage`, because it is per-browser session +continuity rather than a thing anyone names or shares. `LAYOUTS_KEY` +(`pf_layouts_v{vid}`) is the old named list; `loadLayouts()` lifts it into +`pf.layout` as private rows once per version and then clears the key. + +**The dirty dot is a comparison, not a flag.** `restore()` itself fires +`perspective-config-update`, so anything set unconditionally in that handler +would light up the moment a layout was applied. `activeConfigRef` holds what the +pivot last matched and `sameConfig()` compares against it, ignoring `table` +(the per-load table name, different on every refresh). + +**What this replaced.** Named layouts lived only in `localStorage` — invisible to +anyone else, gone on another machine. The one server-side layout was +`pf.source.default_layout`, a single anonymous blob that `PUT +/sources/:id/default-layout` let *any* account overwrite for *every* account: a +published layout with no owner. That route is gone; the column remains, migrated +and read by nothing. + +**Known gap:** `cleanLayout()` still does not guard `aggregates` +(PERSPECTIVE.md §5). Harmless while `save()` emits `aggregates: {}`, but a +published layout that adopts the weighted-mean pattern and then loses a column +would break the restore for everyone rather than for one browser. + +--- + ## Slice mechanics When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side). diff --git a/routes/layouts.js b/routes/layouts.js new file mode 100644 index 0000000..289cc36 --- /dev/null +++ b/routes/layouts.js @@ -0,0 +1,193 @@ +const express = require('express'); +const { sessionUser } = require('../lib/auth'); + +// Named Perspective view configs. Two kinds, one table: +// +// private — yours, nobody else lists it +// published — everyone on the version sees it; only the owner or an admin +// may change it, the same rule pf.log uses for its entries +// +// Scope is the version by default, because that is where people enter the app. +// A layout with version_id NULL applies to every version of the source, which +// is where the old pf.source.default_layout went and what a brand-new version +// picks up before anyone has published anything for it. +module.exports = function(pool) { + const router = express.Router(); + + const SELECT_COLS = `id, source_id, version_id, name, config, owner, + visibility, is_default, created_at, updated_at`; + + // What the client needs to know about a row it cannot write, so the UI can + // grey the control out rather than offer a click that answers 403. + const decorate = (row, req) => ({ + ...row, + scope: row.version_id == null ? 'source' : 'version', + can_edit: !!req.session?.user?.is_admin || row.owner === sessionUser(req) + }); + + // The owner/admin gate, shared by every write. Returns the row, or sends the + // response itself and returns null. + async function ownedLayout(req, res) { + const id = parseInt(req.params.lid); + const { rows } = await pool.query( + `SELECT ${SELECT_COLS} FROM pf.layout WHERE id = $1`, [id] + ); + if (!rows.length) { res.status(404).json({ error: 'Layout not found' }); return null; } + const row = rows[0]; + if (!req.session?.user?.is_admin && row.owner !== sessionUser(req)) { + res.status(403).json({ + error: `“${row.name}” belongs to ${row.owner} — only they or an administrator can change it` + }); + return null; + } + return row; + } + + // Everything applicable to a version: its own published layouts, the + // source-wide published ones, and the caller's own private layouts at either + // scope. Deliberately not territory-filtered — a layout is display config, + // and territory restricts rows, not columns. + router.get('/versions/:id/layouts', async (req, res) => { + const versionId = parseInt(req.params.id); + try { + const ver = await pool.query( + `SELECT source_id FROM pf.version WHERE id = $1`, [versionId] + ); + if (!ver.rows.length) return res.status(404).json({ error: 'Version not found' }); + const sourceId = ver.rows[0].source_id; + + const { rows } = await pool.query( + `SELECT ${SELECT_COLS} + FROM pf.layout + WHERE TRUE + AND source_id = $1 + AND (version_id = $2 OR version_id IS NULL) + AND (visibility = 'published' OR owner = $3) + ORDER BY visibility DESC, is_default DESC, lower(name)`, + [sourceId, versionId, sessionUser(req)] + ); + res.json(rows.map(r => decorate(r, req))); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // Create. scope 'source' saves it against every version of the source; + // anything else is this version. + router.post('/versions/:id/layouts', async (req, res) => { + const versionId = parseInt(req.params.id); + const { name, config, visibility = 'private', scope = 'version' } = req.body || {}; + if (!name || !String(name).trim()) return res.status(400).json({ error: 'Name is required' }); + if (!config) return res.status(400).json({ error: 'Config is required' }); + if (!['private', 'published'].includes(visibility)) { + return res.status(400).json({ error: 'visibility must be private or published' }); + } + try { + const ver = await pool.query( + `SELECT source_id FROM pf.version WHERE id = $1`, [versionId] + ); + if (!ver.rows.length) return res.status(404).json({ error: 'Version not found' }); + + const { rows } = await pool.query( + `INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING ${SELECT_COLS}`, + [ver.rows[0].source_id, scope === 'source' ? null : versionId, + String(name).trim(), config, sessionUser(req), visibility] + ); + res.json(decorate(rows[0], req)); + } catch (err) { + // the partial unique indexes, reported in the terms the user typed + if (err.code === '23505') { + return res.status(409).json({ error: `A layout named “${name}” already exists here` }); + } + res.status(500).json({ error: err.message }); + } + }); + + // Update any of name / config / visibility / is_default. Each is optional; + // a missing key leaves the column alone, which is why the flag-and-value + // pair is used rather than COALESCE on the value. + router.patch('/layouts/:lid', async (req, res) => { + const { name, config, visibility, is_default, scope } = req.body || {}; + if (name === undefined && config === undefined && visibility === undefined + && is_default === undefined && scope === undefined) { + return res.status(400).json({ error: 'Nothing to update' }); + } + if (visibility !== undefined && !['private', 'published'].includes(visibility)) { + return res.status(400).json({ error: 'visibility must be private or published' }); + } + const client = await pool.connect(); + try { + const row = await ownedLayout(req, res); + if (!row) return; + + // Unpublishing a default would leave a default nobody can see, which + // the table's CHECK refuses; clear the flag with it rather than + // failing on a constraint the user never mentioned. + const clearsDefault = visibility === 'private'; + + // One default per scope, enforced by a partial unique index. Stand + // the others down first rather than letting the update collide -- + // and in one transaction, or a name clash on the second statement + // leaves the version with no default at all. + const target_version = scope === undefined ? row.version_id + : (scope === 'source' ? null : row.version_id); + await client.query('BEGIN'); + if (is_default === true) { + await client.query( + `UPDATE pf.layout SET is_default = false + WHERE TRUE + AND source_id = $1 + AND COALESCE(version_id, 0) = COALESCE($2::int, 0) + AND id <> $3 + AND is_default`, + [row.source_id, target_version, row.id] + ); + } + + const { rows } = await client.query( + `UPDATE pf.layout SET + name = CASE WHEN $2::bool THEN $3::text ELSE name END, + config = CASE WHEN $4::bool THEN $5::jsonb ELSE config END, + visibility = CASE WHEN $6::bool THEN $7::text ELSE visibility END, + is_default = CASE WHEN $10::bool THEN false + WHEN $8::bool THEN $9::bool ELSE is_default END, + version_id = CASE WHEN $11::bool THEN $12::int ELSE version_id END, + updated_at = now() + WHERE id = $1 + RETURNING ${SELECT_COLS}`, + [row.id, + name !== undefined, name !== undefined ? String(name).trim() : null, + config !== undefined, config !== undefined ? config : null, + visibility !== undefined, visibility !== undefined ? visibility : null, + is_default !== undefined, is_default === true, + clearsDefault, + scope !== undefined, target_version] + ); + await client.query('COMMIT'); + res.json(decorate(rows[0], req)); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + if (err.code === '23505') { + return res.status(409).json({ error: 'A layout with that name already exists here' }); + } + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + router.delete('/layouts/:lid', async (req, res) => { + try { + const row = await ownedLayout(req, res); + if (!row) return; + await pool.query(`DELETE FROM pf.layout WHERE id = $1`, [row.id]); + res.json({ deleted: row.id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + return router; +}; diff --git a/routes/sources.js b/routes/sources.js index b01865b..1a514b3 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -449,23 +449,12 @@ module.exports = function(pool) { } }); - // set or clear the default Perspective layout for a source. - // Body: a Perspective view config (group_by, split_by, columns, plugin_config, …). - // Pass null or {} to clear. - router.put('/sources/:id/default-layout', async (req, res) => { - try { - const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null; - const result = await pool.query( - `UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`, - [layout, req.params.id] - ); - if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' }); - res.json(result.rows[0]); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } - }); + // PUT /sources/:id/default-layout is gone. It wrote pf.source.default_layout, + // one anonymous blob per source that any account could overwrite for every + // other account -- a published layout with no owner. Its successor is + // pf.layout: named, owned, and writable only by its owner or an admin. The + // old column is left in place, already migrated into pf.layout by + // setup_sql/01_schema.sql, and read by nothing. // deregister a source — does not drop existing forecast tables router.get('/dim-period/cols', async (req, res) => { diff --git a/server.js b/server.js index dede991..2c2f543 100644 --- a/server.js +++ b/server.js @@ -80,6 +80,7 @@ app.use('/api', require('./routes/sources')(pool)); app.use('/api', require('./routes/versions')(pool)); app.use('/api', require('./routes/operations')(pool)); app.use('/api', require('./routes/log')(pool)); +app.use('/api', require('./routes/layouts')(pool)); const port = process.env.PORT || 3010; diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index c0a8169..96db788 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -203,3 +203,61 @@ CREATE TABLE IF NOT EXISTS pf.sql ( generated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (source_id, operation) ); + +-- pf.layout: named Perspective view configs. +-- +-- Replaces the per-browser localStorage lists (pf_layouts_v*) and the single +-- anonymous pf.source.default_layout. A layout is either `private` -- visible +-- only to its owner -- or `published`, visible to everyone on the version and +-- writable only by its owner or an admin, the same rule pf.log already uses. +CREATE TABLE IF NOT EXISTS pf.layout ( + id serial PRIMARY KEY, + source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, + -- null = applies to every version of the source; that is where the old + -- source default lives, and what a new version picks up before anyone has + -- published a layout of its own. + version_id integer REFERENCES pf.version(id) ON DELETE CASCADE, + name text NOT NULL, + config jsonb NOT NULL, + owner text NOT NULL, + visibility text NOT NULL DEFAULT 'private', -- private | published + is_default boolean NOT NULL DEFAULT false, -- applied on first load + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (visibility IN ('private', 'published')), + -- only a published layout can be the default: a private one would be a + -- default nobody else could see. + CHECK (NOT is_default OR visibility = 'published') +); + +-- COALESCE rather than the bare column: version_id is nullable, and in a unique +-- index NULLs are distinct, so source-wide rows would not be constrained at all. +CREATE UNIQUE INDEX IF NOT EXISTS layout_private_name + ON pf.layout (source_id, COALESCE(version_id, 0), owner, lower(name)) + WHERE visibility = 'private'; + +CREATE UNIQUE INDEX IF NOT EXISTS layout_published_name + ON pf.layout (source_id, COALESCE(version_id, 0), lower(name)) + WHERE visibility = 'published'; + +-- one default per scope +CREATE UNIQUE INDEX IF NOT EXISTS layout_one_default + ON pf.layout (source_id, COALESCE(version_id, 0)) + WHERE is_default; + +CREATE INDEX IF NOT EXISTS layout_lookup ON pf.layout (source_id, version_id); + +-- Carry the old single source default across as a published, source-wide row. +-- Idempotent: skipped once a default exists for that source. +INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default) +SELECT s.id, NULL, 'Source default', s.default_layout, COALESCE(s.created_by, 'admin'), 'published', true +FROM pf.source s +WHERE TRUE + AND s.default_layout IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM pf.layout l + WHERE TRUE + AND l.source_id = s.id + AND l.version_id IS NULL + AND l.is_default + ); diff --git a/ui/src/components/LayoutMenu.jsx b/ui/src/components/LayoutMenu.jsx new file mode 100644 index 0000000..c43dd20 --- /dev/null +++ b/ui/src/components/LayoutMenu.jsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from 'react' + +// The pivot layout, as one control rather than a row of equal chips. +// +// Two lists, because a layout is one of two different things: Published, which +// everyone on this forecast sees and only its owner or an admin may change, and +// Mine, which nobody else lists. Everything you cannot write is still there to +// apply -- it is only the write controls that are withheld, so a click never +// answers 403. +export default function LayoutMenu({ + layouts = [], activeLayoutId, dirty, + onApply, onSave, onSaveAs, onSetVisibility, onSetDefault, onRename, onDelete, onReset +}) { + const [open, setOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const [savePublic, setSavePublic] = useState(false) + const [renaming, setRenaming] = useState(null) + const [renameTo, setRenameTo] = useState('') + const boxRef = useRef(null) + + // Close on an outside click or Escape. Capture phase: the pivot lives in a + // shadow root and retargets its events, so a bubbled listener on document + // sees the host rather than the real target and cannot tell inside from out. + useEffect(() => { + if (!open) return + const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) } + const onKey = (e) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', onDown, true) + window.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown, true) + window.removeEventListener('keydown', onKey) + } + }, [open]) + + const active = layouts.find(l => l.id === activeLayoutId) + const published = layouts.filter(l => l.visibility === 'published') + const mine = layouts.filter(l => l.visibility === 'private') + + const submitSaveAs = () => { + const name = saveName.trim() + if (!name) return + onSaveAs(name, savePublic ? 'published' : 'private') + setSaveName('') + setOpen(false) + } + + const submitRename = (l) => { + const name = renameTo.trim() + if (name && name !== l.name) onRename(l, name) + setRenaming(null) + } + + const iconBtn = 'px-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 leading-none' + + const row = (l) => ( +