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