@bongos/core 1.19.935 → 1.19.936
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 +39 -29
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/api/openapi.json +12 -0
- package/docs/api-reference.md +3 -3
- package/docs/copy-inventory.md +36 -34
- package/docs/copy-registry.json +56 -38
- package/docs/module-api-changelog.md +2 -0
- package/modules/government/board.js +4 -0
- package/modules/government/db.js +8 -1
- 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 +7 -0
- package/modules/ideas/migrations/ideas_009_recommendation.sql +62 -0
- package/modules/ideas/projection.js +45 -2
- package/modules/ideas/routes/inbox.js +72 -1
- 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/idea_develop_spark.mjs +6 -1
- package/tests/idea_recommendation.mjs +381 -0
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,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- ideas_009_recommendation.sql — a Full Idea carries a RECOMMENDATION: goal or
|
|
2
|
+
-- criterion (task 1004092 / BV2.R13, spec R22 in
|
|
3
|
+
-- docs/specs/bongos-v2-ideator-surface.md; spec decision 9).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS IS, AND WHAT IT IS NOT. It is the ideator's answer to "how big did
|
|
6
|
+
-- this turn out to be" — a goal (a gravity well of its own) or a criterion (a
|
|
7
|
+
-- ring inside an existing one). It is NOT a decision. The owner's recorded
|
|
8
|
+
-- reasoning: scope is the thing you learn last, so the person who had the
|
|
9
|
+
-- thought writes down what they think it is and the board, reading it, decides.
|
|
10
|
+
-- Nothing in the system may branch on this column as if it were an outcome —
|
|
11
|
+
-- the ratification half (BV2.R17) reads what the BOARD chose, and this column
|
|
12
|
+
-- only travels alongside it so voters can see what was proposed.
|
|
13
|
+
--
|
|
14
|
+
-- WHY A COLUMN AND NOT A SIXTH TEMPLATE FIELD. The five questions are prose an
|
|
15
|
+
-- author writes and the completeness scorer measures; this is a closed
|
|
16
|
+
-- two-value vocabulary that nothing scores. Putting it in `field_values` would
|
|
17
|
+
-- make it a scored field (`completeness.js` walks every declared field), would
|
|
18
|
+
-- change what a Full Idea costs to file, and would have to be declared once per
|
|
19
|
+
-- vintage — while the recommendation is the same question under any template.
|
|
20
|
+
-- It is a property OF the grade row, beside `completeness_score`, not one of
|
|
21
|
+
-- the answers inside it.
|
|
22
|
+
--
|
|
23
|
+
-- NULLABLE, AND THAT IS PERMANENT. Every Full Idea filed before today has no
|
|
24
|
+
-- recommendation, and inventing one for them would put words in an author's
|
|
25
|
+
-- mouth — the never-invent-a-field rule (the Ideator pack) is exactly about
|
|
26
|
+
-- this. So NULL means "the author did not say", every reader must render that
|
|
27
|
+
-- honestly, and there is no backfill. The hall composer requires a choice
|
|
28
|
+
-- going forward (that is where the question is actually asked); the API keeps
|
|
29
|
+
-- it optional, because a reader that can handle NULL for the historical rows
|
|
30
|
+
-- can handle NULL for a terse CLI filing too, and a required body field would
|
|
31
|
+
-- 400 every already-shipped client against an upgraded instance.
|
|
32
|
+
--
|
|
33
|
+
-- Text + CHECK rather than a Postgres enum TYPE, matching
|
|
34
|
+
-- `ideas_grades_grade_chk` one column over: the vocabulary is pinned closed
|
|
35
|
+
-- today (an unknown recommendation is refused, never silently stored) and
|
|
36
|
+
-- widening it later is a one-line constraint change in a subsequent ideas_*
|
|
37
|
+
-- migration rather than an enum-type migration.
|
|
38
|
+
--
|
|
39
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): this
|
|
40
|
+
-- touches only `ideas_grades`, a table this module owns. There is no
|
|
41
|
+
-- down-migration. Idempotent: safe to re-run.
|
|
42
|
+
|
|
43
|
+
BEGIN;
|
|
44
|
+
|
|
45
|
+
ALTER TABLE ideas_grades
|
|
46
|
+
ADD COLUMN IF NOT EXISTS recommendation text;
|
|
47
|
+
|
|
48
|
+
-- Added separately and guarded, because ADD COLUMN IF NOT EXISTS skips the
|
|
49
|
+
-- whole clause on a re-run — an inline CONSTRAINT would then never be created
|
|
50
|
+
-- on an instance where the column already landed without it.
|
|
51
|
+
DO $$
|
|
52
|
+
BEGIN
|
|
53
|
+
IF NOT EXISTS (
|
|
54
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'ideas_grades_recommendation_chk'
|
|
55
|
+
) THEN
|
|
56
|
+
ALTER TABLE ideas_grades
|
|
57
|
+
ADD CONSTRAINT ideas_grades_recommendation_chk
|
|
58
|
+
CHECK (recommendation IS NULL OR recommendation IN ('goal', 'criterion'));
|
|
59
|
+
END IF;
|
|
60
|
+
END $$;
|
|
61
|
+
|
|
62
|
+
COMMIT;
|
|
@@ -80,6 +80,40 @@ const TEMPLATE_KEY_MAX = 64;
|
|
|
80
80
|
// payload gets a structured 400 instead of a constraint-violation 500.
|
|
81
81
|
const FIELD_VALUES_MAX = 32768;
|
|
82
82
|
|
|
83
|
+
// -------------------------------------------------------------------------
|
|
84
|
+
// The recommendation (task 1004092 / BV2.R13, spec R22)
|
|
85
|
+
//
|
|
86
|
+
// What the author thinks their Full Idea turned out to be: a goal (a gravity
|
|
87
|
+
// well of its own) or a criterion (a ring inside an existing one). A CLOSED
|
|
88
|
+
// vocabulary, mirroring ideas_grades_recommendation_chk so an unknown value is
|
|
89
|
+
// a named 400 at the route rather than a constraint-violation 500 in the
|
|
90
|
+
// driver.
|
|
91
|
+
//
|
|
92
|
+
// NULL IS A FIRST-CLASS VALUE AND ALWAYS WILL BE — "the author did not say".
|
|
93
|
+
// Every Full Idea filed before this shipped has no recommendation, and there is
|
|
94
|
+
// no backfill, because inventing one would put a scope opinion in someone's
|
|
95
|
+
// mouth. Every reader must render the absence honestly; nothing may substitute
|
|
96
|
+
// a default.
|
|
97
|
+
//
|
|
98
|
+
// IT IS A RECOMMENDATION, NEVER A DECISION (spec decision 9). The ideator
|
|
99
|
+
// recommends; the board decides. Nothing may branch on this column as if it
|
|
100
|
+
// were the outcome — BV2.R17's ratification reads what the board chose.
|
|
101
|
+
const VALID_RECOMMENDATIONS = ['goal', 'criterion'];
|
|
102
|
+
|
|
103
|
+
// The one predicate. `undefined` and `null` both mean "not given" and are
|
|
104
|
+
// legal; anything else must be a declared member. Pure, so a route can refuse
|
|
105
|
+
// before any write and a test can exercise it with no harness.
|
|
106
|
+
function isValidRecommendation(v) {
|
|
107
|
+
return v === null || v === undefined || VALID_RECOMMENDATIONS.includes(v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Normalise a request-supplied value to what the column stores. Written once
|
|
111
|
+
// because three routes (capture, develop, resubmit) all take the same field and
|
|
112
|
+
// all have to turn "absent" into NULL rather than into the string 'undefined'.
|
|
113
|
+
function normalizeRecommendation(v) {
|
|
114
|
+
return v === undefined || v === '' ? null : v;
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
// The grade half of an idea read: the COALESCE above, plus the template-shaped
|
|
84
118
|
// store and the score. `g.*` columns are NULL for an ungraded idea, which is
|
|
85
119
|
// exactly right — it has no template and no score, it just has a default grade.
|
|
@@ -89,16 +123,22 @@ const FIELD_VALUES_MAX = 32768;
|
|
|
89
123
|
// hall detail view (R153) and the R147 credit split all want it. NULL for the
|
|
90
124
|
// overwhelming majority of rows — a quick idea and a self-authored Full Idea were
|
|
91
125
|
// never developed by anyone — which is the honest answer, not a missing one.
|
|
126
|
+
//
|
|
127
|
+
// `recommendation` (ideas_009, task 1004092 / BV2.R13) rides along for the same
|
|
128
|
+
// reason developed_by does: it is part of a Full Idea's identity from filing
|
|
129
|
+
// onwards, and the board item, the detail read and the author's own trail all
|
|
130
|
+
// want it without a second query. NULL for every quick idea and for every Full
|
|
131
|
+
// Idea filed before the question existed — the honest answer, not a missing one.
|
|
92
132
|
const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
|
|
93
133
|
g.template_key, g.field_values, g.completeness_score,
|
|
94
|
-
g.developed_by, g.developed_at`;
|
|
134
|
+
g.developed_by, g.developed_at, g.recommendation`;
|
|
95
135
|
const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
|
|
96
136
|
|
|
97
137
|
// The same projection for a row that provably has no grade row yet (a just-
|
|
98
138
|
// INSERTed idea), so INSERT…RETURNING does not need a second round trip to
|
|
99
139
|
// return the same shape every other read returns.
|
|
100
140
|
function ungradedProjection() {
|
|
101
|
-
return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null };
|
|
141
|
+
return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null, recommendation: null };
|
|
102
142
|
}
|
|
103
143
|
|
|
104
144
|
module.exports = {
|
|
@@ -110,6 +150,9 @@ module.exports = {
|
|
|
110
150
|
TEMPLATE_KEY_RE,
|
|
111
151
|
TEMPLATE_KEY_MAX,
|
|
112
152
|
FIELD_VALUES_MAX,
|
|
153
|
+
VALID_RECOMMENDATIONS,
|
|
154
|
+
isValidRecommendation,
|
|
155
|
+
normalizeRecommendation,
|
|
113
156
|
GRADE_COLS_Q,
|
|
114
157
|
IDEA_JOIN_GRADE,
|
|
115
158
|
ungradedProjection,
|
|
@@ -122,6 +122,26 @@ function boardWindowPayload(item) {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// THE RECOMMENDATION A FULL IDEA CARRIES (task 1004092 / BV2.R13, spec R22).
|
|
126
|
+
// 'goal' or 'criterion' — the author's reading of how big the thought turned
|
|
127
|
+
// out, carried from filing to the board item so voters see what was proposed.
|
|
128
|
+
// IT IS NOT A DECISION (spec decision 9): the board decides, and no branch in
|
|
129
|
+
// this module may treat it as the outcome.
|
|
130
|
+
//
|
|
131
|
+
// Spelled once because all three filing paths take the same field — capture,
|
|
132
|
+
// develop and resubmit — and a second copy of the refusal is a second place for
|
|
133
|
+
// the vocabulary to drift from ideas_grades_recommendation_chk. Returns true
|
|
134
|
+
// when it has already answered the request.
|
|
135
|
+
function refuseBadRecommendation(res, raw) {
|
|
136
|
+
if (inbox.isValidRecommendation(raw)) return false;
|
|
137
|
+
res.fail('bad_recommendation', 400, {
|
|
138
|
+
recommendation: raw,
|
|
139
|
+
allowed: inbox.VALID_RECOMMENDATIONS,
|
|
140
|
+
reason: 'a Full Idea recommends a goal or a criterion; the board decides which it becomes.',
|
|
141
|
+
});
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
125
145
|
module.exports = function buildInboxRouter() {
|
|
126
146
|
registerSeams();
|
|
127
147
|
const router = express.Router();
|
|
@@ -152,6 +172,10 @@ module.exports = function buildInboxRouter() {
|
|
|
152
172
|
grade: { type: 'string', maxLength: 16 },
|
|
153
173
|
template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
|
|
154
174
|
field_values: { type: 'object' },
|
|
175
|
+
// task 1004092 / BV2.R13 — the author's own reading of how big this
|
|
176
|
+
// turned out: 'goal' or 'criterion'. OPTIONAL, permanently (see the
|
|
177
|
+
// refusal below and ideas_009's header).
|
|
178
|
+
recommendation: { type: 'string', maxLength: 16 },
|
|
155
179
|
})) return;
|
|
156
180
|
const body = req.body || {};
|
|
157
181
|
if (body.kind === 'criterion-proposal') {
|
|
@@ -180,6 +204,18 @@ module.exports = function buildInboxRouter() {
|
|
|
180
204
|
});
|
|
181
205
|
}
|
|
182
206
|
const templateKey = body.template_key ?? undefined;
|
|
207
|
+
// task 1004092 / BV2.R13. Checked BEFORE any write, like the field values
|
|
208
|
+
// below — and REFUSED on a non-full capture rather than dropped, because a
|
|
209
|
+
// quick idea writes no grade row at all, so silently accepting one would
|
|
210
|
+
// return 201 for a recommendation that was never stored anywhere.
|
|
211
|
+
if (refuseBadRecommendation(res, body.recommendation)) return;
|
|
212
|
+
if (body.recommendation !== undefined && grade !== 'full') {
|
|
213
|
+
return res.fail('recommendation_needs_full_grade', 400, {
|
|
214
|
+
grade: grade ?? 'quick',
|
|
215
|
+
reason: 'only a Full Idea recommends a scope — a quick idea has no grade row to carry one.',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const recommendation = inbox.normalizeRecommendation(body.recommendation);
|
|
183
219
|
if (grade === 'full') {
|
|
184
220
|
// Validate BEFORE any write, so a bad payload is a named 400 and no
|
|
185
221
|
// orphan idea lands. Each error carries { field, reason } — the
|
|
@@ -236,11 +272,13 @@ module.exports = function buildInboxRouter() {
|
|
|
236
272
|
...(templateKey ? { templateKey } : {}),
|
|
237
273
|
fieldValues: body.field_values,
|
|
238
274
|
completenessScore: completenessScore(body.field_values, templateKey ? { templateKey } : undefined),
|
|
275
|
+
recommendation,
|
|
239
276
|
});
|
|
240
277
|
idea.grade = row.grade;
|
|
241
278
|
idea.template_key = row.template_key;
|
|
242
279
|
idea.field_values = row.field_values;
|
|
243
280
|
idea.completeness_score = row.completeness_score;
|
|
281
|
+
idea.recommendation = row.recommendation ?? null;
|
|
244
282
|
|
|
245
283
|
// Stream 2 (ADR 0172 / IC2): a Full Idea that clears the completeness
|
|
246
284
|
// bar pays its author, once, on the IDEATOR credit lane — a separate
|
|
@@ -502,6 +540,22 @@ module.exports = function buildInboxRouter() {
|
|
|
502
540
|
substance_min: f.substanceMin || 0,
|
|
503
541
|
};
|
|
504
542
|
}),
|
|
543
|
+
// task 1004092 / BV2.R13. The recommendation is NOT a sixth field — it
|
|
544
|
+
// is not prose, it is not scored, and it is the same closed question
|
|
545
|
+
// under every vintage — so it rides beside `fields` rather than in it.
|
|
546
|
+
// Served from here for the same reason the fields are: a hard-coded
|
|
547
|
+
// radio pair in the browser is the "second copy of the rules" defect
|
|
548
|
+
// one step earlier, and it would offer a vocabulary the CHECK
|
|
549
|
+
// constraint could already have widened past.
|
|
550
|
+
recommendation: {
|
|
551
|
+
label: 'What do you think this is',
|
|
552
|
+
hint: 'Your reading of how big it turned out. The board decides — this is what they read while deciding.',
|
|
553
|
+
required: false,
|
|
554
|
+
options: inbox.VALID_RECOMMENDATIONS.map((value) => ({
|
|
555
|
+
value,
|
|
556
|
+
label: value === 'goal' ? 'A goal of its own' : 'A criterion inside an existing goal',
|
|
557
|
+
})),
|
|
558
|
+
},
|
|
505
559
|
},
|
|
506
560
|
});
|
|
507
561
|
}, { errorCode: 'template_read_failed' }));
|
|
@@ -748,7 +802,9 @@ module.exports = function buildInboxRouter() {
|
|
|
748
802
|
if (validateOrRespond(req, res, {
|
|
749
803
|
field_values: { required: true, type: 'object' },
|
|
750
804
|
template_key: { type: 'string', maxLength: 64 },
|
|
805
|
+
recommendation: { type: 'string', maxLength: 16 },
|
|
751
806
|
})) return;
|
|
807
|
+
if (refuseBadRecommendation(res, req.body.recommendation)) return;
|
|
752
808
|
try {
|
|
753
809
|
const idea = await inbox.getIdea(id);
|
|
754
810
|
if (!idea) return res.fail('idea_not_found', 404);
|
|
@@ -798,8 +854,15 @@ module.exports = function buildInboxRouter() {
|
|
|
798
854
|
...(templateKey ? { templateKey } : {}),
|
|
799
855
|
fieldValues: req.body.field_values,
|
|
800
856
|
completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
|
|
857
|
+
// task 1004092 / BV2.R13. CARRIED FORWARD FROM THE STORED ROW when the
|
|
858
|
+
// body names none — the same `?? idea.…` shape template_key uses two
|
|
859
|
+
// lines up, and for a sharper reason. setIdeaGrade writes the WHOLE
|
|
860
|
+
// row, so defaulting to null here would mean that answering a board
|
|
861
|
+
// objection silently withdrew the scope recommendation the board had
|
|
862
|
+
// been shown, on a request that never mentioned it.
|
|
863
|
+
recommendation: inbox.normalizeRecommendation(req.body.recommendation ?? idea.recommendation ?? undefined),
|
|
801
864
|
});
|
|
802
|
-
const out = { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score };
|
|
865
|
+
const out = { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score, recommendation: row.recommendation ?? null };
|
|
803
866
|
|
|
804
867
|
try {
|
|
805
868
|
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
@@ -995,7 +1058,9 @@ module.exports = function buildInboxRouter() {
|
|
|
995
1058
|
if (validateOrRespond(req, res, {
|
|
996
1059
|
field_values: { required: true, type: 'object' },
|
|
997
1060
|
template_key: { type: 'string', maxLength: 64 },
|
|
1061
|
+
recommendation: { type: 'string', maxLength: 16 },
|
|
998
1062
|
})) return;
|
|
1063
|
+
if (refuseBadRecommendation(res, req.body.recommendation)) return;
|
|
999
1064
|
|
|
1000
1065
|
const idea = await inbox.getIdea(id);
|
|
1001
1066
|
if (!idea) return res.fail('idea_not_found', 404);
|
|
@@ -1040,6 +1105,11 @@ module.exports = function buildInboxRouter() {
|
|
|
1040
1105
|
...(templateKey ? { templateKey } : {}),
|
|
1041
1106
|
fieldValues: req.body.field_values,
|
|
1042
1107
|
completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
|
|
1108
|
+
// task 1004092 / BV2.R13. No `?? idea.recommendation` fallback here, and
|
|
1109
|
+
// that is not an omission: a spark has no recommendation to inherit (the
|
|
1110
|
+
// question belongs to the full grade), so the developer is the first
|
|
1111
|
+
// person ever asked it on this row.
|
|
1112
|
+
recommendation: inbox.normalizeRecommendation(req.body.recommendation),
|
|
1043
1113
|
});
|
|
1044
1114
|
// Zero rows means the guard fired: somebody else developed this spark between
|
|
1045
1115
|
// the read above and this write. Their work stands; this caller is told so
|
|
@@ -1070,6 +1140,7 @@ module.exports = function buildInboxRouter() {
|
|
|
1070
1140
|
completeness_score: row.completeness_score,
|
|
1071
1141
|
developed_by: row.developed_by,
|
|
1072
1142
|
developed_at: row.developed_at,
|
|
1143
|
+
recommendation: row.recommendation ?? null,
|
|
1073
1144
|
};
|
|
1074
1145
|
|
|
1075
1146
|
// Fail-open, exactly like the capture and resubmit paths: the development has
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.936",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.936",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.936",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.936'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -69,7 +69,11 @@ test('the assembled view: enrichment, the open ballot, the snapshot, the databas
|
|
|
69
69
|
|
|
70
70
|
const idea = view.open[0];
|
|
71
71
|
assert.deepEqual(idea.subject,
|
|
72
|
-
|
|
72
|
+
// `recommendation: null` is the stored row's own answer, not a filler: this
|
|
73
|
+
// stub idea has no recommendation column set, and BV2.R13's whole posture is
|
|
74
|
+
// that "the author did not say" must reach the room as itself rather than
|
|
75
|
+
// being dropped or defaulted to one of the two values.
|
|
76
|
+
{ title: 'A big idea', author_id: '9', field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82, recommendation: null },
|
|
73
77
|
'an idea shows its title, author, AND its five sections — the voter decides with the idea in front of them (R17)');
|
|
74
78
|
assert.equal(idea.remaining_seconds, 540, 'the DATABASE clock, passed through — the client never does clock math');
|
|
75
79
|
assert.deepEqual(idea.constitution, MONARCHY, 'the SNAPSHOT rides along — the room describes the rule the item runs under');
|
|
@@ -145,7 +145,12 @@ await test('a DISTINCT developer is recorded, and both parties are named in the
|
|
|
145
145
|
assert.equal(r.payload.developer_id, '42');
|
|
146
146
|
assert.equal(r.payload.author_id, '9');
|
|
147
147
|
assert.equal(r.payload.self_developed, false);
|
|
148
|
-
|
|
148
|
+
// The developer is $5 in developSpark's UPDATE. Pinned by INDEX, not by
|
|
149
|
+
// `.at(-1)`: task 1004092 appended `recommendation` as $6, and a pin that
|
|
150
|
+
// meant "the developer" while reading "the last parameter" then failed for a
|
|
151
|
+
// reason that had nothing to do with what it was asserting.
|
|
152
|
+
assert.equal(updateQ().params[4], '42', 'the developer is the AUTHENTICATED builder, not a body field');
|
|
153
|
+
assert.equal(updateQ().params.length, 6, 'developSpark binds six parameters — a new one must be placed deliberately');
|
|
149
154
|
});
|
|
150
155
|
|
|
151
156
|
await test('self-development is legal and reported as such', async () => {
|