pf_app/routes/layouts.js
Paul Trowbridge d2ba944d41 Make a pivot layout a thing people own and publish
Named layouts lived in localStorage: invisible to anyone else, gone on the
next machine, and nothing to publish from. The one server-side layout was
pf.source.default_layout -- a single anonymous blob any account could
overwrite for every account, which is a published layout with no owner.

pf.layout replaces both. A layout is named, owned, and either private or
published; published ones are listed by everyone on the forecast and
writable only by their owner or an admin, the same rule pf.log already uses
for its entries. Scope is the version, since that is the entry point, with
version_id NULL for the source-wide default a new version inherits.
Applying is never restricted -- Save is withheld on a layout that is not
yours, Save as forks it -- because the guarantee wanted is that a published
layout cannot be changed out from under people, not that it cannot be
adapted.

The toolbar's flat chip row becomes one Layout menu: Published and Mine,
rename/publish/default/delete shown only where they would be allowed, and
a dirty dot computed by comparing the live config against the one the pivot
was applied from, since restore() fires the change event itself.

Existing localStorage lists are lifted into pf.layout on first load.
PUT /sources/:id/default-layout is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 14:20:04 -04:00

194 lines
9.0 KiB
JavaScript

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