@bongos/core 1.19.535 → 1.19.537
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +63 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +187 -4
- package/docs/api-reference.md +10 -2
- package/docs/copy-inventory.md +34 -15
- package/docs/copy-registry.json +233 -30
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/government/catalog.js +1 -0
- package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
- package/modules/hall-ui/public/project-settings.css +90 -0
- package/modules/hall-ui/public/project-settings.html +68 -0
- package/modules/hall-ui/public/project-settings.js +249 -0
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/lifecycle/goal-category.js +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/goals.js +16 -0
- package/modules/lifecycle/routes/project-settings.js +263 -0
- package/modules/provisioning/provisioning.js +33 -3
- package/modules/provisioning/routes/provisioning.js +15 -1
- package/modules/provisioning/tests/provisioning.mjs +29 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +12 -1
- package/src/module-api.js +13 -1
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/project_settings_page.mjs +337 -0
- package/tests/provisioning_module_selection.mjs +114 -5
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Project settings — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="The fundamentals of this project in one place — the stale timer, the category set, how credits are calculated, whether equity is real, and what share of upmarket revenue is paid on credits.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/oversight.css">
|
|
13
|
+
<link rel="stylesheet" href="/builders/project-settings.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body data-page="project-settings" data-page-title="Project settings">
|
|
16
|
+
|
|
17
|
+
<div class="app">
|
|
18
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
19
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
20
|
+
<div class="app-body">
|
|
21
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
22
|
+
<div class="app-content">
|
|
23
|
+
<!-- The LABEL voice (the page-head--board idiom, task 1003307): on a working
|
|
24
|
+
surface the page's own name is the least interesting thing on it. -->
|
|
25
|
+
<div class="page-head page-head--board">
|
|
26
|
+
<h1 class="page-head__h">Project settings</h1>
|
|
27
|
+
<p class="page-head__sub" id="ps-sub">Loading…</p>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<main id="hall-main">
|
|
31
|
+
<!-- The sealed state is a panel with a heading, not a bare line on the ground
|
|
32
|
+
(task 1003469). The page gate is the real wall; this is what a reader who
|
|
33
|
+
followed a link sees instead of an empty page. -->
|
|
34
|
+
<section class="scroll" id="ps-sealed" aria-labelledby="ps-sealed-h" hidden>
|
|
35
|
+
<h2 class="scroll__h" id="ps-sealed-h">Restricted</h2>
|
|
36
|
+
<p class="scroll__lede" id="ps-sealed-body">This page opens at a higher rank.</p>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<section class="scroll" id="ps-body" aria-labelledby="ps-body-h" hidden>
|
|
40
|
+
<h2 class="scroll__h" id="ps-body-h">The fundamentals</h2>
|
|
41
|
+
<p class="scroll__lede">
|
|
42
|
+
Five things every project settles. Two of them are knobs you can turn here;
|
|
43
|
+
the other three are decided in committed configuration, and each says which
|
|
44
|
+
file decides it.
|
|
45
|
+
</p>
|
|
46
|
+
<!-- One mount, five rows, written by project-settings.js. The rows are NOT
|
|
47
|
+
authored in this file: each carries a value plus the provenance the
|
|
48
|
+
server resolved it through, and markup that could drift from that is the
|
|
49
|
+
thing this page exists to end. -->
|
|
50
|
+
<div id="ps-rows"><span class="kit-skel"></span></div>
|
|
51
|
+
</section>
|
|
52
|
+
</main>
|
|
53
|
+
|
|
54
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
60
|
+
|
|
61
|
+
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
62
|
+
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
63
|
+
<script src="/builders/palette.js" defer></script>
|
|
64
|
+
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
65
|
+
<script src="/builders/hall-kit.js"></script>
|
|
66
|
+
<script src="/builders/project-settings.js?v=2026-09-04-ps1"></script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// project-settings.js — the five fundamentals of this project, each with the place
|
|
2
|
+
// its value came from (task 1003280, goal 1000072, criterion legibility-project-settings).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS PAGE IS FOR. Goal 1000072's thesis: the project is legible to Claude and
|
|
5
|
+
// illegible to a person. Answering "how long before work is called stale", "what
|
|
6
|
+
// categories do we use", "how is a credit calculated", "is equity real" or "what
|
|
7
|
+
// share of upmarket is paid on credits" each meant reading source. This page is the
|
|
8
|
+
// translation layer — it invents no value and stores nothing of its own.
|
|
9
|
+
//
|
|
10
|
+
// THE RULE THAT SHAPES EVERY ROW: the SERVER decides what each row says and where it
|
|
11
|
+
// came from; this file only draws it. No formula, threshold, default or file path is
|
|
12
|
+
// re-typed here. That is deliberate and load-bearing — a settings page carrying its
|
|
13
|
+
// own copy of `round(cost × 1.20)` or of "1..365" is exactly the drift ADR 0226
|
|
14
|
+
// describes, one release later and on the screen the owner trusts most.
|
|
15
|
+
//
|
|
16
|
+
// A ROW THE READER CANNOT TYPE INTO MUST LOOK UNWRITABLE AND SAY WHY. Three of the
|
|
17
|
+
// five are read-only, and two of those are read-only for different reasons — equity
|
|
18
|
+
// is a settled owner decision that it is not real, upmarket is a knob that does not
|
|
19
|
+
// exist yet. Both render as themselves rather than as a plausible zero.
|
|
20
|
+
(() => {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const API = '/api/bongos';
|
|
24
|
+
// The generated typed client (task 1996 / R09), as every other hall page uses it.
|
|
25
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
26
|
+
const { escapeHtml } = window.OTB;
|
|
27
|
+
const { emptyStateHtml } = window.OTBKit;
|
|
28
|
+
const toast = (msg) => window.OTB.toast(msg);
|
|
29
|
+
|
|
30
|
+
const $ = (id) => document.getElementById(id);
|
|
31
|
+
|
|
32
|
+
async function fetchJson(path) {
|
|
33
|
+
const res = await api.request('GET', `${API}${path}`);
|
|
34
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
35
|
+
if (res.status === 403) { throw { kind: '403' }; }
|
|
36
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
37
|
+
return res.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function sendJson(method, path, body) {
|
|
41
|
+
const res = await api.request(method, `${API}${path}`, { body: body || {} });
|
|
42
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
43
|
+
const data = res.data || {};
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = data.error || {};
|
|
46
|
+
// The server's message when it wrote one (its range wording is the authority),
|
|
47
|
+
// the code as a last resort. Never a message invented here.
|
|
48
|
+
throw new Error(err.message && err.message !== err.code ? err.message : (err.code || `${path} → ${res.status}`));
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- row chrome ----------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
// The provenance foot. `reads.file` is the artefact the value resolves from and is
|
|
56
|
+
// shown as code because it is a path or a table key the reader may go and open.
|
|
57
|
+
function provHtml(row) {
|
|
58
|
+
const reads = row.reads || {};
|
|
59
|
+
const bits = [];
|
|
60
|
+
if (row.source) bits.push(`source: ${escapeHtml(String(row.source))}`);
|
|
61
|
+
if (reads.file) bits.push(`reads <code>${escapeHtml(String(reads.file))}</code>`);
|
|
62
|
+
if (reads.adr) bits.push(escapeHtml(String(reads.adr)));
|
|
63
|
+
if (!bits.length) return '';
|
|
64
|
+
return `<p class="ps-prov">${bits.join(' · ')}</p>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PILL = {
|
|
68
|
+
live: ['ps-pill--live', 'live'],
|
|
69
|
+
'read-only': ['ps-pill--readonly', 'committed config'],
|
|
70
|
+
'not-yet-real': ['ps-pill--readonly', 'not yet real'],
|
|
71
|
+
absent: ['ps-pill--absent', 'not configured'],
|
|
72
|
+
unavailable: ['ps-pill--absent', 'unavailable'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function pillHtml(row) {
|
|
76
|
+
// An editable row is the only thing that gets the "live" pill; a read-only row
|
|
77
|
+
// whose state the server called 'live' is still committed config to the reader.
|
|
78
|
+
const key = row.editable ? 'live' : (row.state === 'live' ? 'read-only' : row.state);
|
|
79
|
+
const [cls, label] = PILL[key] || PILL['read-only'];
|
|
80
|
+
return `<span class="ps-pill ${cls}">${escapeHtml(label)}</span>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One row's shell. `bodyHtml` is whatever that row needs beneath its sentence —
|
|
84
|
+
// controls for a knob, nothing for the three that are read.
|
|
85
|
+
function rowHtml(row, bodyHtml) {
|
|
86
|
+
return `
|
|
87
|
+
<li class="ov-row" data-row="${escapeHtml(row.key)}">
|
|
88
|
+
<div class="ov-row__main">
|
|
89
|
+
<span class="ov-row__title">${escapeHtml(row.title)} ${pillHtml(row)}</span>
|
|
90
|
+
<p class="ov-row__sub">${escapeHtml(row.sentence || '')}</p>
|
|
91
|
+
${bodyHtml || ''}
|
|
92
|
+
${provHtml(row)}
|
|
93
|
+
</div>
|
|
94
|
+
</li>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- the two knobs -------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function rotBodyHtml(row) {
|
|
100
|
+
const v = row.value || {};
|
|
101
|
+
// The bounds come from the server's own resolver, never re-typed here.
|
|
102
|
+
const invalid = row.invalid
|
|
103
|
+
? `<p class="ps-invalid">A configured value could not be used: <code>${escapeHtml(String(row.invalid.raw))}</code>
|
|
104
|
+
at ${escapeHtml(String(row.invalid.where))} — ${escapeHtml(String(row.invalid.reason))}.
|
|
105
|
+
The timer above is the fallback that is actually in force.</p>`
|
|
106
|
+
: '';
|
|
107
|
+
const sib = row.sibling
|
|
108
|
+
? `<p class="ps-sibling"><strong>${escapeHtml(row.sibling.title)}</strong> —
|
|
109
|
+
${escapeHtml(row.sibling.sentence)}
|
|
110
|
+
${row.sibling.reads && row.sibling.reads.file
|
|
111
|
+
? `<code>${escapeHtml(row.sibling.reads.file)}</code>` : ''}</p>`
|
|
112
|
+
: '';
|
|
113
|
+
return `
|
|
114
|
+
${invalid}
|
|
115
|
+
<div class="ps-controls">
|
|
116
|
+
<label for="ps-rot-days">Raise after</label>
|
|
117
|
+
<input type="number" id="ps-rot-days" min="${escapeHtml(String(v.min))}" max="${escapeHtml(String(v.max))}"
|
|
118
|
+
step="1" value="${escapeHtml(String(v.days))}" aria-describedby="ps-rot-hint">
|
|
119
|
+
<span id="ps-rot-hint">days (${escapeHtml(String(v.min))}–${escapeHtml(String(v.max))})</span>
|
|
120
|
+
<button type="button" class="btn" id="ps-rot-save">Save</button>
|
|
121
|
+
</div>
|
|
122
|
+
${sib}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function catsBodyHtml(row) {
|
|
126
|
+
const v = row.value || {};
|
|
127
|
+
const cats = Array.isArray(v.categories) ? v.categories : [];
|
|
128
|
+
const list = cats.length
|
|
129
|
+
? `<ul class="ps-cats">${cats.map((c) => `
|
|
130
|
+
<li class="ps-cat ${c.active ? '' : 'ps-cat--off'}" data-cat="${escapeHtml(String(c.id))}">
|
|
131
|
+
<input type="text" class="ps-cat__label" value="${escapeHtml(String(c.label))}" aria-label="Label for ${escapeHtml(String(c.slug))}">
|
|
132
|
+
<span class="ps-cat__slug"><code>${escapeHtml(String(c.slug))}</code></span>
|
|
133
|
+
<input type="number" class="ps-cat__order" step="10" value="${escapeHtml(String(c.sort_order))}" aria-label="Order for ${escapeHtml(String(c.slug))}">
|
|
134
|
+
<label><input type="checkbox" class="ps-cat__active" ${c.active ? 'checked' : ''}> offered</label>
|
|
135
|
+
<button type="button" class="btn ps-cat__save">Save</button>
|
|
136
|
+
</li>`).join('')}</ul>`
|
|
137
|
+
: emptyStateHtml('This project has no categories yet.');
|
|
138
|
+
return `
|
|
139
|
+
${list}
|
|
140
|
+
<div class="ps-controls">
|
|
141
|
+
<input type="text" id="ps-cat-new" placeholder="New category label" aria-label="New category label">
|
|
142
|
+
<button type="button" class="btn" id="ps-cat-add">Add</button>
|
|
143
|
+
</div>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- render + wire -------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
let rows = [];
|
|
149
|
+
|
|
150
|
+
function render() {
|
|
151
|
+
const mount = $('ps-rows');
|
|
152
|
+
mount.innerHTML = `<ul class="ov-rows">${rows.map((row) => {
|
|
153
|
+
if (row.key === 'rot_timer' && row.editable) return rowHtml(row, rotBodyHtml(row));
|
|
154
|
+
if (row.key === 'work_categories' && row.editable) return rowHtml(row, catsBodyHtml(row));
|
|
155
|
+
return rowHtml(row, '');
|
|
156
|
+
}).join('')}</ul>`;
|
|
157
|
+
wire();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Every control dies while a write is in flight and comes back after — the
|
|
161
|
+
// sessions.js discipline. Without it a double-click sends two writes and the second
|
|
162
|
+
// reply, arriving first, can repaint a value nobody asked for.
|
|
163
|
+
function setBusy(on) {
|
|
164
|
+
const mount = $('ps-rows');
|
|
165
|
+
mount.querySelectorAll('input, button').forEach((el) => { el.disabled = on; });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function wire() {
|
|
169
|
+
const rotSave = $('ps-rot-save');
|
|
170
|
+
if (rotSave) {
|
|
171
|
+
rotSave.addEventListener('click', async () => {
|
|
172
|
+
const days = Number($('ps-rot-days').value);
|
|
173
|
+
setBusy(true);
|
|
174
|
+
try {
|
|
175
|
+
const out = await sendJson('PATCH', '/project-settings/rot-days', { days });
|
|
176
|
+
toast(`Stale timer is now ${out.rot_days} days.`);
|
|
177
|
+
await load(); // re-read, so the row shows what the SERVER now resolves
|
|
178
|
+
} catch (err) {
|
|
179
|
+
toast(err.message || 'Could not save the timer.');
|
|
180
|
+
setBusy(false); // load() re-renders on success; on failure put the row back
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const addBtn = $('ps-cat-add');
|
|
186
|
+
if (addBtn) {
|
|
187
|
+
addBtn.addEventListener('click', async () => {
|
|
188
|
+
const label = ($('ps-cat-new').value || '').trim();
|
|
189
|
+
if (!label) { toast('Give the category a label.'); return; }
|
|
190
|
+
setBusy(true);
|
|
191
|
+
try {
|
|
192
|
+
const out = await sendJson('POST', '/work-categories', { label });
|
|
193
|
+
toast(out.created === false ? `“${out.category.label}” already existed.` : `Added “${out.category.label}”.`);
|
|
194
|
+
await load();
|
|
195
|
+
} catch (err) {
|
|
196
|
+
toast(err.message || 'Could not add the category.');
|
|
197
|
+
setBusy(false);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
document.querySelectorAll('.ps-cat__save').forEach((btn) => {
|
|
203
|
+
btn.addEventListener('click', async () => {
|
|
204
|
+
const li = btn.closest('.ps-cat');
|
|
205
|
+
const id = li.getAttribute('data-cat');
|
|
206
|
+
const body = {
|
|
207
|
+
label: li.querySelector('.ps-cat__label').value.trim(),
|
|
208
|
+
sort_order: Number(li.querySelector('.ps-cat__order').value),
|
|
209
|
+
active: li.querySelector('.ps-cat__active').checked,
|
|
210
|
+
};
|
|
211
|
+
setBusy(true);
|
|
212
|
+
try {
|
|
213
|
+
await sendJson('PATCH', `/work-categories/${encodeURIComponent(id)}`, body);
|
|
214
|
+
toast('Category saved.');
|
|
215
|
+
await load();
|
|
216
|
+
} catch (err) {
|
|
217
|
+
toast(err.message || 'Could not save the category.');
|
|
218
|
+
setBusy(false);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function load() {
|
|
225
|
+
try {
|
|
226
|
+
const data = await fetchJson('/project-settings');
|
|
227
|
+
rows = Array.isArray(data.rows) ? data.rows : [];
|
|
228
|
+
$('ps-sub').textContent = `${rows.length} fundamentals · ${rows.filter((r) => r.editable).length} you can change here`;
|
|
229
|
+
$('ps-body').hidden = false;
|
|
230
|
+
$('ps-sealed').hidden = true;
|
|
231
|
+
render();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err && err.kind === '401') return; // already redirecting to sign-in
|
|
234
|
+
if (err && err.kind === '403') {
|
|
235
|
+
$('ps-sub').textContent = 'Restricted';
|
|
236
|
+
$('ps-sealed').hidden = false;
|
|
237
|
+
$('ps-body').hidden = true;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// A failed read is a NAMED failure, never a blank page that reads as "this
|
|
241
|
+
// project has no settings".
|
|
242
|
+
$('ps-sub').textContent = 'Could not load';
|
|
243
|
+
$('ps-body').hidden = false;
|
|
244
|
+
$('ps-rows').innerHTML = emptyStateHtml('Could not load the project settings just now.');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
load();
|
|
249
|
+
})();
|
|
@@ -236,6 +236,9 @@
|
|
|
236
236
|
{ id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'government', module: 'dev-box' },
|
|
237
237
|
{ id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
|
|
238
238
|
{ id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: 'government' },
|
|
239
|
+
// The project's own fundamentals (task 1003280, goal 1000072). Mirrors the
|
|
240
|
+
// server's requireGovernmentPage floor; cosmetic only, as every item here is.
|
|
241
|
+
{ id: 'project-settings', label: 'Project settings', icon: 'admin', href: '/project-settings', gate: 'government' },
|
|
239
242
|
// BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
|
|
240
243
|
// Archon-only item the 'government' gate comment anticipated — cosmetic
|
|
241
244
|
// only; requireGovernmentManagePage (the government.manage atom) is the wall.
|
|
@@ -134,6 +134,39 @@ async function setGoalCategory({ goalId, categoryId }, deps = {}) {
|
|
|
134
134
|
return rows[0] ?? null;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Update one category's SHAPE — its label, its place in the order, or whether it
|
|
138
|
+
// is still offered. Task 1003280's settings page is the surface that needed this:
|
|
139
|
+
// the table has carried `sort_order` and `active` since core_226, but nothing could
|
|
140
|
+
// write them, so a project could only ever ADD to its vocabulary.
|
|
141
|
+
//
|
|
142
|
+
// PATCH SEMANTICS, NOT PUT: undefined means "leave it", so a reorder never has to
|
|
143
|
+
// resend the label and cannot blank it by omission. COALESCE does that in SQL,
|
|
144
|
+
// which is why every parameter is cast — a bare NULL parameter has no type and
|
|
145
|
+
// Postgres refuses to infer one through COALESCE.
|
|
146
|
+
//
|
|
147
|
+
// THE SLUG IS NOT PATCHABLE, deliberately. It is the stable key a goal's prose and
|
|
148
|
+
// the /goal-create prompt refer to; renaming it would silently orphan those. The
|
|
149
|
+
// LABEL is the human-facing string and is freely editable — that split is the whole
|
|
150
|
+
// reason the table carries both.
|
|
151
|
+
//
|
|
152
|
+
// DEACTIVATE, NEVER DELETE: `active=false` drops a category from the offered set
|
|
153
|
+
// while every goal already carrying it keeps a resolvable label. A DELETE would
|
|
154
|
+
// either cascade (rewriting history) or fail on the FK (a dead end for the owner).
|
|
155
|
+
async function updateCategory({ id, label, sortOrder, active }, deps = {}) {
|
|
156
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
157
|
+
const { rows } = await activePool.query(
|
|
158
|
+
`UPDATE work_categories
|
|
159
|
+
SET label = COALESCE($2::text, label),
|
|
160
|
+
sort_order = COALESCE($3::integer, sort_order),
|
|
161
|
+
active = COALESCE($4::boolean, active),
|
|
162
|
+
updated_at = now()
|
|
163
|
+
WHERE id = $1
|
|
164
|
+
RETURNING ${CATEGORY_COLS}`,
|
|
165
|
+
[id, label ?? null, sortOrder ?? null, active ?? null]
|
|
166
|
+
);
|
|
167
|
+
return rows[0] ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
// -------------------------------------------------------------------------
|
|
138
171
|
// The advisory (task 1003277)
|
|
139
172
|
// -------------------------------------------------------------------------
|
|
@@ -246,6 +279,53 @@ function categoryCreateHandler({ create = createCategory, validateOrRespond, LIM
|
|
|
246
279
|
// before/after text. A category is a label, not prose — re-labelling is cheap and
|
|
247
280
|
// reversible, and folding it into the prose handler would either subject a label
|
|
248
281
|
// to the prose rules or loosen the prose rules to suit a label.
|
|
282
|
+
// PATCH /work-categories/:id — rename, reorder or retire one category.
|
|
283
|
+
//
|
|
284
|
+
// EVERY FIELD OPTIONAL, and "no field given" is a 400 rather than a silent no-op: a
|
|
285
|
+
// form that submitted nothing has a bug, and answering 200 would hide it behind a row
|
|
286
|
+
// that looks saved.
|
|
287
|
+
//
|
|
288
|
+
// `active: false` is the retire verb. There is no delete: see updateCategory's header.
|
|
289
|
+
function categoryUpdateHandler({ update = updateCategory, validateOrRespond, LIMITS, parseId, log } = {}) {
|
|
290
|
+
return async function patchWorkCategory(req, res) {
|
|
291
|
+
const id = parseId(req, res, { code: 'bad_category_id', positiveInt: true });
|
|
292
|
+
if (id === null) return;
|
|
293
|
+
if (validateOrRespond(req, res, {
|
|
294
|
+
label: { type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
|
|
295
|
+
sort_order: { type: 'number' },
|
|
296
|
+
active: { type: 'boolean' },
|
|
297
|
+
})) return;
|
|
298
|
+
|
|
299
|
+
const body = req.body || {};
|
|
300
|
+
const label = body.label === undefined ? undefined : String(body.label).trim();
|
|
301
|
+
if (label !== undefined && !label) return res.fail('label_required', 400);
|
|
302
|
+
// Whole numbers only. A fractional sort_order would round in the column and land
|
|
303
|
+
// somewhere the caller did not ask for, which is worse than refusing.
|
|
304
|
+
const sortOrder = body.sort_order === undefined ? undefined : Number(body.sort_order);
|
|
305
|
+
if (sortOrder !== undefined && !Number.isInteger(sortOrder)) {
|
|
306
|
+
return res.fail('bad_sort_order', 400, { message: 'sort_order must be a whole number' });
|
|
307
|
+
}
|
|
308
|
+
const active = body.active === undefined ? undefined : Boolean(body.active);
|
|
309
|
+
if (label === undefined && sortOrder === undefined && active === undefined) {
|
|
310
|
+
return res.fail('nothing_to_update', 400, {
|
|
311
|
+
message: 'Send at least one of label, sort_order or active.',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// `UPDATE … RETURNING` already yields null for a missing row, so a separate
|
|
317
|
+
// pre-read to produce the 404 was a second PK query for nothing (task 1003280
|
|
318
|
+
// grade). A null return here IS "no such category" → 404, not a 500.
|
|
319
|
+
const row = await update({ id, label, sortOrder, active });
|
|
320
|
+
if (!row) return res.fail('category_not_found', 404);
|
|
321
|
+
return res.json({ ok: true, category: row });
|
|
322
|
+
} catch (err) {
|
|
323
|
+
log?.error({ err: err && err.message }, 'PATCH /work-categories/:id failed');
|
|
324
|
+
return res.fail('category_update_failed', { status: 500, message: 'internal error' });
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
249
329
|
function goalCategoryHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, set = setGoalCategory, read = getCategory, index = categoryIndex, log } = {}) {
|
|
250
330
|
return async function patchGoalCategory(req, res) {
|
|
251
331
|
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
@@ -292,10 +372,12 @@ module.exports = {
|
|
|
292
372
|
getCategory,
|
|
293
373
|
adviseCategory,
|
|
294
374
|
createCategory,
|
|
375
|
+
updateCategory,
|
|
295
376
|
setGoalCategory,
|
|
296
377
|
decorateGoalCategory,
|
|
297
378
|
categoryIndex,
|
|
298
379
|
categoriesListHandler,
|
|
299
380
|
categoryCreateHandler,
|
|
381
|
+
categoryUpdateHandler,
|
|
300
382
|
goalCategoryHandler,
|
|
301
383
|
};
|
|
@@ -364,6 +364,22 @@ module.exports = function buildGoalsRouter() {
|
|
|
364
364
|
// so two people prompted at once converge instead of one being stranded.
|
|
365
365
|
router.post('/work-categories', auth.requireBuilder, auth.requirePermission('goal.create'),
|
|
366
366
|
goalCategory.categoryCreateHandler({ validateOrRespond, LIMITS, log }));
|
|
367
|
+
// PATCH /work-categories/:id — body { label?, sort_order?, active? }: rename a
|
|
368
|
+
// category, move it in the order, or stop offering it. The set has been list+add
|
|
369
|
+
// only since core_226 even though the table carries both columns, so a project
|
|
370
|
+
// could grow its vocabulary and never tidy it (task 1003280).
|
|
371
|
+
//
|
|
372
|
+
// rank: project.settings.manage (metic+, government_010) — the SHAPE of the set,
|
|
373
|
+
// one tier up from POST's goal.create, because reordering and deactivating affect
|
|
374
|
+
// what every future author is offered rather than adding one label to the tail.
|
|
375
|
+
//
|
|
376
|
+
// The slug is deliberately NOT patchable: it is the stable key goal prose and the
|
|
377
|
+
// /goal-create prompt refer to. Deactivating never deletes — goals already carrying
|
|
378
|
+
// a retired category keep a resolvable label.
|
|
379
|
+
router.patch('/work-categories/:id', auth.requireBuilder,
|
|
380
|
+
auth.requirePermission('project.settings.manage'),
|
|
381
|
+
goalCategory.categoryUpdateHandler({ validateOrRespond, LIMITS, parseId, log }));
|
|
382
|
+
|
|
367
383
|
// PATCH /goals/:id/category — body { category_id } (null clears it). Separate from
|
|
368
384
|
// PATCH /goals/:id because that route is the PROSE edit with its write-once posture
|
|
369
385
|
// and before/after audit delta; a label is neither.
|