@bongos/core 1.19.915 → 1.19.917

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.
@@ -0,0 +1,317 @@
1
+ // modules/ideas/grades.js — the quick / spark / full grade domain (task 1004115).
2
+ //
3
+ // Carved out of inbox.js verbatim: the spark queue (R145), the grade writes
4
+ // (R140), developSpark (R146) and the grade-row read. One file because they are
5
+ // one relation — ideas_grades — and because the guarantees interlock:
6
+ // setIdeaGrade writes the WHOLE row so a re-grade cannot inherit stale
7
+ // field_values, developSpark deliberately steps OUTSIDE that contract for
8
+ // developed_by/developed_at, and the queue exists only because grade=spark is
9
+ // never implicit. Those three arguments read as one argument here and read as
10
+ // three scattered comments when they are 300 lines apart in a 1,500-line file.
11
+ //
12
+ // The grade VOCABULARY (VALID_GRADES, the template caps, the COALESCE default)
13
+ // stays in projection.js, not here: every idea read returns a grade, so the
14
+ // vocabulary is shared and the writes are not.
15
+
16
+ const api = require('../../src/module-api');
17
+ const { pool } = api;
18
+ const {
19
+ IDEA_COLS_Q, GRADE_COLS_Q,
20
+ VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_RE, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
21
+ } = require('./projection');
22
+ const { skillFrictionHiddenClause } = require('./skill-friction');
23
+ // developSpark reads the whole idea back to build the idea.graded.full payload.
24
+ // From ./reads, never from ./inbox — see the cycle note in reads.js.
25
+ const { getIdea } = require('./reads');
26
+
27
+ // -------------------------------------------------------------------------
28
+ // The spark queue (task 1002921 / BV1.R145, goal 1000062)
29
+ //
30
+ // "What Big Idea Sparks are awaiting development?" — the ideator craft's pickup
31
+ // surface, and the first place in this codebase where one craft sources work
32
+ // from another craft's output.
33
+ //
34
+ // THE PREDICATE IS grade='spark' AND status='open', AND THAT IS THE WHOLE
35
+ // QUEUE. There is deliberately no `developed` flag and no separate queue table:
36
+ // R146 develops a spark into a Full Idea IN PLACE on the same idea_inbox row
37
+ // (same id, same captured_by), which flips its grade spark→full — so a developed
38
+ // spark leaves this queue as a CONSEQUENCE of being developed, not because
39
+ // anything remembered to remove it. A second source of truth for "still
40
+ // waiting" is a thing that can disagree with the grade; this cannot.
41
+ //
42
+ // AN INNER JOIN, WHICH INVERTS THIS MODULE'S USUAL RULE ON PURPOSE. Every other
43
+ // idea read LEFT JOINs ideas_grades and COALESCEs to 'quick', because an
44
+ // un-graded idea must still appear. Here the opposite is correct and safe:
45
+ // grade='spark' can never be implicit — absence of a row means quick — so a
46
+ // spark ALWAYS has a real row, and starting from ideas_grades lets
47
+ // ideas_grades_grade_score_idx (ideas_001, written for this very read) drive the
48
+ // scan instead of filtering a full idea_inbox pass afterwards.
49
+ //
50
+ // ORDERING — "newest-or-best first", and only the newest half is real. A spark
51
+ // carries no completeness score by construction (it has no template fields to
52
+ // score), so there is no "best" signal to sort by: upvotes live on builders as
53
+ // karma, not per-idea, and inventing a quality metric for an undeveloped thought
54
+ // is exactly the guess this queue should not be making. captured_at DESC, id
55
+ // DESC as a stable tiebreak for same-timestamp rows. When a real per-idea
56
+ // signal exists, this ORDER BY is the one line that changes.
57
+ //
58
+ // The skill-friction visibility gate (V3.R70) is applied here too, on the same
59
+ // title-prefix rule listOpenIdeas uses. A routine-filed friction row is unlikely
60
+ // to ever be sparked (marking requires an author), but the two idea listings
61
+ // agreeing by construction beats them agreeing by accident.
62
+ function sparkQueueWhere(viewerRank, params) {
63
+ const where = [`g.grade = 'spark'`, `i.status = 'open'`];
64
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
65
+ if (hideFriction) where.push(hideFriction);
66
+ return where;
67
+ }
68
+
69
+ async function listSparkQueue({ limit = 50, viewerRank = null } = {}, deps = {}) {
70
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
71
+ const params = [];
72
+ const where = sparkQueueWhere(viewerRank, params);
73
+ params.push(limit);
74
+ const { rows } = await activePool.query(
75
+ // `b.github_login` because attribution is the POINT of this surface: an
76
+ // ideator picking work up should see whose thought they are developing, and
77
+ // R146 has to preserve that name. LEFT JOIN — a builder row can be missing.
78
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
79
+ b.github_login AS filed_by_login
80
+ FROM ideas_grades g
81
+ JOIN idea_inbox i ON i.id = g.idea_id
82
+ LEFT JOIN builders b ON b.id = i.captured_by
83
+ WHERE ${where.join(' AND ')}
84
+ ORDER BY i.captured_at DESC, i.id DESC
85
+ LIMIT $${params.length}`,
86
+ params
87
+ );
88
+ return rows;
89
+ }
90
+
91
+ // The count behind the queue's pill — the SAME predicate as the list (the
92
+ // countRoutedAwaitingNod / countOpenIdeas precedent), so the number a viewer is
93
+ // shown can never disagree with the rows they can actually see. It deliberately
94
+ // takes viewerRank too: a count that ignored the visibility gate would tell a
95
+ // Xenos there are sparks they cannot find.
96
+ async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
97
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
98
+ const params = [];
99
+ const where = sparkQueueWhere(viewerRank, params);
100
+ const { rows } = await activePool.query(
101
+ `SELECT COUNT(*)::int AS n
102
+ FROM ideas_grades g
103
+ JOIN idea_inbox i ON i.id = g.idea_id
104
+ WHERE ${where.join(' AND ')}`,
105
+ params
106
+ );
107
+ return rows[0] ? rows[0].n : 0;
108
+ }
109
+
110
+ // -------------------------------------------------------------------------
111
+ // Grade writes (task 1002916 / BV1.R140)
112
+ //
113
+ // setIdeaGrade writes the WHOLE grade row — it does not merge with what is
114
+ // already there. That is deliberate: a partial-merge upsert reads innocently at
115
+ // the call site and silently preserves stale field_values from a previous grade,
116
+ // which is the bug that would make "develop this spark into a full idea" (R146)
117
+ // quietly inherit whatever was there before. Callers that only want to move the
118
+ // score have setIdeaCompleteness below; anyone changing the grade states the
119
+ // whole row.
120
+ //
121
+ // Structured throws (not raw constraint violations) so the routes that land on
122
+ // top of this — R143's Full Idea capture, R144's mark-as-spark — return a clean
123
+ // 400/404 rather than a 500 with a Postgres message in it.
124
+ function assertGradePayload({ grade, templateKey, fieldValues, completenessScore }) {
125
+ if (!VALID_GRADES.includes(grade)) {
126
+ throw { code: 'BAD_GRADE', grade, valid: VALID_GRADES };
127
+ }
128
+ if (typeof templateKey !== 'string' || !TEMPLATE_KEY_RE.test(templateKey) || templateKey.length > TEMPLATE_KEY_MAX) {
129
+ throw { code: 'BAD_TEMPLATE_KEY', template_key: templateKey };
130
+ }
131
+ // A map, never a scalar or an array — the scorer and the read path walk it by
132
+ // key. `typeof null === 'object'`, hence the explicit null check.
133
+ if (fieldValues === null || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
134
+ throw { code: 'BAD_FIELD_VALUES' };
135
+ }
136
+ if (JSON.stringify(fieldValues).length > FIELD_VALUES_MAX) {
137
+ throw { code: 'FIELD_VALUES_TOO_LARGE', max: FIELD_VALUES_MAX };
138
+ }
139
+ if (completenessScore !== null) {
140
+ if (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100) {
141
+ throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
142
+ }
143
+ }
144
+ }
145
+
146
+ async function setIdeaGrade(ideaId, {
147
+ grade,
148
+ templateKey = DEFAULT_TEMPLATE_KEY,
149
+ fieldValues = {},
150
+ completenessScore = null,
151
+ } = {}) {
152
+ assertGradePayload({ grade, templateKey, fieldValues, completenessScore });
153
+ try {
154
+ 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)
157
+ ON CONFLICT (idea_id) DO UPDATE
158
+ SET grade = EXCLUDED.grade,
159
+ template_key = EXCLUDED.template_key,
160
+ field_values = EXCLUDED.field_values,
161
+ completeness_score = EXCLUDED.completeness_score
162
+ RETURNING idea_id, grade, template_key, field_values, completeness_score,
163
+ created_at, updated_at`,
164
+ [ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore]
165
+ );
166
+ return rows[0];
167
+ } catch (err) {
168
+ // 23503 = FK violation on idea_id: the idea does not exist. Worth its own
169
+ // code because "you graded a missing idea" is a 404, not a 500.
170
+ if (err && err.code === '23503') throw { code: 'IDEA_NOT_FOUND', idea_id: ideaId };
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ // The score-only write, so the R142 scorer never has to restate the grade and
176
+ // the fields just to move a number (and so it cannot wipe them by forgetting).
177
+ // Returns null when the idea has no grade row: an ungraded idea is quick, and a
178
+ // quick idea has nothing to score — that is a no-op, not an error.
179
+ async function setIdeaCompleteness(ideaId, completenessScore) {
180
+ if (completenessScore !== null && (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100)) {
181
+ throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
182
+ }
183
+ const { rows } = await pool.query(
184
+ `UPDATE ideas_grades
185
+ SET completeness_score = $2
186
+ WHERE idea_id = $1
187
+ RETURNING idea_id, grade, template_key, field_values, completeness_score,
188
+ created_at, updated_at`,
189
+ [ideaId, completenessScore]
190
+ );
191
+ return rows[0] ?? null;
192
+ }
193
+
194
+ // -------------------------------------------------------------------------
195
+ // developSpark — turn a Big Idea Spark into a Full Idea IN PLACE
196
+ // (task 1002922 / BV1.R146, goal 1000062)
197
+ //
198
+ // The pickup half of the spark handoff: R144 recorded the author's consent, R145
199
+ // made the spark findable, and this is another builder filling in the five
200
+ // template fields.
201
+ //
202
+ // IN PLACE, ON THE SAME ROW, AND THAT IS A DONE-WHEN. idea_inbox.id,
203
+ // captured_by and captured_at are NOT touched — only the grade row moves. A new
204
+ // row for the developed version would break the hall's /#/idea/N deep link,
205
+ // ideas_task_lineage.idea_id, and the per-idea credit_log reason key. The
206
+ // author's claim on their own thought is precisely what must survive being
207
+ // developed by someone else; it is what the R147 split pays against.
208
+ //
209
+ // ONE GUARDED UPDATE, WHICH IS WHERE "ONLY ONCE" ACTUALLY LIVES. The predicate
210
+ // carries `AND grade = 'spark'`, so the database decides whether this row was
211
+ // still developable. A read-then-write ("is it a spark? ok, develop it") looks
212
+ // identical in a test and loses a race in production: two builders picking the
213
+ // same spark off the R145 queue at the same moment would both pass the check and
214
+ // the second would overwrite the first's work AND their attribution. Here the
215
+ // second UPDATE matches zero rows and the route turns that into a 409. Zero rows
216
+ // is therefore NOT an error condition to log — it is the answer.
217
+ //
218
+ // It is also why this is not expressed as setIdeaGrade + a second write:
219
+ // two statements cannot carry one guard, and a Full Idea with no recorded
220
+ // developer is the exact state the done-when forbids.
221
+ //
222
+ // THE STATUS HALF OF THE GUARD, AND WHY IT IS NOT REDUNDANT WITH THE ROUTE'S
223
+ // PRE-CHECK. Triage (`updateIdea`) writes idea_inbox.status and NEVER touches
224
+ // ideas_grades — so a spark that a Metic+ triager discards, merges or promotes
225
+ // keeps grade='spark' forever. Grade alone therefore does NOT mean "still
226
+ // developable", and a develop that trusted it would resurrect content the triage
227
+ // flow already decided was dead, opening a fresh board ratification window for
228
+ // it. The R145 queue already filters on status and the R144 mark already requires
229
+ // it; this closes the same hole on the write. In the PREDICATE rather than only
230
+ // in the handler because a triager discarding an idea mid-request is the same
231
+ // class of race as two developers colliding — and the fix for a race is never a
232
+ // check that ran earlier.
233
+ //
234
+ // NOTE ON setIdeaGrade's whole-row contract: developed_by/developed_at are
235
+ // deliberately OUTSIDE it. That contract exists so a grade change cannot inherit
236
+ // stale field_values; but a developer is a historical FACT about who did the
237
+ // work, not current content, and setIdeaGrade's ON CONFLICT SET list omits both
238
+ // columns — so a later re-score or an author's R13 resubmission preserves the
239
+ // developer's attribution instead of silently erasing it.
240
+ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null } = {}) {
241
+ // Reuse the R140 payload validator so a bad template key or an oversize
242
+ // field_values map is a structured throw here rather than a constraint
243
+ // violation surfacing as a 500 (the assertGradePayload contract).
244
+ assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore });
245
+ if (developedBy == null) throw { code: 'MISSING_DEVELOPER' };
246
+ const { rows } = await pool.query(
247
+ `UPDATE ideas_grades
248
+ SET grade = 'full',
249
+ template_key = $2,
250
+ field_values = $3::jsonb,
251
+ completeness_score = $4,
252
+ developed_by = $5,
253
+ developed_at = now()
254
+ WHERE idea_id = $1
255
+ AND grade = 'spark'
256
+ AND EXISTS (
257
+ SELECT 1 FROM idea_inbox i WHERE i.id = $1 AND i.status = 'open'
258
+ )
259
+ 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]
262
+ );
263
+ // null = "this row was not a developable spark when we looked". THREE ways to
264
+ // get here, and the caller re-reads to say which: it was never a spark,
265
+ // somebody else developed it first, or a triager closed the idea underneath us.
266
+ const graded = rows[0] ?? null;
267
+
268
+ // task 1003856: a Full Idea is the ideator's hand-off point, and the
269
+ // cross-role cascade that turns it into an engineer's scoping request needs a
270
+ // transition to hang on. Emitted only on a REAL transition — the UPDATE's
271
+ // `AND grade = 'spark'` means a no-op re-grade returns null and fires nothing,
272
+ // so the event cannot repeat for one idea.
273
+ //
274
+ // Wrapped and awaited-free by the same reasoning as every other kernel emit
275
+ // here: a listener must never be able to fail the write that preceded it. The
276
+ // cascade runner catches everything of its own accord as well.
277
+ if (graded) {
278
+ // The payload carries the WHOLE idea, not just its id. The listener lives in
279
+ // the lifecycle module, which owns no idea table and has no read port for one
280
+ // (ideas provides only `ideas.capture`) — so the read happens here, inside the
281
+ // module that owns the row, rather than by widening another module's surface.
282
+ try {
283
+ const idea = await getIdea(ideaId);
284
+ api.emit('idea.graded.full', {
285
+ ...(idea || {}),
286
+ id: ideaId,
287
+ idea_id: ideaId,
288
+ grade: 'full',
289
+ completeness_score: graded.completeness_score,
290
+ developed_by: graded.developed_by,
291
+ });
292
+ } catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
293
+ }
294
+ return graded;
295
+ }
296
+
297
+ // The grade row on its own. null = no row, which reads as grade='quick'
298
+ // everywhere the idea itself is read (see DEFAULT_GRADE).
299
+ async function getIdeaGrade(ideaId) {
300
+ const { rows } = await pool.query(
301
+ `SELECT idea_id, grade, template_key, field_values, completeness_score,
302
+ created_at, updated_at
303
+ FROM ideas_grades
304
+ WHERE idea_id = $1`,
305
+ [ideaId]
306
+ );
307
+ return rows[0] ?? null;
308
+ }
309
+
310
+ module.exports = {
311
+ listSparkQueue,
312
+ countSparkQueue,
313
+ setIdeaGrade,
314
+ setIdeaCompleteness,
315
+ developSpark,
316
+ getIdeaGrade,
317
+ };