@bongos/core 1.19.1064 → 1.19.1066

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.
Files changed (49) hide show
  1. package/.bongos-core.json +96 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +1 -0
  8. package/docs/api/openapi.json +237 -4
  9. package/docs/api-reference.md +5 -2
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +11 -2
  15. package/docs/page-readings.json +45 -22
  16. package/modules/copy-desk/page-decisions.js +71 -0
  17. package/modules/copy-desk/page-status.js +100 -2
  18. package/modules/copy-desk/pages.js +30 -0
  19. package/modules/copy-desk/routes/copy-desk.js +156 -2
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  21. package/modules/economy/credits.js +77 -0
  22. package/modules/economy/reward-policy.js +7 -1
  23. package/modules/economy/reward.js +4 -0
  24. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  25. package/modules/hall-ui/public/approval-queue.css +286 -0
  26. package/modules/hall-ui/public/approval-queue.js +338 -0
  27. package/modules/hall-ui/public/studio.css +33 -0
  28. package/modules/hall-ui/public/studio.html +18 -1
  29. package/modules/hall-ui/public/studio.js +30 -0
  30. package/modules/hall-ui/public/studio.states.json +198 -0
  31. package/modules/hall-ui/records/approval-queue.md +32 -0
  32. package/modules/lifecycle/db-grade.js +80 -58
  33. package/modules/lifecycle/db-ship.js +14 -5
  34. package/modules/lifecycle/github-pr-close.js +41 -0
  35. package/modules/lifecycle/github-push.js +5 -2
  36. package/modules/lifecycle/lifecycle.js +12 -0
  37. package/modules/lifecycle/page-tweak-approve.js +242 -0
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +12 -0
  42. package/scripts/hall-preview/server.js +18 -0
  43. package/src/module-api.js +1 -1
  44. package/tests/artist_page_credit.mjs +272 -0
  45. package/tests/copy_desk_page_approve.mjs +307 -0
  46. package/tests/fixtures/page-ledger.mjs +41 -1
  47. package/tests/gate_approvals.mjs +93 -0
  48. package/tests/hall_approval_queue.mjs +327 -0
  49. package/tests/hall_audit.mjs +4 -0
@@ -13,7 +13,7 @@
13
13
  "pages": 38,
14
14
  "surfaces": 3,
15
15
  "with_states": 31,
16
- "states": 155
16
+ "states": 161
17
17
  },
18
18
  "surfaces": [
19
19
  {
@@ -842,11 +842,14 @@
842
842
  "modules/hall-ui/public/studio.html",
843
843
  "modules/hall-ui/public/style.css",
844
844
  "modules/hall-ui/public/studio.css",
845
+ "modules/hall-ui/public/approval-queue.css",
845
846
  "modules/hall-ui/public/shell.js",
846
847
  "modules/hall-ui/public/palette.js",
847
848
  "modules/hall-ui/public/dom-utils.js",
848
849
  "modules/hall-ui/public/hall-visual.js",
849
850
  "modules/hall-ui/public/hall-kit.js",
851
+ "modules/hall-ui/public/approval-queue-lib.js",
852
+ "modules/hall-ui/public/approval-queue.js",
850
853
  "modules/hall-ui/public/studio.js"
851
854
  ],
852
855
  "states_files": [
@@ -856,7 +859,13 @@
856
859
  "focus",
857
860
  "out",
858
861
  "full",
859
- "full-back"
862
+ "full-back",
863
+ "approvals",
864
+ "approvals-before",
865
+ "approvals-phone",
866
+ "approvals-note",
867
+ "approved",
868
+ "sent-back"
860
869
  ]
861
870
  },
862
871
  {
@@ -5,7 +5,7 @@
5
5
  "adr": "0341 D3",
6
6
  "reread": "node scripts/gds/page-reader.js --stale",
7
7
  "how": ["Generated by node scripts/gds/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
8
- "counts": {"pages":38,"lines":3636,"placed":1131,"shared":247,"unplaced":2258,"omitted_data":2010},
8
+ "counts": {"pages":38,"lines":3659,"placed":1139,"shared":247,"unplaced":2273,"omitted_data":2015},
9
9
  "pages": [
10
10
  {
11
11
  "id": "builders:atlas",
@@ -2650,35 +2650,58 @@
2650
2650
  "id": "builders:studio",
2651
2651
  "surface": "builders",
2652
2652
  "title": "Studio",
2653
- "states_read": ["focus","out","full","full-back"],
2654
- "files_hash": "2bac3a520cca3414",
2655
- "reading_hash": "8f0ed70c6bd4d60e",
2656
- "counts": {"lines":22,"placed":15,"shared":5,"unplaced":2},
2657
- "omitted": {"data":2},
2653
+ "states_read": ["focus","out","full","full-back","approvals","approvals-before","approvals-phone","approvals-note","approved","sent-back"],
2654
+ "files_hash": "e1041dfa74aca8dd",
2655
+ "reading_hash": "e52bb7163191d85a",
2656
+ "counts": {"lines":45,"placed":23,"shared":5,"unplaced":17},
2657
+ "omitted": {"data":7},
2658
2658
  "notes": [],
2659
2659
  "lines": [
2660
2660
  {"key":"L0001","section":"skip link","text":"Skip to content","placement":"shared","file":"modules/hall-ui/public/shell.js","line":776,"string_id":"87cee78ca134"},
2661
2661
  {"key":"L0002","section":"top bar","text":"Studio","placement":"shared"},
2662
2662
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":496,"string_id":"eba610d25523"},
2663
2663
  {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
2664
- {"key":"L0005","section":"page top","text":"artist mode","placement":"placed","file":"modules/hall-ui/public/studio.html","line":42,"string_id":"520853131591"},
2664
+ {"key":"L0005","section":"page top","text":"artist mode","placement":"placed","file":"modules/hall-ui/public/studio.html","line":43,"string_id":"520853131591"},
2665
2665
  {"key":"L0006","section":"Good morning, {…}","text":"Good morning, {…}","placement":"unplaced"},
2666
2666
  {"key":"L0007","section":"Good morning, {…}","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9"},
2667
- {"key":"L0008","section":"Good morning, {…}","text":"to tend now · {…}","placement":"placed","file":"modules/hall-ui/public/studio.js","line":204,"string_id":"1b88ce51fd84"},
2668
- {"key":"L0009","section":"The copy and visuals that {…} in task #1899","text":"The copy and visuals that {…} in task #1899","placement":"unplaced"},
2669
- {"key":"L0010","section":"The copy and visuals that {…} in task #1899","text":"It shipped and it is live. Say what you see — your review coaches the grader and never blocks what shipped.","placement":"placed","file":"modules/hall-ui/public/studio.js","line":202,"string_id":"a28aa368453f"},
2670
- {"key":"L0011","section":"The copy and visuals that {…} in task #1899","text":"What you saw","placement":"placed","file":"modules/hall-ui/public/studio.js","line":162,"string_id":"1d5bfd09068c"},
2671
- {"key":"L0012","section":"The copy and visuals that {…} in task #1899","text":"— your words, in your voice. Needed for a note or a follow-up.","placement":"placed","file":"modules/hall-ui/public/studio.js","line":162,"string_id":"d3afa6d6dd9e"},
2672
- {"key":"L0013","section":"The copy and visuals that {…} in task #1899","text":"The headline lands, but the sub-line reads like a brochure.","placement":"placed","file":"modules/hall-ui/public/studio.js","line":162,"string_id":"027e9420daa2","attr":"placeholder"},
2673
- {"key":"L0014","section":"The copy and visuals that {…} in task #1899","text":"Looks good","placement":"placed","file":"modules/hall-ui/public/studio.js","line":162,"string_id":"73677b6839d7"},
2674
- {"key":"L0015","section":"The copy and visuals that {…} in task #1899","text":"Note it","placement":"placed","file":"modules/hall-ui/public/studio.js","line":162,"string_id":"73c72974f4f9"},
2675
- {"key":"L0016","section":"The copy and visuals that {…} in task #1899","text":"Needs work","placement":"placed","file":"modules/hall-ui/public/studio.js","line":162,"string_id":"3970d299c95a"},
2676
- {"key":"L0017","section":"The copy and visuals that {…} in task #1899","text":"Open this review","placement":"placed","file":"modules/hall-ui/public/studio.js","line":204,"string_id":"1cb80282de09"},
2677
- {"key":"L0018","section":"The copy and visuals that {…} in task #1899","text":"Let it be for now","placement":"placed","file":"modules/hall-ui/public/studio.js","line":204,"string_id":"d6a78b9e5368"},
2678
- {"key":"L0019","section":"The copy and visuals that {…} in task #1899","text":"and {…} more waiting{…}","placement":"placed","file":"modules/hall-ui/public/studio.js","line":277,"string_id":"d86265f99668"},
2679
- {"key":"L0020","section":"The copy and visuals that {…} in task #1899","text":"The copy desk — every flagged string","placement":"placed","file":"modules/hall-ui/public/studio.html","line":63,"string_id":"2dc061096c48"},
2680
- {"key":"L0021","section":"The copy and visuals that {…} in task #1899","text":"The task board — all work","placement":"placed","file":"modules/hall-ui/public/studio.html","line":64,"string_id":"599fd0e82d1c"},
2681
- {"key":"L0022","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":473,"string_id":"1883ad7f07fc"}
2667
+ {"key":"L0008","section":"Good morning, {…}","text":"Approval queue","placement":"placed","file":"modules/hall-ui/public/studio.html","line":55,"string_id":"20c98a4b630f"},
2668
+ {"key":"L0009","section":"Good morning, {…}","text":"1 page applied, waiting for you","placement":"unplaced","state":"approved"},
2669
+ {"key":"L0010","section":"Good morning, {…}","text":"2 pages applied, waiting for you","placement":"unplaced"},
2670
+ {"key":"L0011","section":"Good morning, {…}","text":"to tend now · {…}","placement":"placed","file":"modules/hall-ui/public/studio.js","line":215,"string_id":"1b88ce51fd84"},
2671
+ {"key":"L0012","section":"The copy and visuals that {…} in task #1899","text":"The copy and visuals that {…} in task #1899","placement":"unplaced"},
2672
+ {"key":"L0013","section":"The copy and visuals that {…} in task #1899","text":"It shipped and it is live. Say what you see — your review coaches the grader and never blocks what shipped.","placement":"placed","file":"modules/hall-ui/public/studio.js","line":213,"string_id":"a28aa368453f"},
2673
+ {"key":"L0014","section":"The copy and visuals that {…} in task #1899","text":"What you saw","placement":"placed","file":"modules/hall-ui/public/studio.js","line":173,"string_id":"1d5bfd09068c"},
2674
+ {"key":"L0015","section":"The copy and visuals that {…} in task #1899","text":"— your words, in your voice. Needed for a note or a follow-up.","placement":"placed","file":"modules/hall-ui/public/studio.js","line":173,"string_id":"d3afa6d6dd9e"},
2675
+ {"key":"L0016","section":"The copy and visuals that {…} in task #1899","text":"The headline lands, but the sub-line reads like a brochure.","placement":"placed","file":"modules/hall-ui/public/studio.js","line":173,"string_id":"027e9420daa2","attr":"placeholder"},
2676
+ {"key":"L0017","section":"The copy and visuals that {…} in task #1899","text":"Looks good","placement":"placed","file":"modules/hall-ui/public/studio.js","line":173,"string_id":"73677b6839d7"},
2677
+ {"key":"L0018","section":"The copy and visuals that {…} in task #1899","text":"Note it","placement":"placed","file":"modules/hall-ui/public/studio.js","line":173,"string_id":"73c72974f4f9"},
2678
+ {"key":"L0019","section":"The copy and visuals that {…} in task #1899","text":"Needs work","placement":"placed","file":"modules/hall-ui/public/studio.js","line":173,"string_id":"3970d299c95a"},
2679
+ {"key":"L0020","section":"The copy and visuals that {…} in task #1899","text":"Open this review","placement":"placed","file":"modules/hall-ui/public/studio.js","line":215,"string_id":"1cb80282de09"},
2680
+ {"key":"L0021","section":"The copy and visuals that {…} in task #1899","text":"Let it be for now","placement":"placed","file":"modules/hall-ui/public/studio.js","line":215,"string_id":"d6a78b9e5368"},
2681
+ {"key":"L0022","section":"The copy and visuals that {…} in task #1899","text":"and {…} more waiting{…}","placement":"placed","file":"modules/hall-ui/public/studio.js","line":288,"string_id":"d86265f99668"},
2682
+ {"key":"L0023","section":"The copy and visuals that {…} in task #1899","text":"The copy desk — every flagged string","placement":"placed","file":"modules/hall-ui/public/studio.html","line":76,"string_id":"2dc061096c48"},
2683
+ {"key":"L0024","section":"The copy and visuals that {…} in task #1899","text":"The task board — all work","placement":"placed","file":"modules/hall-ui/public/studio.html","line":77,"string_id":"599fd0e82d1c"},
2684
+ {"key":"L0025","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":473,"string_id":"1883ad7f07fc"},
2685
+ {"key":"L0026","section":"The copy and visuals that {…} in task #1899","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9","state":"approvals"},
2686
+ {"key":"L0027","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"Home","placement":"unplaced","state":"approvals"},
2687
+ {"key":"L0028","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"· {…} · 3 lines rewritten, applied · 1 not applied","placement":"unplaced","state":"approvals"},
2688
+ {"key":"L0029","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"before","placement":"unplaced","state":"approvals"},
2689
+ {"key":"L0030","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"after","placement":"unplaced","state":"approvals"},
2690
+ {"key":"L0031","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"close","placement":"unplaced","state":"approvals"},
2691
+ {"key":"L0032","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"desktop","placement":"unplaced","state":"approvals"},
2692
+ {"key":"L0033","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"phone","placement":"unplaced","state":"approvals"},
2693
+ {"key":"L0034","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"light","placement":"unplaced","state":"approvals"},
2694
+ {"key":"L0035","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"dark","placement":"unplaced","state":"approvals"},
2695
+ {"key":"L0036","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"{…} project{…}","placement":"placed","file":"modules/hall-ui/public/fleet.js","line":39,"string_id":"771bef3d31b5","state":"approvals-before","occurrences":12},
2696
+ {"key":"L0037","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"not applied: the page no longer shows it","placement":"unplaced","state":"approvals"},
2697
+ {"key":"L0038","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"Sent back with your note. It comes back to the queue once it is re-applied.","placement":"unplaced","state":"sent-back"},
2698
+ {"key":"L0039","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.","placement":"unplaced","state":"approved"},
2699
+ {"key":"L0040","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"open {…}","placement":"placed","file":"modules/hall-ui/public/approval-queue.js","line":156,"string_id":"9a803563c948","state":"approved"},
2700
+ {"key":"L0041","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"approve and ship","placement":"placed","file":"modules/hall-ui/public/approval-queue.js","line":165,"string_id":"ac37199ccdbb","state":"approvals"},
2701
+ {"key":"L0042","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"The hero line is too long on a phone.","placement":"placed","file":"modules/hall-ui/public/approval-queue.js","line":167,"string_id":"f08587a38a2a","attr":"placeholder","state":"approvals-note"},
2702
+ {"key":"L0043","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"send","placement":"unplaced","state":"approvals-note"},
2703
+ {"key":"L0044","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"send it back","placement":"placed","file":"modules/hall-ui/public/approval-queue.js","line":172,"string_id":"92775a3d8d0d","state":"approvals"},
2704
+ {"key":"L0045","section":"Home · {…} · 3 lines rewritten, applied · 1 not applied","text":"highlighted: the words you changed","placement":"placed","file":"modules/hall-ui/public/approval-queue.js","line":161,"string_id":"c2267f78f931","state":"approvals"}
2682
2705
  ]
2683
2706
  },
2684
2707
  {
@@ -0,0 +1,71 @@
1
+ // modules/copy-desk/page-decisions.js — the Approval queue's route helpers (task
2
+ // 1004323 / BV2.TW12, ADR 0341 D7): which round a decision is about, the port's
3
+ // refusals as the panel shows them, and the next page after a decision. The two
4
+ // routes that use them are in routes/copy-desk.js (POST /copy-desk/pages/:pageId/
5
+ // approve and /send-back); these sit apart only because that file is at the
6
+ // 1,500-line ratchet. They answer through `res` and read through the lifecycle
7
+ // port the route hands them; nothing here writes a row, a file or a string.
8
+
9
+ 'use strict';
10
+
11
+ const pageStatus = require('./page-status');
12
+
13
+ // The page's round that is waiting for its artist, or the named refusal.
14
+ // `task_id` in the body, when sent, pins the round the artist looked at: a
15
+ // round sent back and re-applied since is another set of renders.
16
+ async function waitingRoundOrFail(res, lifecycle, pageId, body, openRoundOf) {
17
+ const rounds = await lifecycle.listPageTweakTasks({ pageId });
18
+ const round = openRoundOf(rounds, pageId);
19
+ const state = round ? pageStatus.roundState(round) : null;
20
+ if (!round || state !== 'waiting_for_artist') {
21
+ res.fail('not_waiting', 409, {
22
+ page_id: pageId,
23
+ round: round ? { task_id: String(round.id), status: round.status, state } : null,
24
+ what_this_means: 'This page is not waiting for approval. Only a page /tweak has applied, and whose grade passed, can be approved or sent back.',
25
+ });
26
+ return null;
27
+ }
28
+ if (body.task_id != null && String(body.task_id) !== String(round.id)) {
29
+ res.fail('round_moved', 409, {
30
+ page_id: pageId,
31
+ round: { task_id: String(round.id) },
32
+ what_this_means: 'The page waiting now is a different round from the one you looked at. Open the Approval queue again to see its renders; nothing was decided.',
33
+ });
34
+ return null;
35
+ }
36
+ return { round, rounds };
37
+ }
38
+
39
+ // The port's refusals, as the panel shows them.
40
+ function refuseDecision(res, pageId, r) {
41
+ switch (r.outcome) {
42
+ case 'not_an_artist':
43
+ return res.fail('not_an_artist', 403, {
44
+ page_id: pageId,
45
+ what_this_means: 'Only the artist who wrote this page, or another artist, can approve it or send it back. The project owner can still land it through an override request on the task.',
46
+ });
47
+ case 'not_waiting':
48
+ case 'wrong_source':
49
+ case 'not_found':
50
+ return res.fail('not_waiting', 409, {
51
+ page_id: pageId,
52
+ what_this_means: 'This page is no longer waiting for approval (it was decided a moment ago, or it is being applied again). Nothing was changed.',
53
+ });
54
+ case 'builder_inactive':
55
+ return res.fail('builder_inactive', 403);
56
+ case 'refused':
57
+ return res.fail((r.refusal && r.refusal.code) || 'send_back_refused', 400, (r.refusal && r.refusal.detail) || {});
58
+ default:
59
+ throw new Error(`page decision: unexpected outcome ${r.outcome}`);
60
+ }
61
+ }
62
+
63
+ // What comes after a decision: the next page in the queue, re-read, so the
64
+ // panel names it ("Next in the queue: Contact").
65
+ async function nextWaiting(lifecycle, invData, builderId, crafts) {
66
+ const q = pageStatus.composeApprovals({ inventory: invData, tasks: await lifecycle.listPageTweakTasks(), builderId, crafts });
67
+ const n = q.queue[0] || null;
68
+ return { count: q.count, page: n ? { page_id: n.page_id, title: n.title, path: n.path, task_id: n.task_id } : null };
69
+ }
70
+
71
+ module.exports = { waitingRoundOrFail, refuseDecision, nextWaiting };
@@ -25,6 +25,8 @@
25
25
  // tweaked, and this week's gain.
26
26
  // NEXT the recommendation (TW07): the page to tweak next, its why,
27
27
  // and the next few for "pick another" (composeNext, below).
28
+ // APPROVALS the Approval queue (TW12, task 1004323): every round waiting
29
+ // for its artist, "1 of 2" (composeApprovals, below).
28
30
  //
29
31
  // Pure: no fs, no DB, no clock (the caller passes `now`). The route owns the I/O.
30
32
 
@@ -34,8 +36,10 @@ const pages = require('./pages');
34
36
 
35
37
  const TERMINAL = new Set(['shipped', 'abandoned']);
36
38
 
37
- // The credit reason ADR 0341 D10 books an approved page under. TW13 writes it;
38
- // until then no row carries it and every tally reads 0, which is the truth.
39
+ // The credit reason ADR 0341 D10 books an approved page under. The economy
40
+ // module writes it at the land (credits.js awardArtistPageCredit, task 1004324,
41
+ // its ARTIST_PAGE_REASON_PREFIX); restated here because a module cannot require
42
+ // another, and tests/artist_page_credit.mjs holds the two strings together.
39
43
  const TALLY_REASON_PREFIX = 'tweak.page_approved:';
40
44
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
41
45
 
@@ -573,7 +577,101 @@ function composeEditor({ page, readingPage, round, builderId }) {
573
577
  };
574
578
  }
575
579
 
580
+ // ---------------------------------------------------------------------------
581
+ // The Approval queue (task 1004323 / BV2.TW12, ADR 0341 D7): every round
582
+ // WAITING FOR ITS ARTIST, oldest applied first, with what the panel shows for
583
+ // each: the page, the lines the batch rewrote (before and after, and the ones
584
+ // the applier refused, by name), what the page will read once approved, and
585
+ // where its renders are. The renders themselves are the applied branch's
586
+ // images, read from GET /tasks/:id/visuals; nothing here builds a page from the
587
+ // batch (ADR 0341 builder pick 9).
588
+ //
589
+ // `crafts` is the caller's crafts (the lifecycle port's builderCrafts): an
590
+ // artist may decide any round, and anyone may decide a round they authored.
591
+ // The routes decide again; this only keeps the panel from offering a verb its
592
+ // route would refuse.
593
+ // ---------------------------------------------------------------------------
594
+
595
+ function appliedAtOf(round) {
596
+ const a = pages.parseAppliedBlock(round.description);
597
+ return a.ok ? iso(a.applied.applied_at) : null;
598
+ }
599
+
600
+ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
601
+ const batch = pages.parseBatchBlock(round.description);
602
+ const applied = pages.parseAppliedBlock(round.description);
603
+ const refusedBy = new Map();
604
+ if (applied.ok) for (const r of applied.applied.refused || []) if (r && r.key) refusedBy.set(String(r.key), String(r.code || 'refused'));
605
+ const lines = batch.ok ? batch.batch.lines.map((l) => ({
606
+ key: l.key,
607
+ section: l.section == null ? null : l.section,
608
+ before: l.before,
609
+ after: l.after,
610
+ placement: l.placement || null,
611
+ refused: refusedBy.get(String(l.key)) || null,
612
+ })) : [];
613
+ const shipped = (rounds || []).filter((r) => r.status === 'shipped');
614
+ const now = countsFor(rounds);
615
+ const after = countsFor([...shipped, { ...round, status: 'shipped' }]);
616
+ const author = person(round.artist_id, round.artist_login);
617
+ return {
618
+ page_id: pageId,
619
+ surface: page ? page.surface : String(pageId).split(':')[0],
620
+ surface_word: surfaceWord(page ? page.surface : String(pageId).split(':')[0]),
621
+ title: page ? (page.title || page.id) : pageId,
622
+ path: page ? page.path || null : null,
623
+ task_id: String(round.id),
624
+ round: round.round,
625
+ status: round.status,
626
+ state: roundState(round),
627
+ applied_at: appliedAtOf(round),
628
+ line_count: lines.length,
629
+ applied_count: lines.filter((l) => !l.refused).length,
630
+ refused_count: lines.filter((l) => l.refused).length,
631
+ lines,
632
+ ...(batch.ok ? {} : { batch_unreadable: batch.code }),
633
+ author,
634
+ by_you: !!author && String(author.id) === String(builderId),
635
+ can_decide: isArtist || (!!author && String(author.id) === String(builderId)),
636
+ status_label: now.status_label,
637
+ status_after_approval: after.status_label,
638
+ sent_back_count: pages.blockPresence(round.description).sent_back_count,
639
+ visuals: { method: 'GET', path: `/tasks/${round.id}/visuals` },
640
+ approve: { method: 'POST', path: `/copy-desk/pages/${pageId}/approve` },
641
+ send_back: { method: 'POST', path: `/copy-desk/pages/${pageId}/send-back` },
642
+ };
643
+ }
644
+
645
+ // GET /copy-desk/approvals — the queue, "1 of 2".
646
+ function composeApprovals({ inventory, tasks, builderId, crafts }) {
647
+ const byPage = indexRounds(tasks);
648
+ const invById = new Map(((inventory && inventory.pages) || []).filter((p) => p && p.id).map((p) => [p.id, p]));
649
+ const isArtist = (crafts || []).includes('artist');
650
+ const queue = [];
651
+ for (const [pageId, rounds] of byPage) {
652
+ const open = rounds.filter((r) => !TERMINAL.has(r.status));
653
+ const round = open.length ? open[open.length - 1] : null;
654
+ if (!round || roundState(round) !== 'waiting_for_artist') continue;
655
+ queue.push(approvalFor({ page: invById.get(pageId) || null, pageId, rounds, round, builderId, isArtist }));
656
+ }
657
+ // Oldest applied first, the order they reached the artist; a round with no
658
+ // readable applied block sorts by its task id after the dated ones.
659
+ queue.sort((a, b) => {
660
+ const at = a.applied_at ? Date.parse(a.applied_at) : Infinity;
661
+ const bt = b.applied_at ? Date.parse(b.applied_at) : Infinity;
662
+ return at - bt || Number(a.task_id) - Number(b.task_id);
663
+ });
664
+ queue.forEach((q, i) => { q.position = i + 1; q.of = queue.length; });
665
+ return {
666
+ builder_id: String(builderId),
667
+ is_artist: isArtist,
668
+ count: queue.length,
669
+ queue,
670
+ };
671
+ }
672
+
576
673
  module.exports = {
674
+ composeApprovals,
577
675
  composeEditor,
578
676
  TALLY_REASON_PREFIX,
579
677
  NEXT_ALTERNATIVES,
@@ -420,6 +420,32 @@ function unplacedTaskBody(page, line, roundTaskId) {
420
420
  ].join('\n');
421
421
  }
422
422
 
423
+ // ---------------------------------------------------------------------------
424
+ // The send-back (task 1004323 / BV2.TW12, ADR 0341 D4, D7).
425
+ //
426
+ // The artist sends an applied page back with a one-line note of at least ten
427
+ // characters (the flag's own reason floor). Each send-back APPENDS its block,
428
+ // never replaces one, so a round sent back twice says so twice, and the
429
+ // round-state derivation reads the latest `at` (blockPresence above).
430
+ // ---------------------------------------------------------------------------
431
+
432
+ const MIN_NOTE_CHARS = 10;
433
+ const MAX_NOTE_CHARS = 500;
434
+
435
+ // The note, tidied (one line) and measured, or a named refusal.
436
+ function normalizeSentBackNote(raw) {
437
+ const note = tidy(raw);
438
+ if (note.length < MIN_NOTE_CHARS) return fail('note_too_short', { min: MIN_NOTE_CHARS, length: note.length });
439
+ if (note.length > MAX_NOTE_CHARS) return fail('note_too_long', { max: MAX_NOTE_CHARS, length: note.length });
440
+ return { ok: true, note };
441
+ }
442
+
443
+ // The description with one more sent-back block at its end.
444
+ function appendSentBack(description, { at, by, note }) {
445
+ const src = String(description || '');
446
+ return { ok: true, description: src.trimEnd() + (src.trim() ? '\n\n' : '') + composeSentBackBlock({ at, by, note }) };
447
+ }
448
+
423
449
  module.exports = {
424
450
  SCHEMA,
425
451
  SOURCE,
@@ -438,6 +464,10 @@ module.exports = {
438
464
  parseAppliedBlock,
439
465
  composeSentBackBlock,
440
466
  parseSentBackBlocks,
467
+ MIN_NOTE_CHARS,
468
+ MAX_NOTE_CHARS,
469
+ normalizeSentBackNote,
470
+ appendSentBack,
441
471
  blockPresence,
442
472
  roundOpenForWriting,
443
473
  MAX_LINE_CHARS,
@@ -18,6 +18,9 @@
18
18
  // 1004320) the Word round trip, GET and POST /copy-desk/pages/:pageId/draft.docx:
19
19
  // the upload lands in the round's DRAFT, through the autosave's own port call.
20
20
  // TW10 (task 1004321) added the editor's read, GET /copy-desk/pages/:pageId/draft.
21
+ // TW12 (task 1004323) added the Approval queue: GET /copy-desk/approvals and the
22
+ // two decisions, POST /copy-desk/pages/:pageId/approve and /send-back, which
23
+ // move the round TASK through the lifecycle port and write nothing here.
21
24
  //
22
25
  // THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
23
26
  //
@@ -58,6 +61,7 @@ const pageData = require('../page-data');
58
61
  const pageStatus = require('../page-status');
59
62
  const pages = require('../pages');
60
63
  const docx = require('../docx');
64
+ const { waitingRoundOrFail, refuseDecision, nextWaiting } = require('../page-decisions');
61
65
 
62
66
  const { validateOrRespond } = api;
63
67
 
@@ -118,6 +122,18 @@ const DRAFT_BODY_SCHEMA = Object.freeze({
118
122
  });
119
123
  // The submit takes nothing: it freezes the draft already on the task.
120
124
  const SUBMIT_BODY_SCHEMA = Object.freeze({});
125
+ // The Approval queue's two decisions (task 1004323 / TW12). Who decides is the
126
+ // session and which page is the path; `task_id` only pins the round the artist
127
+ // looked at, and is checked against the page's waiting round, never trusted.
128
+ // The note is the one piece of text a decision carries, and it is a judgement
129
+ // for the next applier, never wording for the page.
130
+ const APPROVE_BODY_SCHEMA = Object.freeze({
131
+ task_id: { type: 'string', maxLength: 20, minLength: 1 },
132
+ });
133
+ const SEND_BACK_BODY_SCHEMA = Object.freeze({
134
+ note: { required: true, type: 'string', maxLength: pages.MAX_NOTE_CHARS * 2, minLength: 1 },
135
+ task_id: { type: 'string', maxLength: 20, minLength: 1 },
136
+ });
121
137
 
122
138
  // The refusals of pages.resolveDraft / freezeBatch, as HTTP statuses.
123
139
  const PAGE_REFUSAL_STATUS = Object.freeze({
@@ -345,8 +361,9 @@ function pageReadsOrFail(res) {
345
361
  }
346
362
 
347
363
  // Every `tweak.page_approved:` credit row the caller has, through the reward
348
- // port (credit_log is economy's table). No reward port, or no rows yet (TW13
349
- // has not built the payout), reads as none: a tally of 0 is the truth then.
364
+ // port (credit_log is economy's table). No reward port, or no approved page
365
+ // landed yet (economy books one row per landed round, task 1004324), reads as
366
+ // none: a tally of 0 is the truth then.
350
367
  //
351
368
  // ONE bounded query. An approved page books one row, so the cap is thousands of
352
369
  // pages for one artist; past it the tally says `truncated` (creditLedger's
@@ -1338,6 +1355,143 @@ module.exports = function buildCopyDeskRouter() {
1338
1355
  }
1339
1356
  });
1340
1357
 
1358
+ // -------------------------------------------------------------------------
1359
+ // The Approval queue (task 1004323 / BV2.TW12, ADR 0341 D7 and D11).
1360
+ //
1361
+ // A passed page tweak WAITS at completed for its artist (page-tweak-hold.js).
1362
+ // The artist approves it (it lands) or sends it back with a note (the /tweak
1363
+ // queue), both through the lifecycle port. WHO DECIDES: the author or any
1364
+ // artist, checked by the port; the owner's escape hatch is the override
1365
+ // request. Anyone may READ the queue. Helpers: ../page-decisions.js.
1366
+ // -------------------------------------------------------------------------
1367
+
1368
+ // GET /copy-desk/approvals — every round waiting for its artist, oldest
1369
+ // applied first, with its position ("1 of 2"), the lines it rewrote (and the
1370
+ // ones the applier refused), and where its renders are. Any signed-in
1371
+ // builder; `can_decide` says whether the caller may approve or send back.
1372
+ router.get('/copy-desk/approvals', api.requireBuilder, async (req, res) => {
1373
+ try {
1374
+ const inv = inventoryOrFail(res);
1375
+ if (!inv) return;
1376
+ const lifecycle = pageReadsOrFail(res);
1377
+ if (!lifecycle) return;
1378
+ const [tasks, crafts] = await Promise.all([
1379
+ lifecycle.listPageTweakTasks(),
1380
+ typeof lifecycle.builderCrafts === 'function' ? lifecycle.builderCrafts(req.builder.id) : Promise.resolve([]),
1381
+ ]);
1382
+ const out = pageStatus.composeApprovals({ inventory: inv.data, tasks, builderId: req.builder.id, crafts });
1383
+ res.set('Cache-Control', 'no-store');
1384
+ res.json({ ok: true, ...out });
1385
+ } catch (err) {
1386
+ log.error({ err }, 'GET /copy-desk/approvals failed');
1387
+ if (!res.headersSent) res.fail('copy_approvals_failed', 500);
1388
+ }
1389
+ });
1390
+
1391
+ function decisionPortOrFail(res, name) {
1392
+ const lifecycle = pageReadsOrFail(res);
1393
+ if (!lifecycle) return null;
1394
+ if (typeof lifecycle[name] !== 'function') {
1395
+ res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot move a page tweak on from the Approval queue. Nothing was decided.' });
1396
+ return null;
1397
+ }
1398
+ return lifecycle;
1399
+ }
1400
+
1401
+ // -------------------------------------------------------------------------
1402
+ // POST /copy-desk/pages/:pageId/approve — the held pass goes on to land.
1403
+ // Body: { task_id? } (the round the artist looked at). The port promotes
1404
+ // completed -> confirmed with the rewards the hold skipped and nudges the
1405
+ // merge; the reconciler lands it otherwise. The count goes up when it ships.
1406
+ // 200 approved · 409 not_waiting · 409 round_moved · 403 not_an_artist
1407
+ // -------------------------------------------------------------------------
1408
+ router.post('/copy-desk/pages/:pageId/approve', api.requireBuilder, async (req, res) => {
1409
+ try {
1410
+ if (validateOrRespond(req, res, APPROVE_BODY_SCHEMA)) return;
1411
+ const found = pageOrFail(req, res);
1412
+ if (!found) return;
1413
+ const { pageId, inv } = found;
1414
+ const lifecycle = decisionPortOrFail(res, 'approveHeldTweak');
1415
+ if (!lifecycle) return;
1416
+ const waiting = await waitingRoundOrFail(res, lifecycle, pageId, req.body || {}, openRoundOf);
1417
+ if (!waiting) return;
1418
+ const { round } = waiting;
1419
+ // What the page will read once this round ships, from ALL its rounds, read
1420
+ // before the approve moves this one on.
1421
+ const before = pageStatus.composeApprovals({ inventory: inv.data, tasks: waiting.rounds, builderId: req.builder.id, crafts: [] }).queue[0] || null;
1422
+ const r = await lifecycle.approveHeldTweak({ taskId: round.id, builderId: req.builder.id });
1423
+ if (r.outcome !== 'approved') return refuseDecision(res, pageId, r);
1424
+ const crafts = typeof lifecycle.builderCrafts === 'function' ? await lifecycle.builderCrafts(req.builder.id) : [];
1425
+ return res.json({
1426
+ ok: true,
1427
+ outcome: 'approved',
1428
+ page_id: pageId,
1429
+ round: { task_id: String(r.task.id), state: 'landing', status: 'confirmed' },
1430
+ status_after: before ? before.status_after_approval : null,
1431
+ land: r.land || null,
1432
+ next: await nextWaiting(lifecycle, inv.data, req.builder.id, crafts),
1433
+ });
1434
+ } catch (err) {
1435
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/approve failed');
1436
+ if (!res.headersSent) res.fail('copy_page_approve_failed', 500);
1437
+ }
1438
+ });
1439
+
1440
+ // -------------------------------------------------------------------------
1441
+ // POST /copy-desk/pages/:pageId/send-back — back to the /tweak queue with a
1442
+ // note (ADR 0341 D7).
1443
+ //
1444
+ // Body: { note, task_id? }. The note is one line of at least 10 characters
1445
+ // (pages.normalizeSentBackNote). The port appends a page-tweak-sent-back
1446
+ // block { at, by, note }, releases any open claim, drops the round's grade
1447
+ // row, returns the task to ready and closes the unmerged PR, so the round
1448
+ // reads `submitted` again.
1449
+ //
1450
+ // 200 sent_back · 400 note_too_short / note_too_long · 409 not_waiting ·
1451
+ // 409 round_moved · 403 not_an_artist
1452
+ // -------------------------------------------------------------------------
1453
+ router.post('/copy-desk/pages/:pageId/send-back', api.requireBuilder, async (req, res) => {
1454
+ try {
1455
+ if (validateOrRespond(req, res, SEND_BACK_BODY_SCHEMA)) return;
1456
+ const found = pageOrFail(req, res);
1457
+ if (!found) return;
1458
+ const { pageId, inv } = found;
1459
+ const note = pages.normalizeSentBackNote(req.body.note);
1460
+ if (!note.ok) {
1461
+ return res.fail(note.code, 400, {
1462
+ ...note.detail,
1463
+ what_this_means: `Say in one line what needs changing, in at least ${pages.MIN_NOTE_CHARS} characters, so whoever applies it again knows what to do. Nothing was sent.`,
1464
+ });
1465
+ }
1466
+ const lifecycle = decisionPortOrFail(res, 'sendBackHeldTweak');
1467
+ if (!lifecycle) return;
1468
+ const waiting = await waitingRoundOrFail(res, lifecycle, pageId, req.body || {}, openRoundOf);
1469
+ if (!waiting) return;
1470
+ const { round } = waiting;
1471
+ const at = new Date().toISOString();
1472
+ const by = String(req.builder.id);
1473
+ const r = await lifecycle.sendBackHeldTweak({
1474
+ taskId: round.id,
1475
+ builderId: req.builder.id,
1476
+ compose: (task) => pages.appendSentBack(task.description, { at, by, note: note.note }),
1477
+ });
1478
+ if (r.outcome !== 'sent_back') return refuseDecision(res, pageId, r);
1479
+ const crafts = typeof lifecycle.builderCrafts === 'function' ? await lifecycle.builderCrafts(req.builder.id) : [];
1480
+ return res.json({
1481
+ ok: true,
1482
+ outcome: 'sent_back',
1483
+ page_id: pageId,
1484
+ round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
1485
+ sent_back: { at, by, note: note.note },
1486
+ pr: r.pr || null,
1487
+ next: await nextWaiting(lifecycle, inv.data, req.builder.id, crafts),
1488
+ });
1489
+ } catch (err) {
1490
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/send-back failed');
1491
+ if (!res.headersSent) res.fail('copy_page_send_back_failed', 500);
1492
+ }
1493
+ });
1494
+
1341
1495
  return router;
1342
1496
  };
1343
1497