@bongos/core 1.19.935 → 1.19.937
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 +74 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +103 -2
- package/docs/api-reference.md +6 -5
- package/docs/copy-inventory.md +36 -34
- package/docs/copy-registry.json +56 -38
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +35 -0
- package/modules/government/db.js +41 -1
- package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/hall-ui/public/board-room.css +6 -0
- package/modules/hall-ui/public/board-room.js +31 -0
- package/modules/hall-ui/public/ideas.css +27 -0
- package/modules/hall-ui/public/ideas.html +7 -0
- package/modules/hall-ui/public/ideas.js +99 -3
- package/modules/ideas/grades.js +43 -14
- package/modules/ideas/inbox.js +18 -0
- package/modules/ideas/migrations/ideas_009_recommendation.sql +62 -0
- package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/projection.js +45 -2
- package/modules/ideas/ratify.js +288 -1
- package/modules/ideas/routes/inbox.js +72 -1
- package/modules/ideas/routes/ratify-goal.js +163 -0
- package/modules/ideas/templates.js +16 -4
- package/modules/lifecycle/goal-from-idea.js +131 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_board_view.mjs +5 -1
- package/tests/government_seed.mjs +1 -0
- package/tests/idea_develop_spark.mjs +6 -1
- package/tests/idea_goal_ratification.mjs +553 -0
- package/tests/idea_recommendation.mjs +381 -0
|
@@ -861,6 +861,10 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
|
|
|
861
861
|
field_values: r.field_values || null,
|
|
862
862
|
template_key: r.template_key || null,
|
|
863
863
|
completeness_score: r.completeness_score ?? null,
|
|
864
|
+
// BV2.R13: the author's recommendation, in front of the voter with
|
|
865
|
+
// the sections. A RECOMMENDATION, NOT A DECISION — the board is
|
|
866
|
+
// deciding it, and nothing on this path may treat it as settled.
|
|
867
|
+
recommendation: r.recommendation ?? null,
|
|
864
868
|
} : null;
|
|
865
869
|
})()
|
|
866
870
|
: (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
|
|
@@ -1037,6 +1041,36 @@ async function latestRejectionForIdea(ideaId) {
|
|
|
1037
1041
|
});
|
|
1038
1042
|
}
|
|
1039
1043
|
|
|
1044
|
+
// ── BV2.R17's read: did the board CARRY this idea? (task 1004096) ───────────
|
|
1045
|
+
//
|
|
1046
|
+
// The ratification half of the ideator surface turns a Full Idea into a goal,
|
|
1047
|
+
// and spec decision 9 is that the ideator RECOMMENDS while the board DECIDES.
|
|
1048
|
+
// The ideas module holds the author's recommendation on its own grade row, so
|
|
1049
|
+
// without this read "the board decides" would be a sentence in a comment: the
|
|
1050
|
+
// only fact stopping a ratification from being one person's opinion is that a
|
|
1051
|
+
// sitting actually closed `passed`.
|
|
1052
|
+
//
|
|
1053
|
+
// Null means "no passing decision on record", which the caller must treat as a
|
|
1054
|
+
// refusal and never as an absence it may work around. A returned item, an item
|
|
1055
|
+
// still open, and an idea that never reached the board all read the same here,
|
|
1056
|
+
// on purpose — none of them is a mandate, and distinguishing them would invite a
|
|
1057
|
+
// caller to accept one of them.
|
|
1058
|
+
//
|
|
1059
|
+
// Frozen, like latestRejectionForIdea, so a caller cannot decorate the record of
|
|
1060
|
+
// a decision and pass it on as though the board had said more than it did.
|
|
1061
|
+
async function boardPassForIdea(ideaId) {
|
|
1062
|
+
const id = Number(ideaId);
|
|
1063
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
1064
|
+
const row = await db.getLatestPassedItem('full_idea', id);
|
|
1065
|
+
if (!row) return null;
|
|
1066
|
+
return Object.freeze({
|
|
1067
|
+
item_id: row.item_id,
|
|
1068
|
+
outcome: 'passed',
|
|
1069
|
+
opened_at: row.opened_at,
|
|
1070
|
+
closed_at: row.closed_at,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1040
1074
|
// ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
|
|
1041
1075
|
//
|
|
1042
1076
|
// THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
|
|
@@ -1123,6 +1157,7 @@ module.exports = {
|
|
|
1123
1157
|
closeItem,
|
|
1124
1158
|
maybeCloseAfterVote,
|
|
1125
1159
|
latestRejectionForIdea,
|
|
1160
|
+
boardPassForIdea,
|
|
1126
1161
|
validateProposedConstitution,
|
|
1127
1162
|
proposeAmendment,
|
|
1128
1163
|
boardRoomView,
|
package/modules/government/db.js
CHANGED
|
@@ -509,7 +509,14 @@ async function getIdeaSubjects(ideaIds) {
|
|
|
509
509
|
// in the hall rather than on a reaction. LEFT JOIN: an idea whose grade row
|
|
510
510
|
// is somehow gone still shows its title.
|
|
511
511
|
const { rows } = await pool.query(
|
|
512
|
-
`
|
|
512
|
+
// `g.recommendation` (task 1004092 / BV2.R13, spec R22): what the author
|
|
513
|
+
// thinks their idea turned out to be — a goal, or a criterion inside one.
|
|
514
|
+
// It rides with the sections for exactly the same reason they do: the board
|
|
515
|
+
// is being asked to decide that question, and deciding it without seeing
|
|
516
|
+
// what was proposed is the gap this closes. NULL for every Full Idea filed
|
|
517
|
+
// before the question existed, which the room renders honestly.
|
|
518
|
+
`SELECT i.id, i.title, i.captured_by, g.field_values, g.template_key, g.completeness_score,
|
|
519
|
+
g.recommendation
|
|
513
520
|
FROM idea_inbox i
|
|
514
521
|
LEFT JOIN ideas_grades g ON g.idea_id = i.id
|
|
515
522
|
WHERE i.id = ANY($1::bigint[])`,
|
|
@@ -603,6 +610,38 @@ async function getLatestReturnedItemWithObjection(subjectType, subjectId) {
|
|
|
603
610
|
return rows[0] || null;
|
|
604
611
|
}
|
|
605
612
|
|
|
613
|
+
// BV2.R17's read (task 1004096): the most recent PASSED item for a subject.
|
|
614
|
+
//
|
|
615
|
+
// The mirror of getLatestReturnedItemWithObjection above, and the reason it
|
|
616
|
+
// exists separately rather than as a `outcome` parameter on that one: the
|
|
617
|
+
// returned read exists to carry an OBJECTION back to an author and joins the
|
|
618
|
+
// votes table to find it, while this one exists to answer a single yes/no —
|
|
619
|
+
// "did the board carry this?" — and must not depend on any vote row existing.
|
|
620
|
+
// A passed item commonly has NO votes at all (a zero-vote sitting passes under
|
|
621
|
+
// the absence rules, ADR 0175 §3), so the LEFT JOIN that is merely defensive
|
|
622
|
+
// there would be load-bearing here, which is a good sign the two are different
|
|
623
|
+
// questions.
|
|
624
|
+
//
|
|
625
|
+
// Ordering is closed_at DESC, id DESC: an idea returned once and resubmitted can
|
|
626
|
+
// have several closed items, and it is the latest decision that stands.
|
|
627
|
+
//
|
|
628
|
+
// Served by the partial index `government_board_items_passed_by_subject`
|
|
629
|
+
// (government_018), the mirror of government_006's index for the returned read.
|
|
630
|
+
// It needs its own: the table only grows, government_003's two indexes both key
|
|
631
|
+
// on OPEN items and government_008's keys on closed_at alone for the whole-board
|
|
632
|
+
// history feed, so none of them helps a lookup filtered by subject.
|
|
633
|
+
async function getLatestPassedItem(subjectType, subjectId) {
|
|
634
|
+
const { rows } = await pool.query(
|
|
635
|
+
`SELECT id AS item_id, closed_at, opened_at, constitution AS decided_under
|
|
636
|
+
FROM government_board_items
|
|
637
|
+
WHERE subject_type = $1 AND subject_id = $2 AND outcome = 'passed'
|
|
638
|
+
ORDER BY closed_at DESC, id DESC
|
|
639
|
+
LIMIT 1`,
|
|
640
|
+
[subjectType, subjectId],
|
|
641
|
+
);
|
|
642
|
+
return rows[0] || null;
|
|
643
|
+
}
|
|
644
|
+
|
|
606
645
|
// The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
|
|
607
646
|
// the database clock, oldest deadline first. Served by the partial index
|
|
608
647
|
// government_board_items_open_by_deadline (government_003 built it for exactly
|
|
@@ -721,6 +760,7 @@ module.exports = {
|
|
|
721
760
|
getIdeaCompleteness,
|
|
722
761
|
getExpiredOpenBoardItems,
|
|
723
762
|
getLatestReturnedItemWithObjection,
|
|
763
|
+
getLatestPassedItem,
|
|
724
764
|
createAmendment,
|
|
725
765
|
getAmendment,
|
|
726
766
|
listOpenBoardItems,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- government_018_passed_items_index.sql — index the passed-item lookup
|
|
2
|
+
-- (task 1004096 / BV2.R17; a grader efficiency finding, and right — exactly as
|
|
3
|
+
-- government_006 was for the sibling read one outcome over).
|
|
4
|
+
--
|
|
5
|
+
-- `getLatestPassedItem` answers "did the board CARRY this subject?" and runs on
|
|
6
|
+
-- every Full-Idea→goal ratification. It is the mirror of
|
|
7
|
+
-- `getLatestReturnedItemWithObjection`, and it inherits that read's problem in
|
|
8
|
+
-- full: `government_board_items` only grows — one row per window, and a
|
|
9
|
+
-- returned idea can be resubmitted without bound — while the table's two
|
|
10
|
+
-- OPEN-item indexes (`government_003`) both key on `closed_at IS NULL` and the
|
|
11
|
+
-- one closed-item index (`government_008`) keys on `closed_at` alone, for the
|
|
12
|
+
-- whole-board history feed. None of them helps a lookup that filters by
|
|
13
|
+
-- subject. Without this, a ratification scans historical volume that scales
|
|
14
|
+
-- with every sitting the project has ever held.
|
|
15
|
+
--
|
|
16
|
+
-- Partial on outcome='passed' — the only outcome this read asks for — and keyed
|
|
17
|
+
-- (subject_type, subject_id, closed_at DESC) to match the query's WHERE and
|
|
18
|
+
-- ORDER BY exactly, the government_006 shape. Two narrow partial indexes rather
|
|
19
|
+
-- than one wider index over `outcome`: each read asks for exactly one outcome
|
|
20
|
+
-- and never ranges across them, so a shared index would be larger and no more
|
|
21
|
+
-- useful to either.
|
|
22
|
+
--
|
|
23
|
+
-- Additive, idempotent, no rows touched — safe on any database state.
|
|
24
|
+
|
|
25
|
+
BEGIN;
|
|
26
|
+
|
|
27
|
+
CREATE INDEX IF NOT EXISTS government_board_items_passed_by_subject
|
|
28
|
+
ON government_board_items (subject_type, subject_id, closed_at DESC)
|
|
29
|
+
WHERE outcome = 'passed';
|
|
30
|
+
|
|
31
|
+
COMMIT;
|
|
@@ -103,6 +103,13 @@ const governmentPort = {
|
|
|
103
103
|
// resubmit path in modules/ideas resolves this to show what was objected to
|
|
104
104
|
// and to check whether the flagged section actually changed.
|
|
105
105
|
latestRejectionForIdea: board.latestRejectionForIdea,
|
|
106
|
+
// BV2.R17 (task 1004096): the record of the board having CARRIED an idea, or
|
|
107
|
+
// null. The ideas module's ratification path resolves this to check the one
|
|
108
|
+
// fact that makes turning a Full Idea into a goal a board decision rather
|
|
109
|
+
// than a ratifier's preference. Read-only, like every other entry here — there
|
|
110
|
+
// is no counterpart that records an outcome, and there must never be one: an
|
|
111
|
+
// outcome is written by a close, never by the module reading it.
|
|
112
|
+
boardPassForIdea: board.boardPassForIdea,
|
|
106
113
|
};
|
|
107
114
|
|
|
108
115
|
function registerSeams() {
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
.gov-item__title .fact-pill { vertical-align: middle; margin-left: 10px; text-shadow: none; }
|
|
41
41
|
.gov-item__state { display: inline-flex; align-items: center; gap: 10px; font-size: 12.5px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
|
|
42
42
|
.gov-item__amend { margin: 0 0 12px; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); overflow-wrap: anywhere; }
|
|
43
|
+
/* The author's scope recommendation (BV2.R13). Sits between the ballot and the
|
|
44
|
+
idea's own words, at the weight of a stated fact rather than a badge — it is
|
|
45
|
+
what the board is deciding, not something already decided. */
|
|
46
|
+
.gov-item__rec { margin: 12px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); }
|
|
47
|
+
.gov-item__rec strong { font-weight: 500; color: var(--ink); }
|
|
48
|
+
.gov-item__rec--none { color: var(--ink-faint); }
|
|
43
49
|
.gov-item__act { margin: 8px 0 4px; }
|
|
44
50
|
.gov-item__cast { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
|
45
51
|
.gov-item__meta { margin: var(--sp-sm) 0 0; border-top: var(--line) solid var(--rule-soft); }
|
|
@@ -265,6 +265,36 @@
|
|
|
265
265
|
: emptyStateHtml('This idea has no written sections.');
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// The author's scope recommendation, in front of the voter WITH the sections
|
|
269
|
+
// (task 1004092 / BV2.R13, spec R22).
|
|
270
|
+
//
|
|
271
|
+
// THE WORDING CARRIES THE WHOLE POINT, so it is not shortened to a pill. The
|
|
272
|
+
// ideator recommends and the board decides (spec decision 9) — a bare badge
|
|
273
|
+
// reading "goal" next to a ballot reads as a fact already settled, which is
|
|
274
|
+
// the one thing this value is not. The line says who said it and who decides.
|
|
275
|
+
//
|
|
276
|
+
// AN ABSENT RECOMMENDATION IS STATED, NOT HIDDEN. Every Full Idea filed before
|
|
277
|
+
// this question existed carries NULL, and so does a terse CLI filing; there is
|
|
278
|
+
// no backfill and no default, because inventing a scope opinion is putting
|
|
279
|
+
// words in an author's mouth. Rendering nothing would leave a voter unable to
|
|
280
|
+
// tell "recommended as a criterion" from "never asked".
|
|
281
|
+
const RECOMMENDATION_WORDS = {
|
|
282
|
+
goal: 'a goal of its own',
|
|
283
|
+
criterion: 'a criterion inside an existing goal',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function recommendationHtml(item) {
|
|
287
|
+
if (item.subject_type !== 'full_idea' || !item.subject) return '';
|
|
288
|
+
const rec = item.subject.recommendation;
|
|
289
|
+
// An unknown value can only mean the vocabulary widened after this page
|
|
290
|
+
// shipped. Print it rather than dropping it — the same honesty rule the
|
|
291
|
+
// unlabelled-section tail follows.
|
|
292
|
+
const words = rec ? (RECOMMENDATION_WORDS[rec] || rec) : null;
|
|
293
|
+
return words
|
|
294
|
+
? `<p class="gov-item__rec">Its author recommends this becomes <strong>${escapeHtml(words)}</strong>. The board decides.</p>`
|
|
295
|
+
: '<p class="gov-item__rec gov-item__rec--none">Its author gave no scope recommendation.</p>';
|
|
296
|
+
}
|
|
297
|
+
|
|
268
298
|
// ---- the sitting's rules, in plain words (task 1003270) --------------------
|
|
269
299
|
|
|
270
300
|
// The card used to carry `tally: 0 yes · 0 no — sitting under <first_ratifier>,
|
|
@@ -412,6 +442,7 @@
|
|
|
412
442
|
</div>
|
|
413
443
|
${amendmentDetail}
|
|
414
444
|
${action ? `<div class="gov-item__act">${action}</div>` : ''}
|
|
445
|
+
${isOpen ? recommendationHtml(item) : ''}
|
|
415
446
|
${isOpen ? ideaSectionsHtml(item) : ''}
|
|
416
447
|
<details class="gov-item__meta">
|
|
417
448
|
<summary class="gov-item__summary">${tally} · who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
|
|
@@ -71,6 +71,33 @@
|
|
|
71
71
|
|
|
72
72
|
.fi-note { font-size: 13.5px; color: var(--ink-faint); }
|
|
73
73
|
|
|
74
|
+
/* ---- the scope recommendation (BV2.R13) ----------------------------------
|
|
75
|
+
A pick, so it is radios — the settings family's `.prow--pick` grammar, not a
|
|
76
|
+
toggle: a toggle says on/off and this says which of two. The fieldset draws
|
|
77
|
+
no box (a bordered group inside a composer panel is the card-in-a-card the
|
|
78
|
+
world removed) and the legend takes the label voice the five questions wear,
|
|
79
|
+
so the sixth question does not read as a different kind of thing.
|
|
80
|
+
|
|
81
|
+
The 24px floor is on the LABEL, not the 13px input: the whole option row is
|
|
82
|
+
the target, so the box stays the size it is drawn while the thing a finger
|
|
83
|
+
lands on is the sentence beside it. */
|
|
84
|
+
.fi-rec__set { margin: 0; padding: 0; border: 0; max-width: var(--measure); }
|
|
85
|
+
.fi-rec__set > .fi-label { margin-bottom: 0; padding: 0; }
|
|
86
|
+
.fi-rec__hint { margin-bottom: 6px; }
|
|
87
|
+
.fi-rec__opt {
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 10px;
|
|
91
|
+
min-height: 24px;
|
|
92
|
+
padding: 3px 0;
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
line-height: 1.4;
|
|
95
|
+
color: var(--ink);
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
}
|
|
98
|
+
.fi-rec__opt input { flex: none; margin: 0; }
|
|
99
|
+
.fi-rec__opt input:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
|
100
|
+
|
|
74
101
|
/* GRAVITY (task 1004083 / spec R02) — the consequence of the picker above it,
|
|
75
102
|
so it sits tight under the select rather than at the form's own rhythm, and
|
|
76
103
|
reads a step louder than .fi-hint: a hint describes a control, this states
|
|
@@ -158,6 +158,13 @@
|
|
|
158
158
|
<p class="fi-note">Loading the five questions…</p>
|
|
159
159
|
</div>
|
|
160
160
|
|
|
161
|
+
<!-- The scope recommendation (BV2.R13). Rendered from the same
|
|
162
|
+
GET /inbox/template read as the fields above, and for the same
|
|
163
|
+
reason: the two values are a closed vocabulary the database
|
|
164
|
+
enforces, so a hand-written pair of radios here would be a
|
|
165
|
+
second copy of it, free to drift. -->
|
|
166
|
+
<div id="fi-rec" class="fi-rec"></div>
|
|
167
|
+
|
|
161
168
|
<p class="fi-err" id="fi-err" role="alert"></p>
|
|
162
169
|
|
|
163
170
|
<div class="fi-actions">
|
|
@@ -119,6 +119,10 @@
|
|
|
119
119
|
template_key: template.key,
|
|
120
120
|
title: titleEl().value,
|
|
121
121
|
field_values: collect(),
|
|
122
|
+
// BV2.R13: a half-written thought includes the scope its author has in
|
|
123
|
+
// mind for it; losing that on navigation is the same loss as losing a
|
|
124
|
+
// paragraph.
|
|
125
|
+
recommendation: collectRecommendation(),
|
|
122
126
|
at: new Date().toISOString(),
|
|
123
127
|
}));
|
|
124
128
|
note('Draft saved in this browser.');
|
|
@@ -258,6 +262,8 @@
|
|
|
258
262
|
}
|
|
259
263
|
titleEl().addEventListener('input', onEdit);
|
|
260
264
|
|
|
265
|
+
renderRecommendation(draft);
|
|
266
|
+
|
|
261
267
|
$('#fi-meter').hidden = false;
|
|
262
268
|
renderMeterSkeleton();
|
|
263
269
|
}
|
|
@@ -268,6 +274,83 @@
|
|
|
268
274
|
return out;
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// the scope recommendation (task 1004092 / BV2.R13, spec R22)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
//
|
|
281
|
+
// NOT A SIXTH TEXTAREA, and deliberately not collected into `field_values`:
|
|
282
|
+
// the five answers are prose the scorer measures, and this is a two-value
|
|
283
|
+
// choice nothing scores. It travels as its own body field, so the meter
|
|
284
|
+
// above never moves when it is picked.
|
|
285
|
+
//
|
|
286
|
+
// THE OPTIONS COME FROM THE SERVER, like the fields. The vocabulary is a
|
|
287
|
+
// database CHECK constraint; hard-coding the pair here would be the "second
|
|
288
|
+
// copy of the rules" defect this whole file is organised against, and would
|
|
289
|
+
// silently omit any value a later migration admits.
|
|
290
|
+
//
|
|
291
|
+
// REQUIRED HERE, OPTIONAL AT THE API — which is not a contradiction. The
|
|
292
|
+
// hall is where the question is actually put to a person, so filing from it
|
|
293
|
+
// without answering would be filing an unanswered question. The route stays
|
|
294
|
+
// permissive because hundreds of already-filed Full Ideas carry no
|
|
295
|
+
// recommendation and a terse CLI filing may carry none either; every reader
|
|
296
|
+
// therefore has to render the absence honestly regardless, and a required
|
|
297
|
+
// body field would 400 every already-shipped client.
|
|
298
|
+
|
|
299
|
+
function recSpec() {
|
|
300
|
+
return (template && template.recommendation) || null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function renderRecommendation(draft) {
|
|
304
|
+
const host = $('#fi-rec');
|
|
305
|
+
if (!host) return;
|
|
306
|
+
const spec = recSpec();
|
|
307
|
+
// An instance whose template read predates this question simply has no
|
|
308
|
+
// control — the composer keeps working and files without one.
|
|
309
|
+
if (!spec || !Array.isArray(spec.options) || !spec.options.length) {
|
|
310
|
+
host.innerHTML = '';
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const picked = draft && typeof draft.recommendation === 'string' ? draft.recommendation : '';
|
|
314
|
+
host.innerHTML = `
|
|
315
|
+
<fieldset class="fi-rec__set">
|
|
316
|
+
<legend class="fi-label">${escapeHtml(spec.label || 'What do you think this is')}</legend>
|
|
317
|
+
${spec.hint ? `<p class="fi-hint fi-rec__hint">${escapeHtml(spec.hint)}</p>` : ''}
|
|
318
|
+
${spec.options.map((o) => `
|
|
319
|
+
<label class="fi-rec__opt">
|
|
320
|
+
<input type="radio" name="fi-rec-choice" value="${escapeHtml(o.value)}"${o.value === picked ? ' checked' : ''}>
|
|
321
|
+
<span>${escapeHtml(o.label || o.value)}</span>
|
|
322
|
+
</label>`).join('')}
|
|
323
|
+
</fieldset>`;
|
|
324
|
+
// onEdit, not a bespoke handler: picking an option has to save the draft
|
|
325
|
+
// and re-evaluate the file button exactly as typing does. It cannot change
|
|
326
|
+
// the score, and scoreNow is a no-op for it beyond one wasted call — which
|
|
327
|
+
// the debounce already collapses.
|
|
328
|
+
for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) {
|
|
329
|
+
el.addEventListener('change', onEdit);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function collectRecommendation() {
|
|
334
|
+
const host = $('#fi-rec');
|
|
335
|
+
const picked = host && host.querySelector('input[name="fi-rec-choice"]:checked');
|
|
336
|
+
return picked ? picked.value : '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function clearRecommendation() {
|
|
340
|
+
const host = $('#fi-rec');
|
|
341
|
+
if (!host) return;
|
|
342
|
+
for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) el.checked = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Whether the composer may file. Only true when the server actually offered
|
|
346
|
+
// the choice — an instance that serves no `recommendation` spec must not have
|
|
347
|
+
// its file button held hostage by a control that was never rendered.
|
|
348
|
+
function recommendationMissing() {
|
|
349
|
+
const spec = recSpec();
|
|
350
|
+
if (!spec || !Array.isArray(spec.options) || !spec.options.length) return false;
|
|
351
|
+
return !collectRecommendation();
|
|
352
|
+
}
|
|
353
|
+
|
|
271
354
|
// ---------------------------------------------------------------------------
|
|
272
355
|
// the meter — every number in it came from the server
|
|
273
356
|
// ---------------------------------------------------------------------------
|
|
@@ -316,7 +399,13 @@
|
|
|
316
399
|
// does NOT enforce the substance floor, because a thin answer is a scoring
|
|
317
400
|
// problem, not a capture error). Matching that exactly means the button never
|
|
318
401
|
// promises an outcome the server will refuse, and never blocks one it accepts.
|
|
319
|
-
|
|
402
|
+
//
|
|
403
|
+
// THE SCOPE RECOMMENDATION IS THE ONE PLACE THIS BUTTON IS STRICTER THAN
|
|
404
|
+
// THE ROUTE (BV2.R13). The route accepts a Full Idea without one, because
|
|
405
|
+
// the historical rows have none; the hall does not, because the hall is
|
|
406
|
+
// where the question was actually asked. See renderRecommendation.
|
|
407
|
+
const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
|
|
408
|
+
|| recommendationMissing();
|
|
320
409
|
// In develop mode the title is the existing idea's and is not editable, so it
|
|
321
410
|
// cannot be a reason to keep the button off.
|
|
322
411
|
$('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
|
|
@@ -342,7 +431,8 @@
|
|
|
342
431
|
if (lastScore) renderScore(lastScore);
|
|
343
432
|
else $('#fi-submit').disabled = filing
|
|
344
433
|
|| (!DEVELOP_ID && !titleEl().value.trim())
|
|
345
|
-
|| template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
|
|
434
|
+
|| template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
|
|
435
|
+
|| recommendationMissing();
|
|
346
436
|
}
|
|
347
437
|
|
|
348
438
|
// ---------------------------------------------------------------------------
|
|
@@ -386,10 +476,13 @@
|
|
|
386
476
|
// The one behavioural fork between the two modes. Developing is an act on an
|
|
387
477
|
// EXISTING idea, so it carries no title and no grade: the row already has
|
|
388
478
|
// both, and R146 preserves them along with its author.
|
|
479
|
+
// BV2.R13: the recommendation travels on BOTH paths, and only when one was
|
|
480
|
+
// actually picked — sending '' would be asserting an opinion nobody gave.
|
|
481
|
+
const rec = collectRecommendation();
|
|
389
482
|
const r = DEVELOP_ID
|
|
390
483
|
? await api.inbox.postInboxIdDevelop({
|
|
391
484
|
id: DEVELOP_ID,
|
|
392
|
-
body: { field_values: collect(), template_key: template.key },
|
|
485
|
+
body: { field_values: collect(), template_key: template.key, ...(rec ? { recommendation: rec } : {}) },
|
|
393
486
|
})
|
|
394
487
|
: await api.inbox.postInbox({
|
|
395
488
|
body: {
|
|
@@ -397,6 +490,7 @@
|
|
|
397
490
|
grade: 'full',
|
|
398
491
|
template_key: template.key,
|
|
399
492
|
field_values: collect(),
|
|
493
|
+
...(rec ? { recommendation: rec } : {}),
|
|
400
494
|
},
|
|
401
495
|
});
|
|
402
496
|
|
|
@@ -429,6 +523,7 @@
|
|
|
429
523
|
|
|
430
524
|
if (!DEVELOP_ID) titleEl().value = '';
|
|
431
525
|
for (const f of template.fields) fieldEl(f.key).value = '';
|
|
526
|
+
clearRecommendation();
|
|
432
527
|
lastScore = null;
|
|
433
528
|
renderScore(null);
|
|
434
529
|
note('');
|
|
@@ -445,6 +540,7 @@
|
|
|
445
540
|
clearDraft();
|
|
446
541
|
titleEl().value = '';
|
|
447
542
|
for (const f of template.fields) fieldEl(f.key).value = '';
|
|
543
|
+
clearRecommendation();
|
|
448
544
|
lastScore = null;
|
|
449
545
|
renderScore(null);
|
|
450
546
|
showErr('');
|
package/modules/ideas/grades.js
CHANGED
|
@@ -18,6 +18,7 @@ const { pool } = api;
|
|
|
18
18
|
const {
|
|
19
19
|
IDEA_COLS_Q, GRADE_COLS_Q,
|
|
20
20
|
VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_RE, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
|
|
21
|
+
VALID_RECOMMENDATIONS, isValidRecommendation,
|
|
21
22
|
} = require('./projection');
|
|
22
23
|
const { skillFrictionHiddenClause } = require('./skill-friction');
|
|
23
24
|
// developSpark reads the whole idea back to build the idea.graded.full payload.
|
|
@@ -121,7 +122,7 @@ async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
|
|
|
121
122
|
// Structured throws (not raw constraint violations) so the routes that land on
|
|
122
123
|
// top of this — R143's Full Idea capture, R144's mark-as-spark — return a clean
|
|
123
124
|
// 400/404 rather than a 500 with a Postgres message in it.
|
|
124
|
-
function assertGradePayload({ grade, templateKey, fieldValues, completenessScore }) {
|
|
125
|
+
function assertGradePayload({ grade, templateKey, fieldValues, completenessScore, recommendation = null }) {
|
|
125
126
|
if (!VALID_GRADES.includes(grade)) {
|
|
126
127
|
throw { code: 'BAD_GRADE', grade, valid: VALID_GRADES };
|
|
127
128
|
}
|
|
@@ -141,27 +142,45 @@ function assertGradePayload({ grade, templateKey, fieldValues, completenessScore
|
|
|
141
142
|
throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
|
|
142
143
|
}
|
|
143
144
|
}
|
|
145
|
+
// task 1004092 / BV2.R13. NULL is legal and permanent ("the author did not
|
|
146
|
+
// say"); anything outside the closed vocabulary is a structured throw here so
|
|
147
|
+
// the route returns a named 400 rather than letting
|
|
148
|
+
// ideas_grades_recommendation_chk surface as a 500.
|
|
149
|
+
if (!isValidRecommendation(recommendation)) {
|
|
150
|
+
throw { code: 'BAD_RECOMMENDATION', recommendation, valid: VALID_RECOMMENDATIONS };
|
|
151
|
+
}
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
// THE RECOMMENDATION IS INSIDE THE WHOLE-ROW CONTRACT, NOT BESIDE IT (task
|
|
155
|
+
// 1004092 / BV2.R13). It defaults to null, so a caller that omits it CLEARS it
|
|
156
|
+
// — deliberately the same rule as field_values, and for the same reason: it is
|
|
157
|
+
// current content the author states, not a historical fact about who acted
|
|
158
|
+
// (which is why developed_by/developed_at sit outside the contract instead).
|
|
159
|
+
// The consequence the callers must honour: POST /inbox/:id/resubmit re-states
|
|
160
|
+
// the whole Full Idea, so it passes the STORED recommendation forward unless
|
|
161
|
+
// the body names a new one — otherwise answering a board objection would
|
|
162
|
+
// silently withdraw the scope the board was shown.
|
|
146
163
|
async function setIdeaGrade(ideaId, {
|
|
147
164
|
grade,
|
|
148
165
|
templateKey = DEFAULT_TEMPLATE_KEY,
|
|
149
166
|
fieldValues = {},
|
|
150
167
|
completenessScore = null,
|
|
168
|
+
recommendation = null,
|
|
151
169
|
} = {}) {
|
|
152
|
-
assertGradePayload({ grade, templateKey, fieldValues, completenessScore });
|
|
170
|
+
assertGradePayload({ grade, templateKey, fieldValues, completenessScore, recommendation });
|
|
153
171
|
try {
|
|
154
172
|
const { rows } = await pool.query(
|
|
155
|
-
`INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score)
|
|
156
|
-
VALUES ($1, $2, $3, $4::jsonb, $5)
|
|
173
|
+
`INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score, recommendation)
|
|
174
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
|
|
157
175
|
ON CONFLICT (idea_id) DO UPDATE
|
|
158
176
|
SET grade = EXCLUDED.grade,
|
|
159
177
|
template_key = EXCLUDED.template_key,
|
|
160
178
|
field_values = EXCLUDED.field_values,
|
|
161
|
-
completeness_score = EXCLUDED.completeness_score
|
|
179
|
+
completeness_score = EXCLUDED.completeness_score,
|
|
180
|
+
recommendation = EXCLUDED.recommendation
|
|
162
181
|
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
163
|
-
created_at, updated_at`,
|
|
164
|
-
[ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore]
|
|
182
|
+
recommendation, created_at, updated_at`,
|
|
183
|
+
[ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore, recommendation]
|
|
165
184
|
);
|
|
166
185
|
return rows[0];
|
|
167
186
|
} catch (err) {
|
|
@@ -185,7 +204,7 @@ async function setIdeaCompleteness(ideaId, completenessScore) {
|
|
|
185
204
|
SET completeness_score = $2
|
|
186
205
|
WHERE idea_id = $1
|
|
187
206
|
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
188
|
-
created_at, updated_at`,
|
|
207
|
+
recommendation, created_at, updated_at`,
|
|
189
208
|
[ideaId, completenessScore]
|
|
190
209
|
);
|
|
191
210
|
return rows[0] ?? null;
|
|
@@ -237,11 +256,11 @@ async function setIdeaCompleteness(ideaId, completenessScore) {
|
|
|
237
256
|
// work, not current content, and setIdeaGrade's ON CONFLICT SET list omits both
|
|
238
257
|
// columns — so a later re-score or an author's R13 resubmission preserves the
|
|
239
258
|
// developer's attribution instead of silently erasing it.
|
|
240
|
-
async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null } = {}) {
|
|
259
|
+
async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null, recommendation = null } = {}) {
|
|
241
260
|
// Reuse the R140 payload validator so a bad template key or an oversize
|
|
242
261
|
// field_values map is a structured throw here rather than a constraint
|
|
243
262
|
// violation surfacing as a 500 (the assertGradePayload contract).
|
|
244
|
-
assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore });
|
|
263
|
+
assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore, recommendation });
|
|
245
264
|
if (developedBy == null) throw { code: 'MISSING_DEVELOPER' };
|
|
246
265
|
const { rows } = await pool.query(
|
|
247
266
|
`UPDATE ideas_grades
|
|
@@ -250,15 +269,21 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
|
|
|
250
269
|
field_values = $3::jsonb,
|
|
251
270
|
completeness_score = $4,
|
|
252
271
|
developed_by = $5,
|
|
253
|
-
developed_at = now()
|
|
272
|
+
developed_at = now(),
|
|
273
|
+
-- task 1004092 / BV2.R13: developing a spark IS filing the Full
|
|
274
|
+
-- Idea, so the developer states its scope recommendation here. A
|
|
275
|
+
-- spark has none to inherit (the question belongs to the full
|
|
276
|
+
-- grade), so an omitted value writing NULL is the honest outcome,
|
|
277
|
+
-- not a loss.
|
|
278
|
+
recommendation = $6
|
|
254
279
|
WHERE idea_id = $1
|
|
255
280
|
AND grade = 'spark'
|
|
256
281
|
AND EXISTS (
|
|
257
282
|
SELECT 1 FROM idea_inbox i WHERE i.id = $1 AND i.status = 'open'
|
|
258
283
|
)
|
|
259
284
|
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
260
|
-
developed_by, developed_at, created_at, updated_at`,
|
|
261
|
-
[ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy]
|
|
285
|
+
developed_by, developed_at, recommendation, created_at, updated_at`,
|
|
286
|
+
[ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy, recommendation]
|
|
262
287
|
);
|
|
263
288
|
// null = "this row was not a developable spark when we looked". THREE ways to
|
|
264
289
|
// get here, and the caller re-reads to say which: it was never a spark,
|
|
@@ -288,6 +313,10 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
|
|
|
288
313
|
grade: 'full',
|
|
289
314
|
completeness_score: graded.completeness_score,
|
|
290
315
|
developed_by: graded.developed_by,
|
|
316
|
+
// Stated from the write's own RETURNING rather than left to the re-read
|
|
317
|
+
// above, for the same reason completeness_score is: the listener acts on
|
|
318
|
+
// what this transition actually wrote.
|
|
319
|
+
recommendation: graded.recommendation ?? null,
|
|
291
320
|
});
|
|
292
321
|
} catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
|
|
293
322
|
}
|
|
@@ -299,7 +328,7 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
|
|
|
299
328
|
async function getIdeaGrade(ideaId) {
|
|
300
329
|
const { rows } = await pool.query(
|
|
301
330
|
`SELECT idea_id, grade, template_key, field_values, completeness_score,
|
|
302
|
-
created_at, updated_at
|
|
331
|
+
recommendation, created_at, updated_at
|
|
303
332
|
FROM ideas_grades
|
|
304
333
|
WHERE idea_id = $1`,
|
|
305
334
|
[ideaId]
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -31,6 +31,7 @@ const ideasConfig = require('./config');
|
|
|
31
31
|
const {
|
|
32
32
|
IDEA_COLS, IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE, ungradedProjection,
|
|
33
33
|
DEFAULT_GRADE, VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
|
|
34
|
+
VALID_RECOMMENDATIONS, isValidRecommendation, normalizeRecommendation,
|
|
34
35
|
} = require('./projection');
|
|
35
36
|
// The carved domains. Each is re-exported at the bottom of this file, so every
|
|
36
37
|
// existing caller of `require('./inbox')` keeps working unchanged — the same
|
|
@@ -796,6 +797,12 @@ module.exports = {
|
|
|
796
797
|
DEFAULT_TEMPLATE_KEY,
|
|
797
798
|
TEMPLATE_KEY_MAX,
|
|
798
799
|
FIELD_VALUES_MAX,
|
|
800
|
+
// the recommendation vocabulary (task 1004092 / BV2.R13) — goal | criterion,
|
|
801
|
+
// re-exported so the routes and the composer's template read declare it from
|
|
802
|
+
// the same place the SQL CHECK mirrors
|
|
803
|
+
VALID_RECOMMENDATIONS,
|
|
804
|
+
isValidRecommendation,
|
|
805
|
+
normalizeRecommendation,
|
|
799
806
|
// skill-friction marker + rank visibility gate (V3.R70 / #289; the kind
|
|
800
807
|
// itself landed in task 1003105 — both markers are live, see above)
|
|
801
808
|
SKILL_FRICTION_TAG,
|
|
@@ -841,4 +848,15 @@ module.exports = {
|
|
|
841
848
|
parseProposalBody: ratify.parseProposalBody,
|
|
842
849
|
KARMA_RATIFY_DELTA: ratify.KARMA_RATIFY_DELTA,
|
|
843
850
|
KARMA_RATIFY_REASON: ratify.KARMA_RATIFY_REASON,
|
|
851
|
+
// Full-Idea → GOAL ratification, the goal half beside it (task 1004096 /
|
|
852
|
+
// BV2.R17); see ratify.js on why the two halves are not symmetrical.
|
|
853
|
+
//
|
|
854
|
+
// ONLY THE TRANSACTION IS RE-EXPORTED. `composeGoalFromFullIdea` and
|
|
855
|
+
// `GOAL_SHAPE_BY_TEMPLATE` are deliberately NOT on the facade: nothing outside
|
|
856
|
+
// ratify.js calls either, and a re-export no caller needs is an unused export
|
|
857
|
+
// the dead-code ratchet counts. The facade's job is to keep the ~20 callers of
|
|
858
|
+
// the pre-carve surface working, not to publish everything a new file declares
|
|
859
|
+
// — anything that wants the pure composer requires ./ratify directly, as the
|
|
860
|
+
// test does.
|
|
861
|
+
ratifyFullIdeaAsGoal: ratify.ratifyFullIdeaAsGoal,
|
|
844
862
|
};
|