@bongos/core 1.20.58 → 1.20.59
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 +80 -40
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +9 -3
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +9 -9
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +30 -30
- package/migrations/core_268_builders_main_discipline.sql +35 -0
- package/modules/copy-desk/artist-stats.js +56 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +39 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -16
- package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
- package/modules/economy/credits-by-craft.js +208 -0
- package/modules/economy/reward.js +8 -0
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/ideas/ideator-stats.js +149 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +7 -0
- package/modules/lifecycle/activity.js +5 -2
- package/modules/lifecycle/db-analytics.js +4 -2
- package/modules/lifecycle/db-tasks.js +49 -1
- package/modules/lifecycle/db.js +3 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/db.js +44 -6
- package/src/bongos/role-stats.js +155 -0
- package/src/bongos/routes/builders.js +15 -2
- package/src/bongos/routes/me.js +11 -3
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/main_discipline.mjs +81 -0
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
package/release-notes.json
CHANGED
|
@@ -8465,5 +8465,11 @@
|
|
|
8465
8465
|
"id": "1004423",
|
|
8466
8466
|
"text": "A project that is being founded now has a genesis home at the top of its hall. The founder settles a plan in one click (each stage's length, the starting government by its real name with a plain explanation, and how many passe"
|
|
8467
8467
|
}
|
|
8468
|
+
],
|
|
8469
|
+
"1.20.59": [
|
|
8470
|
+
{
|
|
8471
|
+
"id": "1004433",
|
|
8472
|
+
"text": "The profile now knows each person by their own work. An artist's page counts pages approved, how many pages of each part of the site they have tweaked, and what pages earned them; an ideator's counts thoughts filed, how many b"
|
|
8473
|
+
}
|
|
8468
8474
|
]
|
|
8469
8475
|
}
|
|
@@ -155,6 +155,11 @@ const INTEGRATION = new Set([
|
|
|
155
155
|
// predicate still returns 0 so the bug itself stays executable. Same
|
|
156
156
|
// always-rolled-back transaction discipline as the entry above.
|
|
157
157
|
'session_earnings_mirror_db',
|
|
158
|
+
// task 1004433: the credits-by-craft split and the per-person earnings
|
|
159
|
+
// fragment against real SQL. Both are questions about which ledger rows a LIKE
|
|
160
|
+
// pattern and a LATERAL unnest reach, which a stubbed client cannot answer.
|
|
161
|
+
// One transaction, always rolled back.
|
|
162
|
+
'role_stats_db',
|
|
158
163
|
// RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
|
|
159
164
|
// 1003091. A curated set keyed by a stem that no longer exists silently stops
|
|
160
165
|
// covering its file: both of these dropped OUT of the integration set and INTO the
|
package/src/bongos/db.js
CHANGED
|
@@ -149,7 +149,27 @@ async function linkAnthropicAccount(builderId, { email, userId }) {
|
|
|
149
149
|
// updated builder row, or throws { code: 'invalid_discipline', bad } if any
|
|
150
150
|
// element is not offered by this instance.
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
// `mainDiscipline` (task 1004433, core_268) is the builder's own pick of MAIN
|
|
153
|
+
// role, which the canonical-order set cannot carry. Three answers:
|
|
154
|
+
// undefined not mentioned: the stored main role is KEPT while it is still in
|
|
155
|
+
// the new set, and cleared when the builder drops that craft (a main
|
|
156
|
+
// role they no longer practise would lead their profile otherwise);
|
|
157
|
+
// null cleared on purpose;
|
|
158
|
+
// a string set, and it must be one of the crafts in the new set
|
|
159
|
+
// (`invalid_main_discipline`): a main role is one of your roles.
|
|
160
|
+
// The write, spelled once so tests/role_stats_db.mjs runs the very statement
|
|
161
|
+
// against a real Postgres: $3 says whether the caller named a main role at all,
|
|
162
|
+
// $4 is that role (or null to clear it).
|
|
163
|
+
const UPDATE_BUILDER_DISCIPLINES_SQL =
|
|
164
|
+
`UPDATE builders
|
|
165
|
+
SET preferred_disciplines = $2,
|
|
166
|
+
main_discipline = CASE WHEN $3::boolean THEN $4::text
|
|
167
|
+
WHEN main_discipline = ANY($2::text[]) THEN main_discipline
|
|
168
|
+
ELSE NULL END
|
|
169
|
+
WHERE id = $1
|
|
170
|
+
RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, karma, created_at, rank, preferred_disciplines, main_discipline`;
|
|
171
|
+
|
|
172
|
+
async function updateBuilderDisciplines(builderId, disciplines, { mainDiscipline } = {}) {
|
|
153
173
|
const { enabledDisciplines } = require('../modules');
|
|
154
174
|
const offered = enabledDisciplines();
|
|
155
175
|
const list = Array.isArray(disciplines) ? disciplines : [];
|
|
@@ -161,12 +181,28 @@ async function updateBuilderDisciplines(builderId, disciplines) {
|
|
|
161
181
|
throw err;
|
|
162
182
|
}
|
|
163
183
|
const normalized = offered.filter((d) => list.includes(d));
|
|
184
|
+
if (typeof mainDiscipline === 'string' && !normalized.includes(mainDiscipline)) {
|
|
185
|
+
const err = new Error(`main discipline ${mainDiscipline} is not one of the chosen disciplines`);
|
|
186
|
+
err.code = 'invalid_main_discipline';
|
|
187
|
+
err.bad = [mainDiscipline];
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
const setMain = mainDiscipline !== undefined;
|
|
164
191
|
const { rows } = await pool.query(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
192
|
+
UPDATE_BUILDER_DISCIPLINES_SQL,
|
|
193
|
+
[builderId, normalized, setMain, setMain ? mainDiscipline : null]
|
|
194
|
+
);
|
|
195
|
+
return rows[0] ?? null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The two role facts the profile needs beside the public builder row (task
|
|
199
|
+
// 1004433): the preferred set and the chosen main role. Its own read, rather
|
|
200
|
+
// than a column added to getBuilderById, so the kernel's hot identity read is
|
|
201
|
+
// untouched by a profile feature.
|
|
202
|
+
async function getBuilderRoleChoice(builderId) {
|
|
203
|
+
const { rows } = await pool.query(
|
|
204
|
+
`SELECT preferred_disciplines, main_discipline FROM builders WHERE id = $1`,
|
|
205
|
+
[builderId]
|
|
170
206
|
);
|
|
171
207
|
return rows[0] ?? null;
|
|
172
208
|
}
|
|
@@ -1065,6 +1101,8 @@ module.exports = {
|
|
|
1065
1101
|
getBuilderByGithubId,
|
|
1066
1102
|
hasInvitedAccessRequest,
|
|
1067
1103
|
updateBuilderDisciplines,
|
|
1104
|
+
UPDATE_BUILDER_DISCIPLINES_SQL,
|
|
1105
|
+
getBuilderRoleChoice,
|
|
1068
1106
|
RANKS,
|
|
1069
1107
|
setBuilderRank,
|
|
1070
1108
|
getLatestRankChangeForBuilder,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/role-stats.js — EACH ROLE BY ITS OWN WORK: the reads behind the
|
|
4
|
+
// role-shaped profile (task 1004433 / WA6.RP03, goal 1000095, criterion
|
|
5
|
+
// wa6-roles-read-true-in-public). The owner-approved mock is
|
|
6
|
+
// docs/design/mocks/profile-roles/ (task 1004431); RP04 to RP07 draw from this.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT ANSWERS. For one builder: which of the three crafts they are shown in,
|
|
9
|
+
// which one leads, and each craft's own headline numbers.
|
|
10
|
+
//
|
|
11
|
+
// engineer `shipped`, works shipped (unchanged from today), and engineering credits
|
|
12
|
+
// artist pages approved, "N of M tweaked" per surface, credits from pages,
|
|
13
|
+
// this week (copy-desk's studio tally, the `copy-desk.artistStats`
|
|
14
|
+
// port)
|
|
15
|
+
// ideator thoughts filed, ignited, Full Ideas, ratified by the board, total
|
|
16
|
+
// size across every idea stream with the historical-estimate
|
|
17
|
+
// marker, and the share still alive (the sky's own reads, the
|
|
18
|
+
// `ideas.ideatorStats` port)
|
|
19
|
+
//
|
|
20
|
+
// Plus `credits_by_craft`, the ledger split of total_credits (economy's
|
|
21
|
+
// `reward.creditsByCraft`), which is what makes an artist or an ideator who has
|
|
22
|
+
// never shipped a task read as what they are.
|
|
23
|
+
//
|
|
24
|
+
// THE OWNER'S RULES (2026-09-30), and where each lives:
|
|
25
|
+
// • one person, one profile; the builder CHOOSES their main role
|
|
26
|
+
// (builders.main_discipline, core_268). Until they do, the main role is the
|
|
27
|
+
// craft they have earned most in, then their first preferred craft, then
|
|
28
|
+
// engineering, and `main_basis` says which of those it is, so a surface can
|
|
29
|
+
// tell a choice from a guess.
|
|
30
|
+
// • any OTHER role shows only once it has earned credits. `shown` is the main
|
|
31
|
+
// role first, then each craft with credits, in preferred order. A craft not
|
|
32
|
+
// in `shown` carries no stats block (null): its tab and chip do not exist.
|
|
33
|
+
// • decision (a): the identity band, the tally and "By the numbers" stay
|
|
34
|
+
// public, so everything here is counts and sums. No idea title, no page
|
|
35
|
+
// wording, no task list leaves this read; those stay behind a sign-in on the
|
|
36
|
+
// Activity routes. The answer is the same for every viewer.
|
|
37
|
+
//
|
|
38
|
+
// A port that is absent (a vanilla instance without the module) gives that
|
|
39
|
+
// block `null` fields rather than zeros: "could not ask" is not "nothing there".
|
|
40
|
+
|
|
41
|
+
const seams = require('../module-seams');
|
|
42
|
+
|
|
43
|
+
const CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
|
|
44
|
+
// A discipline as a craft, asked of economy (reward.craftForPreference), the
|
|
45
|
+
// module that decides which craft a credit belongs to. One table, so a builder's
|
|
46
|
+
// chips and their credits cannot disagree (`ui`, the design discipline, is the
|
|
47
|
+
// artist's craft there). With no economy module there are no credits to split
|
|
48
|
+
// either, and only the three craft names read as crafts.
|
|
49
|
+
function craftOf(discipline) {
|
|
50
|
+
const reward = seams.resolveOptional('reward');
|
|
51
|
+
if (reward && typeof reward.craftForPreference === 'function') return reward.craftForPreference(discipline);
|
|
52
|
+
return CRAFTS.includes(discipline) ? discipline : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function preferredCrafts(preferred) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const d of Array.isArray(preferred) ? preferred : []) {
|
|
58
|
+
const c = craftOf(d);
|
|
59
|
+
if (c && !out.includes(c)) out.push(c);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// resolveMainRole — pure. The chosen craft, else the earned one, else the
|
|
65
|
+
// preferred one, else engineering (the platform's default craft).
|
|
66
|
+
function resolveMainRole({ mainDiscipline = null, preferred = [], byCraft = null } = {}) {
|
|
67
|
+
const chosen = craftOf(mainDiscipline);
|
|
68
|
+
if (chosen) return { main: chosen, basis: 'chosen' };
|
|
69
|
+
if (byCraft) {
|
|
70
|
+
let best = null;
|
|
71
|
+
for (const c of CRAFTS) {
|
|
72
|
+
const n = Number(byCraft[c]) || 0;
|
|
73
|
+
if (n > 0 && (!best || n > best.n)) best = { c, n };
|
|
74
|
+
}
|
|
75
|
+
if (best) return { main: best.c, basis: 'earned' };
|
|
76
|
+
}
|
|
77
|
+
const pref = preferredCrafts(preferred);
|
|
78
|
+
if (pref.length) return { main: pref[0], basis: 'preferred' };
|
|
79
|
+
return { main: 'engineer', basis: 'default' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// shownCrafts — pure. Main first, then every craft with credits, in the
|
|
83
|
+
// builder's preferred order and then CRAFTS order.
|
|
84
|
+
function shownCrafts({ main, preferred = [], byCraft = null } = {}) {
|
|
85
|
+
const order = [...preferredCrafts(preferred), ...CRAFTS].filter((c, i, a) => a.indexOf(c) === i);
|
|
86
|
+
const out = [main];
|
|
87
|
+
for (const c of order) {
|
|
88
|
+
if (c === main) continue;
|
|
89
|
+
if (byCraft && Number(byCraft[c]) > 0) out.push(c);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// composeRoleStats — pure: the facts in, the profile's `roles` block out.
|
|
95
|
+
function composeRoleStats({ choice = null, byCraft = null, shippedCount = 0, artist = null, ideator = null } = {}) {
|
|
96
|
+
const preferred = (choice && choice.preferred_disciplines) || [];
|
|
97
|
+
const { main, basis } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
|
|
98
|
+
const shown = shownCrafts({ main, preferred, byCraft });
|
|
99
|
+
const credits = byCraft
|
|
100
|
+
? { engineer: byCraft.engineer, artist: byCraft.artist, ideator: byCraft.ideator, other: byCraft.other, total: byCraft.total }
|
|
101
|
+
: null;
|
|
102
|
+
return {
|
|
103
|
+
main,
|
|
104
|
+
main_basis: basis,
|
|
105
|
+
shown,
|
|
106
|
+
preferred: preferredCrafts(preferred),
|
|
107
|
+
credits_by_craft: credits,
|
|
108
|
+
engineer: shown.includes('engineer')
|
|
109
|
+
? { shipped: Number(shippedCount) || 0, credits: credits ? credits.engineer : null }
|
|
110
|
+
: null,
|
|
111
|
+
artist: shown.includes('artist') ? (artist || emptyArtist()) : null,
|
|
112
|
+
ideator: shown.includes('ideator') ? (ideator || emptyIdeator()) : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The shapes a shown craft takes when its port could not be asked: every number
|
|
117
|
+
// null, so a renderer says "unknown", never "0".
|
|
118
|
+
function emptyArtist() {
|
|
119
|
+
return { pages_approved: null, pages: null, surfaces_touched: null, credits: null, this_week: null, surfaces: [], unavailable: true };
|
|
120
|
+
}
|
|
121
|
+
function emptyIdeator() {
|
|
122
|
+
return {
|
|
123
|
+
filed: null, ignited: null, full_ideas: null, ratified: null,
|
|
124
|
+
size: { total: null, paid: null, estimated: null, historical: null },
|
|
125
|
+
alive: { share: null, has_work: null, attributable: null },
|
|
126
|
+
unavailable: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// readRoleStats — the I/O half. The crafts are decided FIRST, from the ledger,
|
|
131
|
+
// so the heavier per-craft reads run only for a craft that will be shown.
|
|
132
|
+
async function readRoleStats(builderId, { shippedCount = 0, getRoleChoice } = {}) {
|
|
133
|
+
const reward = seams.resolveOptional('reward');
|
|
134
|
+
const [choice, byCraftMap] = await Promise.all([
|
|
135
|
+
getRoleChoice ? getRoleChoice(builderId) : null,
|
|
136
|
+
reward && typeof reward.creditsByCraft === 'function' ? reward.creditsByCraft([builderId]) : null,
|
|
137
|
+
]);
|
|
138
|
+
const byCraft = byCraftMap ? (byCraftMap.get(String(builderId)) || null) : null;
|
|
139
|
+
const preferred = (choice && choice.preferred_disciplines) || [];
|
|
140
|
+
const { main } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
|
|
141
|
+
const shown = shownCrafts({ main, preferred, byCraft });
|
|
142
|
+
|
|
143
|
+
const artistPort = shown.includes('artist') ? seams.resolveOptional('copy-desk.artistStats') : null;
|
|
144
|
+
const ideatorPort = shown.includes('ideator') ? seams.resolveOptional('ideas.ideatorStats') : null;
|
|
145
|
+
const [artist, ideator] = await Promise.all([
|
|
146
|
+
typeof artistPort === 'function' ? artistPort(builderId) : null,
|
|
147
|
+
typeof ideatorPort === 'function' ? ideatorPort(builderId) : null,
|
|
148
|
+
]);
|
|
149
|
+
return composeRoleStats({ choice, byCraft, shippedCount, artist, ideator });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Only the reader is exported: the pure halves above are reached through it
|
|
153
|
+
// (tests/profile_role_stats.mjs drives them via the real route), and an export
|
|
154
|
+
// nothing imports is dead surface to the knip ratchet.
|
|
155
|
+
module.exports = { readRoleStats };
|
|
@@ -33,6 +33,7 @@ const { validateOrRespond, LIMITS, parseId } = require('./_helpers');
|
|
|
33
33
|
// for the roster-visible checklist (read/shape) — never a direct require.
|
|
34
34
|
const { pool } = require('../pool');
|
|
35
35
|
const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
|
|
36
|
+
const { readRoleStats } = require('../role-stats');
|
|
36
37
|
const seams = require('../../module-seams');
|
|
37
38
|
const { branding } = require('../../branding');
|
|
38
39
|
// The project's stale-claim timer (task 1003476, goal 1000072) — kernel config, so a
|
|
@@ -503,6 +504,11 @@ module.exports = function buildBuildersRouter() {
|
|
|
503
504
|
// monthly_budget_usd / system_role) are NEVER spread into the
|
|
504
505
|
// JSON. A leak-prevention unit test (tests/profile_route.mjs) asserts their
|
|
505
506
|
// absence + the 404-on-unknown-id behaviour.
|
|
507
|
+
// `roles` (task 1004433) is the one block that is NOT a re-presentation of the
|
|
508
|
+
// leaderboard: per-craft counts and sums. The owner made them public on
|
|
509
|
+
// 2026-09-30 (RP01 decision (a): the tally and By the numbers stay public, the
|
|
510
|
+
// trail, the earnings and every Activity tab need a sign-in), so the block
|
|
511
|
+
// carries counts only — no idea title, no page wording, no task list.
|
|
506
512
|
// THROTTLED since task 1002804. The global limiter meters WRITES only
|
|
507
513
|
// (rateLimitMiddleware returns early for GET) and the public read limiter is
|
|
508
514
|
// mounted under /public/*, which this is not — so this route answered at line
|
|
@@ -535,12 +541,17 @@ module.exports = function buildBuildersRouter() {
|
|
|
535
541
|
// the same indistinguishable answer cross_project already gives.
|
|
536
542
|
// Optional-chained so an older hub provider without the function degrades
|
|
537
543
|
// to null rather than throwing across a version window.
|
|
538
|
-
const
|
|
539
|
-
|
|
544
|
+
const shippedCount = await lc().countShippedTasksForBuilder(targetId);
|
|
545
|
+
// roles: each craft by its own work (task 1004433 / WA6.RP03) — which
|
|
546
|
+
// crafts show, which leads, and each one's headline numbers. Counts and
|
|
547
|
+
// sums only, the same for every viewer: the owner's decision (a) keeps the
|
|
548
|
+
// tally and By the numbers public and everything under them signed-in.
|
|
549
|
+
const [recentShips, achievements, crossProject, handle, roles] = await Promise.all([
|
|
540
550
|
lc().recentShippedTasksByBuilder(targetId, 5),
|
|
541
551
|
reward ? reward.getBuilderAchievements(targetId) : [],
|
|
542
552
|
hub ? hub.getPublicProfileExtras(b.github_id) : null,
|
|
543
553
|
hub && hub.getPublicHandle ? hub.getPublicHandle(b.github_id) : null,
|
|
554
|
+
readRoleStats(targetId, { shippedCount, getRoleChoice: db.getBuilderRoleChoice }),
|
|
544
555
|
]);
|
|
545
556
|
// WHITELIST — only the leaderboard-equivalent public field set. Never spread
|
|
546
557
|
// the raw row: it carries anthropic_email / anthropic_user_id /
|
|
@@ -573,6 +584,7 @@ module.exports = function buildBuildersRouter() {
|
|
|
573
584
|
shipped_count: shippedCount,
|
|
574
585
|
recent_ships: recentShips,
|
|
575
586
|
achievements,
|
|
587
|
+
roles,
|
|
576
588
|
cross_project: crossProject,
|
|
577
589
|
handle: handle || null,
|
|
578
590
|
});
|
|
@@ -582,6 +594,7 @@ module.exports = function buildBuildersRouter() {
|
|
|
582
594
|
shipped_count: shippedCount,
|
|
583
595
|
recent_ships: recentShips,
|
|
584
596
|
achievements,
|
|
597
|
+
roles,
|
|
585
598
|
});
|
|
586
599
|
} catch (err) {
|
|
587
600
|
log.error('[gds] GET /builders/:id/profile', err);
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -554,16 +554,24 @@ module.exports = function buildMeRouter() {
|
|
|
554
554
|
// profile. 422 invalid_discipline if any element is out of range.
|
|
555
555
|
// rank: any-builder — self-service profile edit; documented "no rank gate" in the
|
|
556
556
|
// handler comment above (a builder owns their own preference).
|
|
557
|
+
// `main_discipline` (optional, task 1004433): the builder's own MAIN role, one
|
|
558
|
+
// of the crafts in the same body; null clears it, absent keeps it (see
|
|
559
|
+
// db.updateBuilderDisciplines). 422 invalid_main_discipline otherwise.
|
|
557
560
|
router.patch('/me/disciplines', auth.requireBuilder, async (req, res) => {
|
|
558
561
|
if (validateOrRespond(req, res, {
|
|
559
562
|
preferred_disciplines: { required: true, type: 'array', maxItems: 16, itemsType: 'string' },
|
|
563
|
+
main_discipline: { type: 'string', maxLength: 32 },
|
|
560
564
|
})) return;
|
|
561
565
|
const input = req.body?.preferred_disciplines;
|
|
562
566
|
if (!Array.isArray(input)) {
|
|
563
567
|
return res.fail('preferred_disciplines must be an array', 400);
|
|
564
568
|
}
|
|
569
|
+
const body = req.body || {};
|
|
570
|
+
const mainOpt = Object.prototype.hasOwnProperty.call(body, 'main_discipline')
|
|
571
|
+
? { mainDiscipline: body.main_discipline === null ? null : String(body.main_discipline) }
|
|
572
|
+
: {};
|
|
565
573
|
try {
|
|
566
|
-
const builder = await db.updateBuilderDisciplines(req.builder.id, input);
|
|
574
|
+
const builder = await db.updateBuilderDisciplines(req.builder.id, input, mainOpt);
|
|
567
575
|
// The onboarding state machine carved into modules/onboarding/ (BV1.R81 /
|
|
568
576
|
// ADR 0093 §1): resolve the `onboarding` kernel port for the mark + echo —
|
|
569
577
|
// never a direct require of a module. Default-on (present in prod); degrades
|
|
@@ -587,8 +595,8 @@ module.exports = function buildMeRouter() {
|
|
|
587
595
|
const onboarding = onboardingPort ? onboardingPort.shapeForApi(state, { shipCount, discordLinked: !!discordLink, cliAuthed: !!cliAuthed }) : null;
|
|
588
596
|
res.json({ builder, onboarding });
|
|
589
597
|
} catch (err) {
|
|
590
|
-
if (err.code === 'invalid_discipline') {
|
|
591
|
-
return res.fail(
|
|
598
|
+
if (err.code === 'invalid_discipline' || err.code === 'invalid_main_discipline') {
|
|
599
|
+
return res.fail(err.code, 422, { bad: err.bad });
|
|
592
600
|
}
|
|
593
601
|
throw err;
|
|
594
602
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.59'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -35,7 +35,7 @@ api.pool = {
|
|
|
35
35
|
async query(sql, params = []) {
|
|
36
36
|
const q = flat(sql);
|
|
37
37
|
seen.push({ q, params });
|
|
38
|
-
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
38
|
+
if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) {
|
|
39
39
|
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
40
40
|
// The fixture rows stand in for this SELECT, so every column they carry
|
|
41
41
|
// must be one the SELECT really names: a renamed alias would otherwise
|
|
@@ -188,7 +188,7 @@ export function installPageLedger(api, { builders }) {
|
|
|
188
188
|
return { rows: db.tasks.filter((t) => t.status === 'shipped' && Date.parse(t.shipped_at) > since && (t.touches || []).some((f) => p[0].includes(f))).map((t) => ({ id: t.id, title: t.title, shipped_at: t.shipped_at })) };
|
|
189
189
|
}
|
|
190
190
|
// The page reads (listPageTweakTasks), computed from the ledger's state.
|
|
191
|
-
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
191
|
+
if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) {
|
|
192
192
|
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
193
193
|
// The optional page scope (task 1004319): $1 is the round-ref prefix or null.
|
|
194
194
|
assert.match(q, /left\(t\.source_ref, length\(\$1::text\)\) = \$1::text/);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// tests/main_discipline.mjs — the builder's own MAIN role (task 1004433 /
|
|
2
|
+
// WA6.RP03, core_268): what updateBuilderDisciplines sends for each of the
|
|
3
|
+
// three answers, and what it refuses before anything is written.
|
|
4
|
+
//
|
|
5
|
+
// The statement's own behaviour (keep, clear, set) runs against a real Postgres
|
|
6
|
+
// in tests/role_stats_db.mjs; this pins the half before it, DB-free: the
|
|
7
|
+
// "named or not" flag, the value, and the refusal of a main role that is not
|
|
8
|
+
// one of the builder's chosen crafts.
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
let passed = 0;
|
|
16
|
+
let failed = 0;
|
|
17
|
+
async function t(name, fn) {
|
|
18
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
19
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const calls = [];
|
|
23
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
24
|
+
require.cache[poolPath] = {
|
|
25
|
+
id: poolPath, filename: poolPath, loaded: true,
|
|
26
|
+
exports: {
|
|
27
|
+
pool: {
|
|
28
|
+
async query(sql, params) {
|
|
29
|
+
calls.push({ sql, params });
|
|
30
|
+
return { rows: [{ id: params[0], preferred_disciplines: params[1], main_discipline: params[2] ? params[3] : null }] };
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const db = require('../src/bongos/db.js');
|
|
36
|
+
const { enabledDisciplines } = require('../src/modules.js');
|
|
37
|
+
const offered = enabledDisciplines();
|
|
38
|
+
const last = () => calls[calls.length - 1];
|
|
39
|
+
|
|
40
|
+
await t('the write goes through the one exported statement', async () => {
|
|
41
|
+
await db.updateBuilderDisciplines(7, ['engineer']);
|
|
42
|
+
assert.equal(last().sql, db.UPDATE_BUILDER_DISCIPLINES_SQL);
|
|
43
|
+
assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /main_discipline = CASE WHEN \$3::boolean THEN \$4::text/);
|
|
44
|
+
assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /WHEN main_discipline = ANY\(\$2::text\[\]\) THEN main_discipline\s+ELSE NULL END/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await t('not mentioned: the flag is false, so the stored main role is left to the statement to keep or clear', async () => {
|
|
48
|
+
await db.updateBuilderDisciplines(7, ['engineer', 'ideator']);
|
|
49
|
+
assert.deepEqual(last().params.slice(2), [false, null]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await t('named: the flag is true and the role travels', async () => {
|
|
53
|
+
await db.updateBuilderDisciplines(7, ['ideator', 'engineer'], { mainDiscipline: 'ideator' });
|
|
54
|
+
assert.deepEqual(last().params.slice(2), [true, 'ideator']);
|
|
55
|
+
assert.deepEqual(last().params[1], offered.filter((d) => ['ideator', 'engineer'].includes(d)),
|
|
56
|
+
'the set is still stored in canonical order: the main role is what carries the choice');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t('null clears it on purpose', async () => {
|
|
60
|
+
await db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: null });
|
|
61
|
+
assert.deepEqual(last().params.slice(2), [true, null]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t('a main role that is not one of the chosen crafts is refused, and nothing is written', async () => {
|
|
65
|
+
const before = calls.length;
|
|
66
|
+
await assert.rejects(() => db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: 'ideator' }),
|
|
67
|
+
(err) => err.code === 'invalid_main_discipline' && err.bad[0] === 'ideator');
|
|
68
|
+
assert.equal(calls.length, before);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t('the PATCH route hands main_discipline through only when the body names it', async () => {
|
|
72
|
+
const fs = require('node:fs');
|
|
73
|
+
const src = fs.readFileSync(require.resolve('../src/bongos/routes/me.js'), 'utf8');
|
|
74
|
+
const route = src.slice(src.indexOf("router.patch('/me/disciplines'"), src.indexOf("router.patch('/me/disciplines'") + 2500);
|
|
75
|
+
assert.match(route, /hasOwnProperty\.call\(body, 'main_discipline'\)/);
|
|
76
|
+
assert.match(route, /db\.updateBuilderDisciplines\(req\.builder\.id, input, mainOpt\)/);
|
|
77
|
+
assert.match(route, /invalid_main_discipline/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
81
|
+
if (failed) process.exit(1);
|