@bongos/core 1.19.297 → 1.19.299
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 +60 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0185-spark-handoff-credit-split.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +128 -4
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +28 -13
- package/docs/copy-registry.json +162 -17
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/economy/credits.js +156 -31
- package/modules/hall-ui/public/ideas.css +120 -0
- package/modules/hall-ui/public/ideas.html +95 -0
- package/modules/hall-ui/public/ideas.js +335 -0
- package/modules/hall-ui/public/shell.js +8 -0
- package/modules/ideas/routes/inbox.js +121 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +6 -1
- package/src/module-api.js +5 -1
- package/tests/hall_nav.mjs +6 -1
- package/tests/idea_credit_split.mjs +224 -0
- package/tests/idea_develop_spark.mjs +47 -10
- package/tests/idea_full_pass_credit.mjs +50 -18
- package/tests/idea_hall_composer.mjs +246 -0
|
@@ -6,8 +6,8 @@ const api = require('../../../src/module-api');
|
|
|
6
6
|
const inbox = require('../inbox');
|
|
7
7
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
8
8
|
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
9
|
-
const { validateFieldValues } = require('../templates');
|
|
10
|
-
const { completenessScore, weakestSectionPayload } = require('../completeness');
|
|
9
|
+
const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
|
|
10
|
+
const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('../completeness');
|
|
11
11
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
12
12
|
|
|
13
13
|
const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
|
|
@@ -313,6 +313,94 @@ module.exports = function buildInboxRouter() {
|
|
|
313
313
|
});
|
|
314
314
|
|
|
315
315
|
|
|
316
|
+
|
|
317
|
+
// -----------------------------------------------------------------------
|
|
318
|
+
// The two reads the hall's Full Idea composer is built from
|
|
319
|
+
// (task 1002928 / BV1.R152, goal 1000062).
|
|
320
|
+
//
|
|
321
|
+
// The done-when says the live meter must reflect THE REAL SCORER, "not a
|
|
322
|
+
// second copy of the rules". A browser cannot require() this module —
|
|
323
|
+
// completeness.js and templates.js are CJS and templates.js reaches inbox.js
|
|
324
|
+
// for DEFAULT_TEMPLATE_KEY, which reaches the pool — so the choice is between
|
|
325
|
+
// re-implementing the arithmetic in page JS and asking the server. These two
|
|
326
|
+
// routes are that ask, and they are why the composer contains no rules at all:
|
|
327
|
+
//
|
|
328
|
+
// GET /inbox/template → what the five fields ARE (so the FORM is rendered
|
|
329
|
+
// from the R141 declaration, not from five <textarea>
|
|
330
|
+
// elements hand-written into the HTML — a hard-coded
|
|
331
|
+
// form is the same "second copy" defect as a
|
|
332
|
+
// hard-coded scorer, one step earlier, and it would
|
|
333
|
+
// silently ignore a per-project template (R156)).
|
|
334
|
+
// POST /inbox/score → what it is WORTH right now (the R142 scorer itself,
|
|
335
|
+
// per-field, in template order).
|
|
336
|
+
//
|
|
337
|
+
// BOTH ARE PURE. No DB read, no write, no idea id: they describe and score a
|
|
338
|
+
// payload the caller already holds. That is what makes a debounced call on
|
|
339
|
+
// every keystroke reasonable, and why scoring carries no rank beyond sign-in —
|
|
340
|
+
// it tells you nothing you did not just type.
|
|
341
|
+
//
|
|
342
|
+
// DECLARED BEFORE `GET /inbox/:id`, and for GET /inbox/template that ordering
|
|
343
|
+
// is LOAD-BEARING: Express matches in declaration order, so with `:id` first
|
|
344
|
+
// the literal "/inbox/template" binds id='template' and parseId 400s it. Same
|
|
345
|
+
// trap as /inbox/sparks and /inbox/awaiting-nod above.
|
|
346
|
+
//
|
|
347
|
+
// rank: any-builder — filing a Full Idea is open to every role by the goal's
|
|
348
|
+
// own settled decision ("no permission gate on Full Ideas; completeness scoring
|
|
349
|
+
// and triage do the filtering"), so describing and scoring one must be too.
|
|
350
|
+
// Deliberately NOT pinned in route-rank-check: neither has a gate to protect.
|
|
351
|
+
router.get('/inbox/template', api.requireBuilder, api.asyncHandler('GET /inbox/template', async (req, res) => {
|
|
352
|
+
// templateFor deliberately does NOT default — it returns null for anything
|
|
353
|
+
// that is not a declared own key, which is what makes it safe against an
|
|
354
|
+
// attacker-supplied 'constructor'/'__proto__' (the R143 grader's finding). So
|
|
355
|
+
// the DEFAULT is supplied here, explicitly, from the one place that declares
|
|
356
|
+
// it. Leaving it undefined 404s the normal page load, which is exactly what
|
|
357
|
+
// the first run of tests/idea_hall_composer.mjs caught.
|
|
358
|
+
const key = typeof req.query.template_key === 'string' && req.query.template_key
|
|
359
|
+
? req.query.template_key
|
|
360
|
+
: inbox.DEFAULT_TEMPLATE_KEY;
|
|
361
|
+
const template = templateFor(key);
|
|
362
|
+
if (!template) {
|
|
363
|
+
return res.fail('unknown_template', 404, { template_key: key });
|
|
364
|
+
}
|
|
365
|
+
// Projected rather than passed through: the declaration is frozen and the
|
|
366
|
+
// composer needs exactly these four things per field.
|
|
367
|
+
const byKey = new Map(template.fields.map((f) => [f.key, f]));
|
|
368
|
+
return res.json({
|
|
369
|
+
template: {
|
|
370
|
+
key: template.key,
|
|
371
|
+
label: template.label,
|
|
372
|
+
// ORDER from fieldOrder (which returns KEYS — the one legal source of
|
|
373
|
+
// display order, since jsonb does not preserve key order), DATA from the
|
|
374
|
+
// declaration itself. Spelled as a lookup rather than mapping over
|
|
375
|
+
// template.fields directly so the provenance of the order is visible at
|
|
376
|
+
// the call site and cannot drift into an Object.keys() somewhere.
|
|
377
|
+
fields: fieldOrder(template).map((key) => {
|
|
378
|
+
const f = byKey.get(key);
|
|
379
|
+
return {
|
|
380
|
+
key: f.key,
|
|
381
|
+
label: f.label,
|
|
382
|
+
required: !!f.required,
|
|
383
|
+
substance_min: f.substanceMin || 0,
|
|
384
|
+
};
|
|
385
|
+
}),
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
}, { errorCode: 'template_read_failed' }));
|
|
389
|
+
|
|
390
|
+
router.post('/inbox/score', api.requireBuilder, api.asyncHandler('POST /inbox/score', async (req, res) => {
|
|
391
|
+
if (validateOrRespond(req, res, {
|
|
392
|
+
field_values: { required: true, type: 'object' },
|
|
393
|
+
template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
|
|
394
|
+
})) return;
|
|
395
|
+
const templateKey = req.body.template_key || undefined;
|
|
396
|
+
// scoreFieldValues NEVER throws (R142's contract) and answers
|
|
397
|
+
// { score: null, fields: [] } for anything it cannot score — an unknown
|
|
398
|
+
// template, a non-object, a non-string field. A draft in progress is exactly
|
|
399
|
+
// that kind of half-formed input, so the composer wants the null rather than
|
|
400
|
+
// a 400: "not scorable yet" is a legal state on the way to a Full Idea.
|
|
401
|
+
const { score, fields } = scoreFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
|
|
402
|
+
return res.json({ score, fields, template_key: templateKey || null });
|
|
403
|
+
}, { errorCode: 'score_failed' }));
|
|
316
404
|
// GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
|
|
317
405
|
//
|
|
318
406
|
// Big Idea Sparks awaiting development: the ideator craft's pickup surface,
|
|
@@ -614,16 +702,15 @@ module.exports = function buildInboxRouter() {
|
|
|
614
702
|
// to tell a 404 apart from a 409 and to give a useful message; it is not the
|
|
615
703
|
// guard, and it deliberately does not try to be.
|
|
616
704
|
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// still pay it at the right time, to the right people, in the right shares.
|
|
705
|
+
// THE STREAM-2 CREDIT IS PAID HERE, SPLIT (task 1002923 / R147, ADR 0185).
|
|
706
|
+
// R146 shipped this route paying nothing on purpose: with two people involved,
|
|
707
|
+
// the recipient was ambiguous, and awardIdeaFullPassCredit's idempotency
|
|
708
|
+
// predicate is (builder_id, reason) — so guessing the developer would NOT have
|
|
709
|
+
// blocked a later payment to the author, and one idea would have paid twice.
|
|
710
|
+
// R147 resolved that by moving the payee decision INTO the economy module: it
|
|
711
|
+
// reads captured_by + developed_by itself and books 70% to the developer, 30%
|
|
712
|
+
// to the spark author (one payee at 100% when they are the same person). This
|
|
713
|
+
// route can therefore call it safely — it no longer chooses who earns.
|
|
627
714
|
//
|
|
628
715
|
// The board window IS opened, because it is recipient-agnostic (it takes an
|
|
629
716
|
// idea and a score, not a builder) and without it a developed Full Idea could
|
|
@@ -716,6 +803,28 @@ module.exports = function buildInboxRouter() {
|
|
|
716
803
|
developed_at: row.developed_at,
|
|
717
804
|
};
|
|
718
805
|
|
|
806
|
+
// Fail-open, exactly like the capture and resubmit paths: the development has
|
|
807
|
+
// already committed, and a credit-lane hiccup must never roll back somebody's
|
|
808
|
+
// work. The split (and who is in it) is decided inside the economy module.
|
|
809
|
+
try {
|
|
810
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
811
|
+
if (reward && reward.awardIdeaFullPassCredit) {
|
|
812
|
+
const r = await reward.awardIdeaFullPassCredit(api.pool, {
|
|
813
|
+
ideaId: id,
|
|
814
|
+
authorId: idea.captured_by,
|
|
815
|
+
completenessScore: row.completeness_score,
|
|
816
|
+
});
|
|
817
|
+
if (r && r.credited > 0) {
|
|
818
|
+
out.ideator_credit = r.credited;
|
|
819
|
+
// The per-payee breakdown, so a developer can see their share and the
|
|
820
|
+
// author theirs without reading the ledger.
|
|
821
|
+
if (r.payees) out.ideator_credit_split = r.payees;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
} catch (creditErr) {
|
|
825
|
+
api.logger('ideas').error(`develop: pass-credit failed (development intact, credit skipped): ${creditErr && creditErr.message}`);
|
|
826
|
+
}
|
|
827
|
+
|
|
719
828
|
let board_window;
|
|
720
829
|
try {
|
|
721
830
|
const government = api.resolveOptional && api.resolveOptional('government');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.299",
|
|
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.299",
|
|
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.299",
|
|
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",
|
|
@@ -927,6 +927,11 @@ function mountInternalSurfaces(app) {
|
|
|
927
927
|
// flag is open to every rank by design (see the nav comment in shell.js), so
|
|
928
928
|
// the page's only real gate is sign-in — the writes behind it self-gate, and
|
|
929
929
|
// closing someone else's flag is checked server-side at metic-or-author.
|
|
930
|
+
// task 1002928: `ideas` (the R152 Full Idea composer) joins the same tier for
|
|
931
|
+
// the same reason — goal 1000062's settled decision is that there is NO
|
|
932
|
+
// permission gate on Full Ideas; scoring and triage do the filtering. Its two
|
|
933
|
+
// reads (GET /inbox/template, POST /inbox/score) are pure and any-builder, and
|
|
934
|
+
// the file itself goes through POST /inbox, which carries its own rules.
|
|
930
935
|
//
|
|
931
936
|
// NB the assignment below must stay `const NAME_RE = /…/;` with NOTHING between
|
|
932
937
|
// the `=` and the literal: tests/hall_page_gate_map.mjs reconstructs the whole
|
|
@@ -934,7 +939,7 @@ function mountInternalSurfaces(app) {
|
|
|
934
939
|
// the regex out of its map — which reads as "every page on this tier is
|
|
935
940
|
// ungated". It fails loudly by design; keep prose above the const.
|
|
936
941
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
937
|
-
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
942
|
+
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
938
943
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
939
944
|
// page that embeds them.
|
|
940
945
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -1021,8 +1021,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1021
1021
|
// landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
|
|
1022
1022
|
// 1.19.297 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1023
1023
|
// landed since 1.19.296 with no explicit bump. run 32592502621. (task 1002620)
|
|
1024
|
+
// 1.19.298 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1025
|
+
// landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
|
|
1026
|
+
// 1.19.299 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1027
|
+
// landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
|
|
1024
1028
|
// ---------------------------------------------------------------------------
|
|
1025
|
-
const CORE_VERSION = '1.19.
|
|
1029
|
+
const CORE_VERSION = '1.19.299'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1026
1030
|
|
|
1027
1031
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1028
1032
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -137,7 +137,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
137
137
|
// whose subject is a person rather than the product. Deliberate addition to
|
|
138
138
|
// this oracle: the group is pinned exactly so a new tab in the hall's most
|
|
139
139
|
// valuable nav real estate is a reviewed decision, not a drift.
|
|
140
|
-
|
|
140
|
+
// task 1002928 (R152 of goal 1000062): 'ideas' — the Full Idea composer — joins
|
|
141
|
+
// the same group, placed BEFORE copy-desk so the run reads as the four project
|
|
142
|
+
// pages followed by the two CRAFT surfaces side by side: the ideator's queue and
|
|
143
|
+
// the artist's. Same reasoning as copy-desk for why it is here rather than Docs
|
|
144
|
+
// (a place you act, not reference material) or You (its subject is the project).
|
|
145
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk'],
|
|
141
146
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
142
147
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
143
148
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// tests/idea_credit_split.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002923 (BV1.R147, goal 1000062; ADR 0185) — the spark-handoff credit
|
|
4
|
+
// split: 70% developer / 30% spark author, on both ideator streams.
|
|
5
|
+
//
|
|
6
|
+
// The three properties that are REAL BUGS if missed, asserted first:
|
|
7
|
+
// 1. the total is CONSERVED under rounding (round(3.5)+round(1.5) = 6 from a
|
|
8
|
+
// 5-credit payout is the naive mistake);
|
|
9
|
+
// 2. a SELF-DEVELOPED idea collapses to ONE payee — two rows for one builder
|
|
10
|
+
// would be silently UNDER-paid, because the idempotency predicate is
|
|
11
|
+
// (builder_id, reason) and the second insert is refused;
|
|
12
|
+
// 3. zero-value rows are DROPPED, not booked as 0-credit ledger noise.
|
|
13
|
+
//
|
|
14
|
+
// Then the two streams end-to-end against a stub client, and the develop route
|
|
15
|
+
// actually paying (the gap R146 deliberately left open).
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/idea_credit_split.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const credits = require('../modules/economy/credits.js');
|
|
24
|
+
const { splitIdeaCredit, IDEA_DEVELOPER_SHARE, IDEA_FULL_PASS_CREDIT, IDEA_TASK_SHIP_BASE } = credits;
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
async function test(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
30
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sum = (ps) => ps.reduce((n, p) => n + p.delta, 0);
|
|
34
|
+
|
|
35
|
+
// --- property 1: the total is conserved ---------------------------------
|
|
36
|
+
|
|
37
|
+
await test('the split CONSERVES the total at every payout size', () => {
|
|
38
|
+
for (let total = 1; total <= 120; total += 1) {
|
|
39
|
+
const ps = splitIdeaCredit(total, { authorId: 9, developedBy: 42 });
|
|
40
|
+
assert.equal(sum(ps), total, `total ${total} must not gain or lose a credit`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await test('a 5-credit payout splits 4/1, not 4/2 — the rounding trap', () => {
|
|
45
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 42 });
|
|
46
|
+
assert.equal(sum(ps), 5);
|
|
47
|
+
const dev = ps.find((p) => p.role === 'developer');
|
|
48
|
+
const author = ps.find((p) => p.role === 'spark_author');
|
|
49
|
+
assert.equal(dev.delta, 4, 'round(5 * 0.70) = 4');
|
|
50
|
+
assert.equal(author.delta, 1, 'the author gets the REMAINDER, not round(1.5)=2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('the developer gets the larger share, and it tracks the constant', () => {
|
|
54
|
+
assert.equal(IDEA_DEVELOPER_SHARE, 0.70, 'the owner decision, 2026-08-22');
|
|
55
|
+
const ps = splitIdeaCredit(100, { authorId: 9, developedBy: 42 });
|
|
56
|
+
assert.equal(ps.find((p) => p.role === 'developer').delta, 70);
|
|
57
|
+
assert.equal(ps.find((p) => p.role === 'spark_author').delta, 30);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- property 2: self-development collapses -----------------------------
|
|
61
|
+
|
|
62
|
+
await test('a SELF-developed idea is ONE payee at the full amount', () => {
|
|
63
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 9 });
|
|
64
|
+
assert.equal(ps.length, 1, 'two rows for one builder would be silently under-paid');
|
|
65
|
+
assert.equal(ps[0].delta, 5);
|
|
66
|
+
assert.equal(ps[0].builderId, 9);
|
|
67
|
+
assert.equal(ps[0].role, 'author_developer');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('string/number id mismatch still counts as the same person', () => {
|
|
71
|
+
// captured_by and developed_by come back from pg as strings on a bigint column.
|
|
72
|
+
const ps = splitIdeaCredit(5, { authorId: '9', developedBy: 9 });
|
|
73
|
+
assert.equal(ps.length, 1, 'a type difference must not become a phantom second payee');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('an UNDEVELOPED idea pays its author everything', () => {
|
|
77
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: null });
|
|
78
|
+
assert.deepEqual(ps, [{ builderId: 9, delta: 5, role: 'author' }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- property 3: no zero rows -------------------------------------------
|
|
82
|
+
|
|
83
|
+
await test('a 1-credit payout pays the developer 1 and books NOTHING for the author', () => {
|
|
84
|
+
const ps = splitIdeaCredit(1, { authorId: 9, developedBy: 42 });
|
|
85
|
+
assert.equal(sum(ps), 1);
|
|
86
|
+
assert.equal(ps.length, 1, 'a 0-credit ledger row reads like a payment that was not made');
|
|
87
|
+
assert.equal(ps[0].role, 'developer');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('a zero or negative payout pays nobody', () => {
|
|
91
|
+
assert.deepEqual(splitIdeaCredit(0, { authorId: 9, developedBy: 42 }), []);
|
|
92
|
+
assert.deepEqual(splitIdeaCredit(-5, { authorId: 9, developedBy: 42 }), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('no author means no payment at all', () => {
|
|
96
|
+
assert.deepEqual(splitIdeaCredit(5, { authorId: null, developedBy: 42 }), []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --- stream 2, end to end ----------------------------------------------
|
|
100
|
+
|
|
101
|
+
// A stub client that records every INSERT and answers the parties lookup.
|
|
102
|
+
function stubClient({ authorId, developedBy, alreadyPaid = [] }) {
|
|
103
|
+
const inserts = [];
|
|
104
|
+
return {
|
|
105
|
+
inserts,
|
|
106
|
+
query: async (sql, params = []) => {
|
|
107
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
108
|
+
return { rows: [{ captured_by: authorId, developed_by: developedBy }] };
|
|
109
|
+
}
|
|
110
|
+
if (/FROM ideas_task_lineage/i.test(sql)) {
|
|
111
|
+
return { rows: [{ idea_id: '77', captured_by: authorId, grade: 'full', completeness: 100, developed_by: developedBy }] };
|
|
112
|
+
}
|
|
113
|
+
if (/INSERT INTO credit_log/i.test(sql)) {
|
|
114
|
+
const [builderId, , delta, reason] = params;
|
|
115
|
+
if (alreadyPaid.some((a) => String(a.builderId) === String(builderId) && a.reason === reason)) {
|
|
116
|
+
return { rowCount: 0 };
|
|
117
|
+
}
|
|
118
|
+
inserts.push({ builderId, delta, reason, description: params[4] });
|
|
119
|
+
return { rowCount: 1 };
|
|
120
|
+
}
|
|
121
|
+
return { rows: [], rowCount: 0 };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ON = { ideatorCredit: true };
|
|
127
|
+
|
|
128
|
+
await test('stream 2: a developed Full Idea pays BOTH parties under one reason key', async () => {
|
|
129
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
130
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
131
|
+
assert.equal(r.credited, IDEA_FULL_PASS_CREDIT, 'the whole payout is booked, split across two rows');
|
|
132
|
+
assert.equal(c.inserts.length, 2);
|
|
133
|
+
// One reason key for both — which is exactly why the idempotency predicate
|
|
134
|
+
// must include builder_id, or the second payee would be refused.
|
|
135
|
+
assert.equal(new Set(c.inserts.map((i) => i.reason)).size, 1);
|
|
136
|
+
assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
|
|
137
|
+
assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
|
|
138
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
|
|
139
|
+
assert.match(c.inserts.find((i) => i.builderId === '9').description, /spark author/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('stream 2: the PARTIES come from the DB, not from the caller', async () => {
|
|
143
|
+
// A caller naming the wrong author cannot redirect the money — the whole
|
|
144
|
+
// reason R146 could not safely pay.
|
|
145
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
146
|
+
await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '999', completenessScore: 100, config: ON });
|
|
147
|
+
assert.ok(!c.inserts.some((i) => i.builderId === '999'), 'the caller-supplied author is ignored when the row is readable');
|
|
148
|
+
assert.deepEqual(c.inserts.map((i) => i.builderId).sort(), ['42', '9']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('stream 2: an undeveloped Full Idea still pays its author the full 5', async () => {
|
|
152
|
+
const c = stubClient({ authorId: '9', developedBy: null });
|
|
153
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
154
|
+
assert.equal(r.credited, IDEA_FULL_PASS_CREDIT);
|
|
155
|
+
assert.equal(c.inserts.length, 1);
|
|
156
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
157
|
+
assert.equal(c.inserts[0].delta, IDEA_FULL_PASS_CREDIT);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('stream 2: a PARTIALLY booked split completes on a re-run, never double-pays', async () => {
|
|
161
|
+
const reason = 'idea.credit.full_pass:77';
|
|
162
|
+
// The developer's row landed; the author's did not (a crash between inserts).
|
|
163
|
+
const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }] });
|
|
164
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
165
|
+
assert.equal(c.inserts.length, 1, 'only the missing row is written');
|
|
166
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
167
|
+
assert.equal(r.credited, 1, 'credited reports only what was NEWLY booked');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('stream 2: a fully booked split reports already_paid and writes nothing', async () => {
|
|
171
|
+
const reason = 'idea.credit.full_pass:77';
|
|
172
|
+
const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }, { builderId: '9', reason }] });
|
|
173
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
174
|
+
assert.equal(c.inserts.length, 0);
|
|
175
|
+
assert.equal(r.credited, 0);
|
|
176
|
+
assert.equal(r.reason, 'already_paid');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('stream 2: below the bar nobody is paid, developed or not', async () => {
|
|
180
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
181
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 10, config: ON });
|
|
182
|
+
assert.equal(r.credited, 0);
|
|
183
|
+
assert.equal(r.reason, 'below_bar');
|
|
184
|
+
assert.equal(c.inserts.length, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('stream 2: the lane can still be switched off entirely', async () => {
|
|
188
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
189
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: { ideatorCredit: false } });
|
|
190
|
+
assert.equal(r.reason, 'ideator_lane_off');
|
|
191
|
+
assert.equal(c.inserts.length, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- stream 1, end to end ----------------------------------------------
|
|
195
|
+
|
|
196
|
+
await test('stream 1: a task shipped under a DEVELOPED idea splits the same way', async () => {
|
|
197
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
198
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
199
|
+
assert.equal(r.credited, IDEA_TASK_SHIP_BASE, 'a 100% full idea pays the full base, split');
|
|
200
|
+
assert.equal(c.inserts.length, 2);
|
|
201
|
+
assert.equal(sum(c.inserts.map((i) => ({ delta: i.delta }))), IDEA_TASK_SHIP_BASE);
|
|
202
|
+
assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
|
|
203
|
+
assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
|
|
204
|
+
// Same rule, so the same wording — one rule to explain, not two.
|
|
205
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /stream 1/);
|
|
206
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test('stream 1: an undeveloped idea is unchanged — the author gets it all', async () => {
|
|
210
|
+
const c = stubClient({ authorId: '9', developedBy: null });
|
|
211
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
212
|
+
assert.equal(r.credited, IDEA_TASK_SHIP_BASE);
|
|
213
|
+
assert.equal(c.inserts.length, 1);
|
|
214
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('stream 1: the per-(idea,task) reason key still scopes idempotency', async () => {
|
|
218
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
219
|
+
await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
220
|
+
for (const i of c.inserts) assert.equal(i.reason, 'idea.credit.task_ship:77:100');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
console.log(`\nidea_credit_split: ${passed} passed, ${failed} failed`);
|
|
224
|
+
if (failed > 0) process.exit(1);
|
|
@@ -93,9 +93,11 @@ const basePoolQuery = async (sql, params = []) => {
|
|
|
93
93
|
async function test(name, fn) {
|
|
94
94
|
queries = [];
|
|
95
95
|
portsResolved = [];
|
|
96
|
-
// Restored every test:
|
|
97
|
-
//
|
|
96
|
+
// Restored every test: cases below swap in a two-phase pool stub (a triager
|
|
97
|
+
// closing the idea mid-request) and reward/government port stubs, and none of
|
|
98
|
+
// them may leak into the next test.
|
|
98
99
|
api.pool.query = basePoolQuery;
|
|
100
|
+
api.resolveOptional = (name) => { portsResolved.push(name); return undefined; };
|
|
99
101
|
// The author is builder 9; the developer in call() defaults to 42.
|
|
100
102
|
idea = {
|
|
101
103
|
id: '77', title: 'a spark', body_md: 'the thought', status: 'open',
|
|
@@ -293,16 +295,51 @@ await test('a missing idea is a 404, not a 500', async () => {
|
|
|
293
295
|
|
|
294
296
|
// --- the money boundary: R147 owns the split ---------------------------
|
|
295
297
|
|
|
296
|
-
|
|
298
|
+
// R146 shipped this route paying NOTHING on purpose: with two people involved the
|
|
299
|
+
// recipient was ambiguous, and awardIdeaFullPassCredit's idempotency predicate is
|
|
300
|
+
// (builder_id, reason) — so guessing the developer would not have blocked a later
|
|
301
|
+
// payment to the author, and one idea would have paid twice. R147 (ADR 0185)
|
|
302
|
+
// resolved that by moving the payee decision INTO the economy module, so this
|
|
303
|
+
// route can now call it safely: it no longer chooses who earns.
|
|
304
|
+
await test('the stream-2 pass credit IS paid here now, and the route does not pick the payee', async () => {
|
|
305
|
+
let seen = null;
|
|
306
|
+
api.resolveOptional = (name) => {
|
|
307
|
+
portsResolved.push(name);
|
|
308
|
+
if (name === 'reward') {
|
|
309
|
+
return {
|
|
310
|
+
awardIdeaFullPassCredit: async (_client, args) => {
|
|
311
|
+
seen = args;
|
|
312
|
+
return { credited: 5, reason: 'paid', payees: [
|
|
313
|
+
{ builderId: '42', delta: 4, role: 'developer', booked: true },
|
|
314
|
+
{ builderId: '9', delta: 1, role: 'spark_author', booked: true },
|
|
315
|
+
] };
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
297
321
|
const r = await call();
|
|
298
322
|
assert.equal(r.status, 200);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// the
|
|
303
|
-
|
|
304
|
-
assert.
|
|
305
|
-
assert.equal(
|
|
323
|
+
assert.ok(portsResolved.includes('reward'), 'the reward port is consulted');
|
|
324
|
+
assert.equal(r.payload.idea.ideator_credit, 5, 'the total is surfaced');
|
|
325
|
+
assert.equal(r.payload.idea.ideator_credit_split.length, 2, 'and the per-payee breakdown');
|
|
326
|
+
// The route hands over the idea and the score — never a chosen recipient beyond
|
|
327
|
+
// the author fallback the economy module overrides from the DB.
|
|
328
|
+
assert.equal(seen.ideaId, 77);
|
|
329
|
+
assert.equal(seen.completenessScore, 100);
|
|
330
|
+
assert.ok(!('developedBy' in seen), 'the route does not tell economy who developed it');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await test('a credit failure does not roll back the development (fail-open)', async () => {
|
|
334
|
+
api.resolveOptional = (name) => {
|
|
335
|
+
portsResolved.push(name);
|
|
336
|
+
if (name === 'reward') return { awardIdeaFullPassCredit: async () => { throw new Error('synthetic credit outage'); } };
|
|
337
|
+
return undefined;
|
|
338
|
+
};
|
|
339
|
+
const r = await call();
|
|
340
|
+
assert.equal(r.status, 200, 'the development landed');
|
|
341
|
+
assert.equal(r.payload.developed, true);
|
|
342
|
+
assert.equal(r.payload.idea.ideator_credit, undefined);
|
|
306
343
|
});
|
|
307
344
|
|
|
308
345
|
await test('but the board window IS opened — otherwise the handoff dead-ends', async () => {
|