@bongos/core 1.19.998 → 1.19.1000
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 +105 -90
- package/.claude/skills/collab-review/SKILL.md +24 -13
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +154 -4
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +18 -13
- package/docs/copy-registry.json +70 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +79 -0
- package/modules/hall-ui/public/collab.css +60 -0
- package/modules/hall-ui/public/collab.js +134 -5
- package/modules/lifecycle/help-requests.js +118 -8
- package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
- package/modules/lifecycle/routes/help-requests.js +93 -13
- package/modules/provisioning/capacity.js +3 -3
- package/modules/provisioning/control-plane-guard.js +1 -1
- package/modules/provisioning/core-upgrade.js +7 -7
- package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
- package/modules/provisioning/onboard-plan.js +1 -1
- package/modules/provisioning/paid-shape-gate.js +28 -15
- package/modules/provisioning/provisioning.js +18 -14
- package/modules/provisioning/routes/repo-private.js +1 -1
- package/modules/provisioning/tests/liveness.mjs +16 -8
- package/modules/provisioning/tests/provisioning.mjs +1 -1
- package/modules/public-landing/public/projects.html +26 -27
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +12 -4
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/provision-capacity.js +6 -1
- package/scripts/gds/provision-config.js +6 -3
- package/scripts/gds/provision-core-upgrade.js +8 -8
- package/scripts/gds/provision-disconnect.js +2 -2
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo-private.js +1 -1
- package/scripts/gds/provision-repo.js +13 -3
- package/scripts/gds/provision-teardown.js +2 -2
- package/scripts/gds/provision-units.js +2 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +140 -7
- package/tests/core_upgrade_door.mjs +1 -1
- package/tests/core_upgrade_runner.mjs +20 -20
- package/tests/deploy_page_projects.mjs +5 -5
- package/tests/hall_collab_world.mjs +6 -2
- package/tests/help_requests.mjs +201 -6
- package/tests/hub_disconnect_ui.mjs +4 -1
- package/tests/init.mjs +6 -6
- package/tests/onboard.mjs +5 -4
- package/tests/project_catalog_backfill.mjs +1 -1
- package/tests/project_catalog_bridge.mjs +2 -2
- package/tests/project_catalog_routes.mjs +4 -4
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +22 -5
- package/tests/projects_hub_repo_private.mjs +1 -1
- package/tests/provision.mjs +32 -32
- package/tests/provision_byo_host.mjs +1 -1
- package/tests/provision_caddy_wiring.mjs +1 -1
- package/tests/provision_disconnect.mjs +3 -3
- package/tests/provision_repo_private.mjs +1 -1
- package/tests/provision_restart.mjs +5 -5
- package/tests/provision_settings_apply.mjs +4 -4
- package/tests/provision_teardown_address.mjs +2 -2
- package/tests/provisioning_capacity.mjs +40 -34
- package/tests/provisioning_capacity_gate.mjs +5 -12
- package/tests/provisioning_default_domain.mjs +1 -1
- package/tests/provisioning_domain_attach.mjs +4 -4
- package/tests/provisioning_domain_unique.mjs +2 -2
- package/tests/provisioning_error_note_scope.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +5 -5
- package/tests/provisioning_onboard_mode.mjs +2 -2
- package/tests/provisioning_paid_shape_gate.mjs +57 -44
- package/tests/provisioning_repo_private_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +11 -4
- package/tests/provisioning_settings_apply.mjs +12 -5
- package/tests/provisioning_settings_env.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +3 -3
- package/tests/public_landing_projects.mjs +13 -6
- package/tests/upgrade.mjs +2 -2
|
@@ -57,6 +57,13 @@
|
|
|
57
57
|
// blocks and the API sends ids as strings.
|
|
58
58
|
const askById = new Map();
|
|
59
59
|
let forMeRows = [];
|
|
60
|
+
// Who is reading, and which crafts they answer for — the two facts L.canClose
|
|
61
|
+
// needs. Read from /me on load, never from a query param: the whole reason the
|
|
62
|
+
// addressed read takes its crafts from the profile is that a caller-supplied
|
|
63
|
+
// craft was this surface's privacy hole.
|
|
64
|
+
let meId = null;
|
|
65
|
+
let myCrafts = [];
|
|
66
|
+
let replying = false;
|
|
60
67
|
|
|
61
68
|
// The hall answers on two shapes — /builders on the site, or builders.<domain>
|
|
62
69
|
// as its own host. A pasted prompt is opened somewhere that is not this page,
|
|
@@ -125,6 +132,44 @@
|
|
|
125
132
|
</div>${full}`;
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
// ---- the thread (task 1004216, ADR 0330) -----------------------------------
|
|
136
|
+
//
|
|
137
|
+
// An ask can be ANSWERED on the page now. What this draws is deliberately thin:
|
|
138
|
+
// who said it, when, and what they said, oldest first — no avatars on replies
|
|
139
|
+
// (the row already leads with a face and a second column of them would read as
|
|
140
|
+
// a chat), no edit, no delete, no reactions, no threading of replies to
|
|
141
|
+
// replies. The restraint is the argument: this is the answer surface ADR 0330
|
|
142
|
+
// opens, not the chat surface ADR 0187 §4a declined.
|
|
143
|
+
function replyHtml(reply, nowMs) {
|
|
144
|
+
return `<li class="cb-reply">
|
|
145
|
+
<span class="cb-reply__who">${escapeHtml(L.replyAuthor(reply))}</span>
|
|
146
|
+
<span class="cb-reply__when">${escapeHtml(L.saidLabel(reply.created_at, nowMs))}</span>
|
|
147
|
+
<p class="cb-reply__body">${escapeHtml(String(reply.body == null ? '' : reply.body))}</p>
|
|
148
|
+
</li>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The composer is CLOSED until asked for. A textarea open on every row would
|
|
152
|
+
// turn a page you read into a page you are being asked to type on, and the
|
|
153
|
+
// blocks are condensed to three rows precisely so the page stays readable.
|
|
154
|
+
function threadHtml(req, key, nowMs) {
|
|
155
|
+
const thread = L.threadOf(req);
|
|
156
|
+
const id = escapeHtml(String(req.id));
|
|
157
|
+
const fid = `cb-reply-${escapeHtml(key)}-${id}`;
|
|
158
|
+
const list = thread.length
|
|
159
|
+
? `<ul class="cb-thread">${thread.map((r) => replyHtml(r, nowMs)).join('')}</ul>`
|
|
160
|
+
: '';
|
|
161
|
+
return `${list}<div class="cb-reply-bar">
|
|
162
|
+
<button class="cb-ask__door" type="button" data-reply-open="${fid}">${thread.length ? 'Add a reply' : 'Reply'}</button>
|
|
163
|
+
</div>
|
|
164
|
+
<form class="cb-reply-form" id="${fid}" data-reply-form="${id}" hidden>
|
|
165
|
+
<textarea class="cb-reply-input" name="body" rows="3" maxlength="${L.MAX_REPLY}" placeholder="Moot — the metrics stack was removed on the 15th, so there is nothing left to rotate."></textarea>
|
|
166
|
+
<div class="cb-reply-actions">
|
|
167
|
+
<button class="btn-accent" type="submit" data-reply-send="${id}">Post reply</button>
|
|
168
|
+
<button class="btn-ghost" type="button" data-reply-cancel="${fid}">Cancel</button>
|
|
169
|
+
</div>
|
|
170
|
+
</form>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
128
173
|
function contextChip(req) {
|
|
129
174
|
const ctx = L.contextOf(req);
|
|
130
175
|
if (!ctx) return '';
|
|
@@ -208,12 +253,22 @@
|
|
|
208
253
|
// call on this reader, so its row is the LIT one (.cb-row--live).
|
|
209
254
|
function forMeRow(req, nowMs, extra, key, baseline) {
|
|
210
255
|
const live = req.needs_builder_id != null ? ' cb-row--live' : '';
|
|
256
|
+
// CLOSING IS THE HALF THIS BLOCK NEVER HAD (task 1004216). Until now the only
|
|
257
|
+
// write on a row addressed to you was copying it; the person being asked
|
|
258
|
+
// could answer nowhere and close nothing, so a dealt-with ask stayed open
|
|
259
|
+
// until its author happened to remember it. L.canClose decides whether to
|
|
260
|
+
// draw it and the server's own WHERE decides whether it works — so the worst
|
|
261
|
+
// a wrong answer here can do is offer a button that refuses.
|
|
262
|
+
const close = L.canClose(req, meId, myCrafts)
|
|
263
|
+
? `<div class="ov-row__actions"><button class="btn-ghost" data-settle="answered" data-id="${escapeHtml(String(req.id))}">Close this ask</button></div>`
|
|
264
|
+
: '';
|
|
211
265
|
return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
|
|
212
266
|
${faceHtml(L.faceOf(req, 'in'))}
|
|
213
267
|
<div class="ov-row__main">${askHtml(req, key)}
|
|
214
268
|
<div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
|
|
269
|
+
${threadHtml(req, key, nowMs)}
|
|
215
270
|
</div>
|
|
216
|
-
<div class="ov-row__right">${ageChip(req, nowMs, baseline)}</div>
|
|
271
|
+
<div class="ov-row__right">${ageChip(req, nowMs, baseline)}${close}</div>
|
|
217
272
|
</li>`;
|
|
218
273
|
}
|
|
219
274
|
|
|
@@ -235,6 +290,7 @@
|
|
|
235
290
|
${faceHtml(L.faceOf(req, 'out'))}
|
|
236
291
|
<div class="ov-row__main">${askHtml(req, key)}
|
|
237
292
|
<div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}${ageChip(req, nowMs, baseline)}</div>
|
|
293
|
+
${threadHtml(req, key, nowMs)}
|
|
238
294
|
</div>
|
|
239
295
|
<div class="ov-row__right">
|
|
240
296
|
<div class="ov-row__actions">
|
|
@@ -252,6 +308,7 @@
|
|
|
252
308
|
${faceHtml(L.faceOf(req, 'in'))}
|
|
253
309
|
<div class="ov-row__main">${askHtml(req, key)}
|
|
254
310
|
<div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
|
|
311
|
+
${threadHtml(req, key, nowMs)}
|
|
255
312
|
</div>
|
|
256
313
|
<div class="ov-row__right">${ageChip(req, nowMs, baseline)}</div>
|
|
257
314
|
</li>`;
|
|
@@ -434,17 +491,28 @@
|
|
|
434
491
|
|
|
435
492
|
async function load() {
|
|
436
493
|
const nowMs = Date.now();
|
|
437
|
-
const [forMe, mine, all] = await Promise.all([
|
|
494
|
+
const [forMe, mine, all, me] = await Promise.all([
|
|
438
495
|
api.helpRequests.getHelpRequestsForMe({}),
|
|
439
496
|
api.helpRequests.getHelpRequestsMine({ query: { status: 'open' } }),
|
|
440
497
|
// The board read (task 1003942) — GET /help-requests, every open ask in the
|
|
441
498
|
// project. Through api.request because the generated client predates it.
|
|
442
499
|
api.request('GET', '/help-requests'),
|
|
500
|
+
// WHO IS READING (task 1004216), IN the fan-out rather than in front of it.
|
|
501
|
+
// The close control needs the viewer's id, and a serialized await here
|
|
502
|
+
// would put a round trip in front of every other read on the page.
|
|
503
|
+
api.me.getMe({}),
|
|
443
504
|
]);
|
|
444
505
|
|
|
445
506
|
const rowsOf = (r) => ((r && r.ok && r.data && r.data.help_requests) || []);
|
|
446
507
|
const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
|
|
447
508
|
|
|
509
|
+
// FAILS CLOSED. A /me that errored leaves meId null, L.canClose says no, and
|
|
510
|
+
// the page simply draws no close control — the same shape a reader with no
|
|
511
|
+
// right to close sees. Guessing an id from any other response would offer a
|
|
512
|
+
// button the server then refuses.
|
|
513
|
+
meId = (me && me.ok && me.data && me.data.builder && me.data.builder.id) || null;
|
|
514
|
+
myCrafts = crafts;
|
|
515
|
+
|
|
448
516
|
// Re-indexed on every load, not merged into: a request settled elsewhere
|
|
449
517
|
// must disappear from the copy path at the same moment it disappears from
|
|
450
518
|
// the rows, or a stale entry would keep handing a session work that is done.
|
|
@@ -548,8 +616,11 @@
|
|
|
548
616
|
|
|
549
617
|
const r = await api.request('GET', '/mingle');
|
|
550
618
|
const pairs = (r.ok && r.data && r.data.open_pairs) || [];
|
|
551
|
-
|
|
552
|
-
|
|
619
|
+
// Named apart from the module-level `meId` on purpose: mingle asks the mingle
|
|
620
|
+
// route who you are, and two different reads answering to one name in one
|
|
621
|
+
// file is how a shadowed binding becomes a bug nobody can see.
|
|
622
|
+
const mingleMe = (r.ok && r.data && r.data.you) || null;
|
|
623
|
+
paint('#cb-mingle', '#cb-mingle-empty', pairs.map((p) => mingleRow(p, mingleMe)).join(''), pairs.length);
|
|
553
624
|
}
|
|
554
625
|
|
|
555
626
|
async function decideMingle(id, decision) {
|
|
@@ -571,6 +642,49 @@
|
|
|
571
642
|
}
|
|
572
643
|
}
|
|
573
644
|
|
|
645
|
+
// Post a reply. One in flight at a time (the `replying` latch), the same guard
|
|
646
|
+
// `settling` gives the settle actions: a double-submit on an APPEND-ONLY table
|
|
647
|
+
// writes the answer twice and there is no edit or delete to take it back with.
|
|
648
|
+
async function postReply(form) {
|
|
649
|
+
if (replying) return;
|
|
650
|
+
const id = form.getAttribute('data-reply-form');
|
|
651
|
+
const input = form.querySelector('.cb-reply-input');
|
|
652
|
+
const v = L.validateReplyBody(input && input.value);
|
|
653
|
+
if (!v.ok) {
|
|
654
|
+
// Refused here rather than at the server, so an empty reply costs no round
|
|
655
|
+
// trip and the message names the field the reader is looking at.
|
|
656
|
+
toast(v.reason === 'empty' ? 'Write something first.' : `A reply is at most ${L.MAX_REPLY} characters.`);
|
|
657
|
+
if (input) input.focus();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
replying = true;
|
|
661
|
+
const send = form.querySelector('[data-reply-send]');
|
|
662
|
+
if (send) { send.disabled = true; send.textContent = 'Posting…'; }
|
|
663
|
+
try {
|
|
664
|
+
const r = await api.request('POST', `/help-requests/${id}/replies`, { body: v.body });
|
|
665
|
+
if (!r.ok) {
|
|
666
|
+
toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not post that reply.');
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (input) input.value = '';
|
|
670
|
+
toast('Replied.');
|
|
671
|
+
await load();
|
|
672
|
+
} finally {
|
|
673
|
+
replying = false;
|
|
674
|
+
if (send) { send.disabled = false; send.textContent = 'Post reply'; }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function toggleReply(fid, open) {
|
|
679
|
+
const form = document.getElementById(fid);
|
|
680
|
+
if (!form) return;
|
|
681
|
+
form.hidden = !open;
|
|
682
|
+
if (open) {
|
|
683
|
+
const input = form.querySelector('.cb-reply-input');
|
|
684
|
+
if (input) input.focus();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
574
688
|
async function settle(id, status) {
|
|
575
689
|
if (settling) return;
|
|
576
690
|
settling = true;
|
|
@@ -580,7 +694,7 @@
|
|
|
580
694
|
toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not update that request.');
|
|
581
695
|
return;
|
|
582
696
|
}
|
|
583
|
-
toast(status === 'answered' ? '
|
|
697
|
+
toast(status === 'answered' ? 'Closed.' : 'Withdrawn.');
|
|
584
698
|
await load();
|
|
585
699
|
} finally {
|
|
586
700
|
settling = false;
|
|
@@ -597,6 +711,10 @@
|
|
|
597
711
|
if (copyBtn) { ev.preventDefault(); copyAsk(copyBtn); return; }
|
|
598
712
|
const copyAll = closest('#cb-for-me-copy');
|
|
599
713
|
if (copyAll) { ev.preventDefault(); copyQueue(); return; }
|
|
714
|
+
const replyOpen = closest('[data-reply-open]');
|
|
715
|
+
if (replyOpen) { ev.preventDefault(); toggleReply(replyOpen.getAttribute('data-reply-open'), true); return; }
|
|
716
|
+
const replyCancel = closest('[data-reply-cancel]');
|
|
717
|
+
if (replyCancel) { ev.preventDefault(); toggleReply(replyCancel.getAttribute('data-reply-cancel'), false); return; }
|
|
600
718
|
const mingleBtn = closest('[data-mingle]');
|
|
601
719
|
if (mingleBtn) {
|
|
602
720
|
ev.preventDefault();
|
|
@@ -609,6 +727,17 @@
|
|
|
609
727
|
settle(Number(btn.getAttribute('data-id')), btn.getAttribute('data-settle'));
|
|
610
728
|
});
|
|
611
729
|
|
|
730
|
+
// A form, not a click handler on the button: submit is what Enter does in a
|
|
731
|
+
// textarea's form and what a screen reader announces, and a control that only
|
|
732
|
+
// answered a mouse would be a reply surface you could not reach from a
|
|
733
|
+
// keyboard.
|
|
734
|
+
document.addEventListener('submit', (ev) => {
|
|
735
|
+
const form = ev.target && ev.target.closest ? ev.target.closest('[data-reply-form]') : null;
|
|
736
|
+
if (!form) return;
|
|
737
|
+
ev.preventDefault();
|
|
738
|
+
postReply(form);
|
|
739
|
+
});
|
|
740
|
+
|
|
612
741
|
load().catch(() => {
|
|
613
742
|
const sub = $('#cb-sub');
|
|
614
743
|
// V6: a message that stops someone points at the next move in the same breath.
|
|
@@ -48,7 +48,7 @@ async function craftsForBuilder(builderId, deps = {}) {
|
|
|
48
48
|
return [...new Set(list.map(craftForDiscipline).filter(Boolean))];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at';
|
|
51
|
+
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by';
|
|
52
52
|
// The two list reads also NAME the people on a row — who is asking (the fact
|
|
53
53
|
// the collab page owes a helper: "who to answer", task 1003471) and who a
|
|
54
54
|
// by-name ask is addressed to — through two LEFT JOINs on builders. COLS stays
|
|
@@ -228,24 +228,134 @@ async function getHelpRequest(id, deps = {}) {
|
|
|
228
228
|
return rows[0] || null;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
231
|
+
// THE ASKER OR THE ADDRESSEE settles the request (ADR 0330, widening the
|
|
232
|
+
// asker-only rule lifecycle_004 shipped with).
|
|
233
|
+
//
|
|
234
|
+
// Asker-only was coherent while there was nothing to say on the row: the only
|
|
235
|
+
// person who could know an ask had been answered was the person who had gone and
|
|
236
|
+
// got the answer somewhere else. Replies change that — the answer is now ON the
|
|
237
|
+
// ask, and a request addressed to someone who has just answered it in front of
|
|
238
|
+
// everyone and still cannot close it is a surface arguing with itself. So the
|
|
239
|
+
// person a request NAMES may close it too, by id or through a craft they belong
|
|
240
|
+
// to, and `settled_by` records which of them did.
|
|
241
|
+
//
|
|
242
|
+
// WHAT DOES NOT WIDEN: 'withdrawn' stays the ASKER'S ALONE. Withdrawing means "I
|
|
243
|
+
// no longer need this", which is a statement only the person who needed it can
|
|
244
|
+
// make; letting an addressee withdraw would hand them a way to make an ask
|
|
245
|
+
// disappear without ever answering it. An addressee who wants rid of an ask
|
|
246
|
+
// answers it and closes it, in the open, with their name on both.
|
|
247
|
+
//
|
|
248
|
+
// Consent is untouched. Being asked still assigns nobody anything (ADR 0187 §3) —
|
|
249
|
+
// closing an ask is not accepting work, it is ending a question.
|
|
250
|
+
async function setHelpRequestStatus({ id, builderId, status, crafts = [] }, deps = {}) {
|
|
235
251
|
const activePool = deps.pool || pool;
|
|
252
|
+
const safeCrafts = (Array.isArray(crafts) ? crafts : []).filter((c) => HELP_CRAFTS.includes(c));
|
|
253
|
+
const askerOnly = status === 'withdrawn';
|
|
236
254
|
const { rows } = await activePool.query(
|
|
237
255
|
`UPDATE lifecycle_help_requests
|
|
238
|
-
SET status = $3, updated_at = now()
|
|
239
|
-
WHERE id = $1 AND
|
|
256
|
+
SET status = $3, updated_at = now(), settled_by = $2
|
|
257
|
+
WHERE id = $1 AND status = 'open'
|
|
258
|
+
AND (requested_by = $2
|
|
259
|
+
OR ($4::boolean IS FALSE
|
|
260
|
+
AND (needs_builder_id = $2 OR needs_craft = ANY($5::text[]))))
|
|
240
261
|
RETURNING ${COLS}`,
|
|
241
|
-
[id, builderId, status]
|
|
262
|
+
[id, builderId, status, askerOnly, safeCrafts]
|
|
242
263
|
);
|
|
243
264
|
return rows[0] || null;
|
|
244
265
|
}
|
|
245
266
|
|
|
267
|
+
// ---- replies (task 1004216) -------------------------------------------------
|
|
268
|
+
//
|
|
269
|
+
// APPEND-ONLY, and the table's own trigger is the authority — there is deliberately
|
|
270
|
+
// no updateReply and no deleteReply here, and adding one would not work if you
|
|
271
|
+
// tried. A reply is a thing somebody said; the record of what was said at the time
|
|
272
|
+
// is the entire point.
|
|
273
|
+
const MAX_REPLY = 4000;
|
|
274
|
+
const REPLY_COLS = 'r.id, r.help_request_id, r.builder_id, r.body, r.created_at, '
|
|
275
|
+
+ 'b.github_login AS builder_login, b.display_name AS builder_name, b.avatar_url AS builder_avatar_url';
|
|
276
|
+
const REPLY_FROM = 'FROM lifecycle_help_request_replies r LEFT JOIN builders b ON b.id = r.builder_id';
|
|
277
|
+
|
|
278
|
+
// PURE, for the same reason validateHelpRequest is: the route refuses with a named
|
|
279
|
+
// field before a query runs, and every branch is testable without a database.
|
|
280
|
+
function validateReply(input) {
|
|
281
|
+
const errors = [];
|
|
282
|
+
const body = typeof (input || {}).body === 'string' ? input.body.trim() : '';
|
|
283
|
+
if (!body) errors.push({ field: 'body', reason: 'required' });
|
|
284
|
+
else if (body.length > MAX_REPLY) errors.push({ field: 'body', reason: 'too_long' });
|
|
285
|
+
return { ok: errors.length === 0, errors };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function createReply({ helpRequestId, builderId, body }, deps = {}) {
|
|
289
|
+
const activePool = deps.pool || pool;
|
|
290
|
+
const { rows } = await activePool.query(
|
|
291
|
+
`INSERT INTO lifecycle_help_request_replies (help_request_id, builder_id, body)
|
|
292
|
+
VALUES ($1, $2, $3)
|
|
293
|
+
RETURNING id, help_request_id, builder_id, body, created_at`,
|
|
294
|
+
[helpRequestId, builderId, String(body).trim()]
|
|
295
|
+
);
|
|
296
|
+
return rows[0];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Oldest first — the order a conversation is read in, and the opposite of every
|
|
300
|
+
// other list on the Collab page, which sorts by what needs attention. A thread is
|
|
301
|
+
// not a queue.
|
|
302
|
+
async function listReplies(helpRequestId, deps = {}) {
|
|
303
|
+
const activePool = deps.pool || pool;
|
|
304
|
+
const { rows } = await activePool.query(
|
|
305
|
+
`SELECT ${REPLY_COLS} ${REPLY_FROM}
|
|
306
|
+
WHERE r.help_request_id = $1
|
|
307
|
+
ORDER BY r.created_at ASC, r.id ASC
|
|
308
|
+
LIMIT ${MAX_ROWS}`,
|
|
309
|
+
[helpRequestId]
|
|
310
|
+
);
|
|
311
|
+
return rows;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The most replies one thread contributes to a LIST read. A page draws a thread
|
|
315
|
+
// under every row, so the bound that matters is per-ask, not overall: a single
|
|
316
|
+
// global LIMIT would let one busy ask eat the budget and hand the rows below it
|
|
317
|
+
// empty threads, which reads as "nobody answered" — the exact lie this whole
|
|
318
|
+
// surface exists to stop telling.
|
|
319
|
+
const MAX_THREAD_ON_LIST = 20;
|
|
320
|
+
|
|
321
|
+
// Every reply on a set of asks in ONE query, so a page rendering twenty rows makes
|
|
322
|
+
// one round trip rather than twenty. Returns a Map keyed by help_request_id.
|
|
323
|
+
//
|
|
324
|
+
// BOUNDED, like every other read here. listReplies caps at MAX_ROWS; this one
|
|
325
|
+
// backs three list routes on the page's own load path, so an unbounded result set
|
|
326
|
+
// would be the one place a busy project could make the Collab page arrive by the
|
|
327
|
+
// megabyte. The window is per help_request_id, and the ORDER inside it is the
|
|
328
|
+
// same one listReplies uses, so the first N a list shows are the first N the
|
|
329
|
+
// single-thread read shows — never a different N in a different order.
|
|
330
|
+
async function repliesForRequests(ids, deps = {}) {
|
|
331
|
+
const activePool = deps.pool || pool;
|
|
332
|
+
const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(Number).filter(Number.isInteger))];
|
|
333
|
+
if (!wanted.length) return new Map();
|
|
334
|
+
const { rows } = await activePool.query(
|
|
335
|
+
`SELECT * FROM (
|
|
336
|
+
SELECT ${REPLY_COLS},
|
|
337
|
+
ROW_NUMBER() OVER (PARTITION BY r.help_request_id
|
|
338
|
+
ORDER BY r.created_at ASC, r.id ASC) AS thread_pos
|
|
339
|
+
${REPLY_FROM}
|
|
340
|
+
WHERE r.help_request_id = ANY($1::bigint[])
|
|
341
|
+
) t
|
|
342
|
+
WHERE t.thread_pos <= ${MAX_THREAD_ON_LIST}
|
|
343
|
+
ORDER BY t.created_at ASC, t.id ASC`,
|
|
344
|
+
[wanted]
|
|
345
|
+
);
|
|
346
|
+
const out = new Map();
|
|
347
|
+
for (const row of rows) {
|
|
348
|
+
const key = String(row.help_request_id);
|
|
349
|
+
if (!out.has(key)) out.set(key, []);
|
|
350
|
+
out.get(key).push(row);
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
246
355
|
module.exports = {
|
|
247
356
|
HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
|
|
248
357
|
validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
|
|
249
358
|
builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
|
|
250
359
|
countOpenForBuilder, craftMapSql,
|
|
360
|
+
MAX_REPLY, MAX_THREAD_ON_LIST, validateReply, createReply, listReplies, repliesForRequests,
|
|
251
361
|
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
-- lifecycle_011_help_request_replies.sql — an ask can finally be ANSWERED where it
|
|
2
|
+
-- was asked (task 1004216, goal 1000095; ADR 0330, amending ADR 0187 §4a).
|
|
3
|
+
--
|
|
4
|
+
-- THE HOLE THIS FILLS. lifecycle_004 gave a builder a way to ask for help and gave
|
|
5
|
+
-- the ASKER a way to mark their own row answered. It gave the person being asked
|
|
6
|
+
-- nothing at all. So the answer — the thing the whole surface exists to produce —
|
|
7
|
+
-- happened somewhere Bongos cannot see, and the row sat open until its author
|
|
8
|
+
-- happened to remember it. Six asks addressed to the owner were open between four
|
|
9
|
+
-- and thirteen days when this was written, every one of them already answerable.
|
|
10
|
+
--
|
|
11
|
+
-- WHY THIS IS NOT THE SURFACE ADR 0187 RULED OUT. §4a put conversation out of
|
|
12
|
+
-- scope and deferred it to the Agora (task 1002946, goal 1000064) — "this goal
|
|
13
|
+
-- must not quietly reimplement the Agora to get around it". That deferral no
|
|
14
|
+
-- longer has an addressee: task 1002946 is ABANDONED and goal 1000064 is ARCHIVED,
|
|
15
|
+
-- verified 2026-09-23. The surface that was to own replies will not be built.
|
|
16
|
+
--
|
|
17
|
+
-- The restraint survives the reversal, and it lives in the schema rather than in a
|
|
18
|
+
-- comment asking nicely. A reply is APPEND-ONLY: there is no updated_at, no
|
|
19
|
+
-- edited_by, no deleted_at, no parent_reply_id, no reaction table and no mention
|
|
20
|
+
-- table. What you can do is add one, and read them in order. That is an answer
|
|
21
|
+
-- surface; a thing you can edit, nest, react to and address at people is a chat,
|
|
22
|
+
-- and a chat is what §4a declined to build here.
|
|
23
|
+
--
|
|
24
|
+
-- WHY THE FK RESTRICTS RATHER THAN CASCADES, which is the opposite of what it
|
|
25
|
+
-- first said. `ON DELETE CASCADE` reads right — a reply has no meaning without
|
|
26
|
+
-- the ask it answers — but it CONTRADICTS append-only, and Postgres says so out
|
|
27
|
+
-- loud: a cascade is an ordinary `DELETE FROM lifecycle_help_request_replies`,
|
|
28
|
+
-- the trigger below refuses it, and deleting a help request that had ever been
|
|
29
|
+
-- answered failed outright. Caught by running the migration on a real database
|
|
30
|
+
-- rather than by reading it.
|
|
31
|
+
--
|
|
32
|
+
-- Restrict is the honest resolution rather than the lesser evil. Nothing deletes
|
|
33
|
+
-- a help request: it is SETTLED (`status`), and there is no DELETE route for one
|
|
34
|
+
-- anywhere in this module. So the FK forbids a thing that does not happen, and
|
|
35
|
+
-- if it ever must, "you cannot silently destroy answers as a side effect of
|
|
36
|
+
-- removing the question" is the right refusal for an append-only record to give.
|
|
37
|
+
--
|
|
38
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it (ADR 0083
|
|
39
|
+
-- §Decision #5): a new table the lifecycle module owns outright, plus one column
|
|
40
|
+
-- on a table it already owns.
|
|
41
|
+
|
|
42
|
+
BEGIN;
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS lifecycle_help_request_replies (
|
|
45
|
+
id bigserial PRIMARY KEY,
|
|
46
|
+
help_request_id bigint NOT NULL REFERENCES lifecycle_help_requests(id) ON DELETE RESTRICT,
|
|
47
|
+
builder_id bigint NOT NULL REFERENCES builders(id),
|
|
48
|
+
body text NOT NULL,
|
|
49
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
50
|
+
|
|
51
|
+
-- Same shape as what_is_stuck's own check: whitespace is not an answer.
|
|
52
|
+
CONSTRAINT lifecycle_help_request_replies_body_chk
|
|
53
|
+
CHECK (length(btrim(body)) > 0)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- Every read is "the replies on this ask, oldest first" — the order a
|
|
57
|
+
-- conversation is read in, and the order the page renders.
|
|
58
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_request_replies_thread_idx
|
|
59
|
+
ON lifecycle_help_request_replies (help_request_id, created_at);
|
|
60
|
+
|
|
61
|
+
COMMENT ON TABLE lifecycle_help_request_replies IS
|
|
62
|
+
'Answers on a help request, append-only and oldest-first (task 1004216). No edit, no delete, no nesting, no reactions, no mentions — that restraint is what keeps this an answer surface rather than the chat surface ADR 0187 4a declined to build.';
|
|
63
|
+
|
|
64
|
+
-- WHO SETTLED IT. Until now only the asker could, so the answer was never in
|
|
65
|
+
-- question. ADR 0330 lets the ADDRESSEE close an ask too — a request addressed to
|
|
66
|
+
-- someone who has answered it and cannot close it is the defect that widening
|
|
67
|
+
-- fixes — which makes "who closed this" a real question for the first time.
|
|
68
|
+
-- Nullable, and null for every historical row: a settle that predates this column
|
|
69
|
+
-- was the asker's by construction, and backfilling requested_by would invent a
|
|
70
|
+
-- fact rather than record one.
|
|
71
|
+
ALTER TABLE lifecycle_help_requests
|
|
72
|
+
ADD COLUMN IF NOT EXISTS settled_by bigint REFERENCES builders(id);
|
|
73
|
+
|
|
74
|
+
COMMENT ON COLUMN lifecycle_help_requests.settled_by IS
|
|
75
|
+
'The builder who moved this request out of open — the asker or, since ADR 0330, its addressee. NULL on rows settled before the column existed; that absence means "not recorded", never "the asker".';
|
|
76
|
+
|
|
77
|
+
-- The table must be append-only in the DATABASE, not merely in the routes that
|
|
78
|
+
-- happen to exist today. A later route, a migration, or a hand-run UPDATE would
|
|
79
|
+
-- otherwise silently rewrite what somebody said — and the whole value of a reply
|
|
80
|
+
-- as a record is that it is what was said at the time.
|
|
81
|
+
CREATE OR REPLACE FUNCTION lifecycle_help_request_replies_append_only()
|
|
82
|
+
RETURNS trigger AS $$
|
|
83
|
+
BEGIN
|
|
84
|
+
RAISE EXCEPTION
|
|
85
|
+
'lifecycle_help_request_replies is append-only (ADR 0330): a reply cannot be % once written',
|
|
86
|
+
CASE TG_OP WHEN 'UPDATE' THEN 'edited' ELSE 'deleted' END;
|
|
87
|
+
END;
|
|
88
|
+
$$ LANGUAGE plpgsql;
|
|
89
|
+
|
|
90
|
+
DROP TRIGGER IF EXISTS lifecycle_help_request_replies_no_update ON lifecycle_help_request_replies;
|
|
91
|
+
CREATE TRIGGER lifecycle_help_request_replies_no_update
|
|
92
|
+
BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies
|
|
93
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_help_request_replies_append_only();
|
|
94
|
+
|
|
95
|
+
-- Proof the guard is armed, checked here rather than trusted: a migration that
|
|
96
|
+
-- silently failed to attach the trigger would leave the table editable, and every
|
|
97
|
+
-- test of the ROUTES would still pass.
|
|
98
|
+
DO $$
|
|
99
|
+
BEGIN
|
|
100
|
+
IF NOT EXISTS (
|
|
101
|
+
SELECT 1 FROM pg_trigger
|
|
102
|
+
WHERE tgrelid = 'lifecycle_help_request_replies'::regclass
|
|
103
|
+
AND tgname = 'lifecycle_help_request_replies_no_update'
|
|
104
|
+
AND NOT tgisinternal
|
|
105
|
+
) THEN
|
|
106
|
+
RAISE EXCEPTION 'lifecycle_011: the append-only trigger is not attached — the table would accept an UPDATE or DELETE';
|
|
107
|
+
END IF;
|
|
108
|
+
END $$;
|
|
109
|
+
|
|
110
|
+
INSERT INTO schema_migrations (version) VALUES ('lifecycle_011_help_request_replies')
|
|
111
|
+
ON CONFLICT (version) DO NOTHING;
|
|
112
|
+
|
|
113
|
+
COMMIT;
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// read for "I am stuck" (task 1003171 / C01, goal 1000075; criterion
|
|
3
3
|
// collab-ask-for-help).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// board (task 1003942), settle
|
|
7
|
-
// The Collab PAGE is C02 and the matching rules beyond craft
|
|
8
|
-
// surface
|
|
5
|
+
// Seven routes: ask, see what is addressed to me, see my own, see the whole open
|
|
6
|
+
// board (task 1003942), settle it, and — since task 1004216 — REPLY to one and
|
|
7
|
+
// read its replies. The Collab PAGE is C02 and the matching rules beyond craft
|
|
8
|
+
// are its job; this surface stops at "the request is addressed, answerable, and
|
|
9
|
+
// answered".
|
|
9
10
|
//
|
|
10
11
|
// Reaches core only through the doorway (ADR 0083).
|
|
11
12
|
|
|
@@ -17,6 +18,17 @@ const help = require('../help-requests');
|
|
|
17
18
|
|
|
18
19
|
const { asyncHandler, validateOrRespond, parseId } = api;
|
|
19
20
|
|
|
21
|
+
// Hang each row's replies on it in ONE query (task 1004216). The page renders a
|
|
22
|
+
// thread under every ask, and fetching them per row would turn one list read into
|
|
23
|
+
// twenty — the shape that makes a page feel slow for reasons no single request
|
|
24
|
+
// explains. An empty thread is `[]`, never absent: a reader of this response
|
|
25
|
+
// should never have to tell "no replies" apart from "this response does not carry
|
|
26
|
+
// replies".
|
|
27
|
+
async function withReplies(rows) {
|
|
28
|
+
const threads = await help.repliesForRequests(rows.map((r) => r.id));
|
|
29
|
+
return rows.map((r) => ({ ...r, replies: threads.get(String(r.id)) || [] }));
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
module.exports = function buildHelpRequestsRouter() {
|
|
21
33
|
const router = express.Router();
|
|
22
34
|
|
|
@@ -78,14 +90,14 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
78
90
|
const mine = await help.craftsForBuilder(req.builder.id);
|
|
79
91
|
const want = typeof req.query.craft === 'string' ? req.query.craft : null;
|
|
80
92
|
const crafts = want ? mine.filter((c) => c === want) : mine;
|
|
81
|
-
const rows = await help.listOpenForBuilder({ builderId: req.builder.id, crafts });
|
|
93
|
+
const rows = await withReplies(await help.listOpenForBuilder({ builderId: req.builder.id, crafts }));
|
|
82
94
|
return res.json({ help_requests: rows, count: rows.length, crafts });
|
|
83
95
|
}, { errorCode: 'help_request_list_failed' }));
|
|
84
96
|
|
|
85
97
|
// rank: any-builder — your own asks, so you can settle them.
|
|
86
98
|
router.get('/help-requests/mine', api.requireBuilder, asyncHandler('GET /help-requests/mine', async (req, res) => {
|
|
87
99
|
const status = typeof req.query.status === 'string' ? req.query.status : null;
|
|
88
|
-
const rows = await help.listMine({ builderId: req.builder.id, status });
|
|
100
|
+
const rows = await withReplies(await help.listMine({ builderId: req.builder.id, status }));
|
|
89
101
|
return res.json({ help_requests: rows, count: rows.length });
|
|
90
102
|
}, { errorCode: 'help_request_list_failed' }));
|
|
91
103
|
|
|
@@ -94,15 +106,23 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
94
106
|
// addressed inside this project and every reader here is a member; the route
|
|
95
107
|
// takes no craft and no builder from the caller, so there is nothing to widen.
|
|
96
108
|
router.get('/help-requests', api.requireBuilder, asyncHandler('GET /help-requests', async (req, res) => {
|
|
97
|
-
const rows = await help.listOpenAll();
|
|
109
|
+
const rows = await withReplies(await help.listOpenAll());
|
|
98
110
|
return res.json({ help_requests: rows, count: rows.length });
|
|
99
111
|
}, { errorCode: 'help_request_list_failed' }));
|
|
100
112
|
|
|
101
|
-
//
|
|
102
|
-
// 'withdrawn' when
|
|
103
|
-
// the
|
|
113
|
+
// THE ASKER OR THE ADDRESSEE settles the request: 'answered' when it has been
|
|
114
|
+
// dealt with, 'withdrawn' when the asker no longer needs it. ADR 0330 widened
|
|
115
|
+
// 'answered' to the person the request NAMES — once a reply puts the answer on
|
|
116
|
+
// the row itself, an addressee who has answered in the open and still cannot
|
|
117
|
+
// close it is a surface arguing with itself. 'withdrawn' stays the asker's
|
|
118
|
+
// alone: only the person who needed something can say they no longer need it.
|
|
119
|
+
//
|
|
120
|
+
// Nobody is assigned by being asked — closing an ask ends a question, it does
|
|
121
|
+
// not accept work (ADR 0187 §3).
|
|
104
122
|
//
|
|
105
|
-
// rank: any-builder —
|
|
123
|
+
// rank: any-builder — who may settle is enforced in the UPDATE's WHERE, not by
|
|
124
|
+
// rank. The caller's crafts come from THEIR OWN profile, never from the body,
|
|
125
|
+
// for the same reason the addressed read refuses a ?craft= it was handed.
|
|
106
126
|
router.patch('/help-requests/:id', api.requireBuilder, asyncHandler('PATCH /help-requests/:id', async (req, res) => {
|
|
107
127
|
const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
|
|
108
128
|
if (id === null) return;
|
|
@@ -110,18 +130,78 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
110
130
|
status: { required: true, type: 'string', enum: ['answered', 'withdrawn'] },
|
|
111
131
|
})) return;
|
|
112
132
|
|
|
113
|
-
const
|
|
133
|
+
const crafts = await help.craftsForBuilder(req.builder.id);
|
|
134
|
+
const row = await help.setHelpRequestStatus({
|
|
135
|
+
id, builderId: req.builder.id, status: req.body.status, crafts,
|
|
136
|
+
});
|
|
114
137
|
if (!row) {
|
|
115
138
|
// ONE refusal for three cases: absent, someone else's, or already settled.
|
|
116
139
|
// The first version looked the row up to say WHICH, which let a caller
|
|
117
140
|
// enumerate existing ids — and contradicted this very comment (grader, task
|
|
118
141
|
// 1003171). The lookup is gone; the comment and the code now agree.
|
|
119
142
|
return res.fail('help_request_not_settleable', 404, {
|
|
120
|
-
reason: 'no open help request
|
|
143
|
+
reason: 'no open help request with that id that you asked or were asked',
|
|
121
144
|
});
|
|
122
145
|
}
|
|
123
146
|
return res.json({ ok: true, help_request: row });
|
|
124
147
|
}, { errorCode: 'help_request_update_failed' }));
|
|
125
148
|
|
|
149
|
+
// ---- replies (task 1004216, ADR 0330) -------------------------------------
|
|
150
|
+
//
|
|
151
|
+
// THE HALF THIS SURFACE SHIPPED WITHOUT. A builder could ask, and the asker
|
|
152
|
+
// could mark their own row answered; the person being ASKED had no write at all,
|
|
153
|
+
// so the answer happened somewhere Bongos could not see it.
|
|
154
|
+
//
|
|
155
|
+
// ANY BUILDER MAY REPLY, deliberately. The board read (GET /help-requests) is
|
|
156
|
+
// already project-wide, so every member can see every open ask — and a reply
|
|
157
|
+
// says nothing more private than the ask it hangs under. Restricting replies to
|
|
158
|
+
// the addressee would also break the common good case: somebody who is not who
|
|
159
|
+
// you asked turns out to know the answer.
|
|
160
|
+
//
|
|
161
|
+
// APPEND-ONLY, with no edit and no delete route. That is not an omission to fill
|
|
162
|
+
// in later: the table's own trigger refuses an UPDATE or a DELETE, and the
|
|
163
|
+
// restraint is what keeps this an answer surface rather than the chat surface
|
|
164
|
+
// ADR 0187 §4a declined to build here.
|
|
165
|
+
router.post('/help-requests/:id/replies', api.requireBuilder, asyncHandler('POST /help-requests/:id/replies', async (req, res) => {
|
|
166
|
+
const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
|
|
167
|
+
if (id === null) return;
|
|
168
|
+
if (validateOrRespond(req, res, {
|
|
169
|
+
body: { required: true, type: 'string', minLength: 1, maxLength: help.MAX_REPLY },
|
|
170
|
+
})) return;
|
|
171
|
+
|
|
172
|
+
const v = help.validateReply(req.body);
|
|
173
|
+
if (!v.ok) return res.fail('invalid_reply', 400, { errors: v.errors });
|
|
174
|
+
|
|
175
|
+
// Checked rather than left to the FK, so a reply to an id that never existed
|
|
176
|
+
// is a named 404 instead of a Postgres string rendered as a 500.
|
|
177
|
+
const target = await help.getHelpRequest(id);
|
|
178
|
+
if (!target) return res.fail('help_request_not_found', 404, { reason: 'no help request with that id' });
|
|
179
|
+
|
|
180
|
+
const reply = await help.createReply({
|
|
181
|
+
helpRequestId: id, builderId: req.builder.id, body: req.body.body,
|
|
182
|
+
});
|
|
183
|
+
return res.status(201).json({ ok: true, reply });
|
|
184
|
+
}, { errorCode: 'help_request_reply_failed' }));
|
|
185
|
+
|
|
186
|
+
// rank: any-builder, matching the board read this hangs under.
|
|
187
|
+
//
|
|
188
|
+
// AN UNKNOWN ID ANSWERS `[]`, NOT 404, and that asymmetry with the POST above is
|
|
189
|
+
// deliberate rather than an oversight (raised by the grader). This surface has
|
|
190
|
+
// already had one lesson about existence lookups: PATCH used to look a row up to
|
|
191
|
+
// say WHICH of three things was wrong, which let a caller enumerate ids by
|
|
192
|
+
// watching the refusal change. A 404 here would rebuild exactly that oracle on
|
|
193
|
+
// the cheapest possible request — a GET, repeatable, with nothing written.
|
|
194
|
+
//
|
|
195
|
+
// The POST can afford the 404 because it is a WRITE: it costs an attempt, it is
|
|
196
|
+
// rate-limited by being a write, and a reply to a phantom ask has to fail with a
|
|
197
|
+
// reason or fail as an FK 500 that tells the caller nothing useful. A read has no
|
|
198
|
+
// such excuse, and "no replies" is a true answer about an ask that does not exist.
|
|
199
|
+
router.get('/help-requests/:id/replies', api.requireBuilder, asyncHandler('GET /help-requests/:id/replies', async (req, res) => {
|
|
200
|
+
const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
|
|
201
|
+
if (id === null) return;
|
|
202
|
+
const replies = await help.listReplies(id);
|
|
203
|
+
return res.json({ replies, count: replies.length });
|
|
204
|
+
}, { errorCode: 'help_request_reply_list_failed' }));
|
|
205
|
+
|
|
126
206
|
return router;
|
|
127
207
|
};
|