@bongos/core 1.19.381 → 1.19.383

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.
@@ -554,6 +554,97 @@ async function countOpenIdeas({ viewerRank = null } = {}) {
554
554
  return rows[0].n;
555
555
  }
556
556
 
557
+ // -------------------------------------------------------------------------
558
+ // The idea TRAIL — every idea one builder filed, and what became of it
559
+ // (task 1003281, goal 1000072, criterion legibility-activity-by-role).
560
+ //
561
+ // THE READ THAT EXISTS NOWHERE ELSE, and the reason the criterion was written:
562
+ // the owner could file an idea and then never find out what happened to it.
563
+ // Every other idea read is scoped to what is STILL OPEN — listOpenIdeas, the
564
+ // spark queue, the nod queue — so a promoted, discarded or merged idea drops
565
+ // out of every surface the moment it is triaged, which is exactly when its
566
+ // outcome becomes interesting. This read is scoped to a PERSON instead of to a
567
+ // status, so nothing they filed ever leaves it.
568
+ //
569
+ // NO NEW STORAGE. The four outcomes are already on the row: `status` says which
570
+ // one, `promoted_to_task_id` + ideas_task_lineage say which task a promotion
571
+ // produced, `merged_into_idea_id` says which idea absorbed a merge, and
572
+ // `triage_note` carries the triager's sentence (core_195, task 1002447). The
573
+ // joins below turn those ids into names so a reader gets "became task #1003281
574
+ // — See what you did, by role" rather than a number they must go look up.
575
+ //
576
+ // AN OPEN IDEA IS AN OUTCOME. It reads as "still open", never as an absence —
577
+ // one of the four the criterion names. That is why status is returned verbatim
578
+ // and the surface renders it, rather than the read filtering open rows out.
579
+ //
580
+ // LINEAGE, NOT JUST THE LEGACY COLUMN. One idea can become MANY tasks
581
+ // (ideas_task_lineage, task 1002997), while `promoted_to_task_id` holds only the
582
+ // first. Both are returned: the lineage array is the complete answer, the legacy
583
+ // column is the only answer for promotions that predate the lineage table. The
584
+ // LATERAL is bounded by the page window, so this stays one query, not an N+1.
585
+ const IDEA_TRAIL_DEFAULT_LIMIT = 20;
586
+ const IDEA_TRAIL_MAX_LIMIT = 100;
587
+
588
+ // The WHERE this list and its count share, so the pager can never claim rows the
589
+ // list will not show. viewerRank applies the SAME skill-friction gate as every
590
+ // other idea read (V3.R70 / #289) — including on a builder reading their OWN
591
+ // trail: the gate is about what a rank may see, not about authorship.
592
+ function ideaTrailWhere(builderId, viewerRank, params) {
593
+ params.push(builderId);
594
+ const where = [`i.captured_by = $${params.length}`];
595
+ if (!rankCanSeeSkillFriction(viewerRank)) {
596
+ params.push(`${SKILL_FRICTION_TAG}%`);
597
+ where.push(`i.title NOT LIKE $${params.length}`);
598
+ }
599
+ return where.join(' AND ');
600
+ }
601
+
602
+ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_LIMIT, offset = 0, viewerRank = null } = {}, deps = {}) {
603
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
604
+ const lim = Math.min(Math.max(Number.isInteger(Number(limit)) ? Number(limit) : IDEA_TRAIL_DEFAULT_LIMIT, 1), IDEA_TRAIL_MAX_LIMIT);
605
+ const off = Number.isInteger(Number(offset)) && Number(offset) > 0 ? Number(offset) : 0;
606
+ const params = [];
607
+ const where = ideaTrailWhere(builderId, viewerRank, params);
608
+ params.push(lim, off);
609
+ const { rows } = await activePool.query(
610
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
611
+ pt.title AS promoted_task_title,
612
+ pt.status AS promoted_task_status,
613
+ mi.title AS merged_into_idea_title,
614
+ mi.status AS merged_into_idea_status,
615
+ COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
616
+ FROM idea_inbox i
617
+ ${IDEA_JOIN_GRADE}
618
+ LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
619
+ LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
620
+ LEFT JOIN LATERAL (
621
+ SELECT json_agg(json_build_object(
622
+ 'task_id', l.task_id, 'origin', l.origin,
623
+ 'title', lt.title, 'status', lt.status
624
+ ) ORDER BY l.task_id) AS tasks
625
+ FROM ideas_task_lineage l
626
+ JOIN tasks lt ON lt.id = l.task_id
627
+ WHERE l.idea_id = i.id
628
+ ) lin ON true
629
+ WHERE ${where}
630
+ ORDER BY i.captured_at DESC, i.id DESC
631
+ LIMIT $${params.length - 1} OFFSET $${params.length}`,
632
+ params
633
+ );
634
+ return rows;
635
+ }
636
+
637
+ async function countIdeaTrailForBuilder(builderId, { viewerRank = null } = {}, deps = {}) {
638
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
639
+ const params = [];
640
+ const where = ideaTrailWhere(builderId, viewerRank, params);
641
+ const { rows } = await activePool.query(
642
+ `SELECT count(*)::int AS n FROM idea_inbox i WHERE ${where}`,
643
+ params
644
+ );
645
+ return rows[0].n;
646
+ }
647
+
557
648
  // -------------------------------------------------------------------------
558
649
  // Update / merge
559
650
  // -------------------------------------------------------------------------
@@ -1298,6 +1389,13 @@ module.exports = {
1298
1389
  // the spark queue (task 1002921, BV1.R145) — sparks awaiting development
1299
1390
  listSparkQueue,
1300
1391
  countSparkQueue,
1392
+ // the idea TRAIL (task 1003281) — every idea one builder filed, with what
1393
+ // became of it. Scoped to a person, not to a status, so a triaged idea never
1394
+ // drops out of the one surface that answers "what happened to my idea".
1395
+ listIdeaTrailForBuilder,
1396
+ countIdeaTrailForBuilder,
1397
+ IDEA_TRAIL_DEFAULT_LIMIT,
1398
+ IDEA_TRAIL_MAX_LIMIT,
1301
1399
  // criterion-proposal ratification (V3.R31 / #252)
1302
1400
  ratifyCriterionProposal,
1303
1401
  parseProposalBody,
@@ -470,6 +470,45 @@ module.exports = function buildInboxRouter() {
470
470
  ]);
471
471
  return res.json({ sparks, count });
472
472
  }, { errorCode: 'spark_queue_failed' }));
473
+ // rank: any-builder — THE IDEA TRAIL: every idea one builder filed, newest
474
+ // first, with what became of each (task 1003281, goal 1000072, criterion
475
+ // legibility-activity-by-role). Backs the ideator view of the profile's
476
+ // activity log; the tasks and goals halves of that log are served by the core
477
+ // GET /builders/:id/activity, because they are lifecycle rows, not ideas.
478
+ //
479
+ // WHY IT IS A LIST ROUTE AND NOT A FIELD ON THE PROFILE: the trail is paged.
480
+ // A prolific filer's trail is long, and the criterion asks for "every idea you
481
+ // submitted", so it cannot be a top-N attached to another response.
482
+ //
483
+ // Gated to any signed-in builder, matching GET /inbox/:id directly below: idea
484
+ // TITLES and triage notes are builder-visible, not world-visible, and the
485
+ // public profile route deliberately carries none of them. The viewer's rank
486
+ // rides into the read so the skill-friction gate holds here as it does on
487
+ // every other idea list.
488
+ //
489
+ // DECLARED BEFORE `GET /inbox/:id` DELIBERATELY — the fourth route in this file
490
+ // to need that. `/inbox/by-builder/41` would otherwise bind `:id='by-builder'`
491
+ // and parseId would 400 the whole trail. tests/profile_activity.mjs pins the
492
+ // relative order.
493
+ router.get('/inbox/by-builder/:id', api.requireBuilder, api.asyncHandler('GET /inbox/by-builder/:id', async (req, res) => {
494
+ const builderId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
495
+ if (builderId === null) return;
496
+ const limitRaw = Number(req.query.limit);
497
+ const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= inbox.IDEA_TRAIL_MAX_LIMIT
498
+ ? limitRaw
499
+ : inbox.IDEA_TRAIL_DEFAULT_LIMIT;
500
+ const offsetRaw = Number(req.query.offset);
501
+ const offset = Number.isInteger(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0;
502
+ // Both halves take the viewer's rank so the count above the list can never
503
+ // claim rows the list will not show (the /inbox/sparks precedent).
504
+ const viewerRank = req.builder && req.builder.rank;
505
+ const [ideas, total] = await Promise.all([
506
+ inbox.listIdeaTrailForBuilder(builderId, { limit, offset, viewerRank }),
507
+ inbox.countIdeaTrailForBuilder(builderId, { viewerRank }),
508
+ ]);
509
+ return res.json({ builder_id: builderId, ideas, total, limit, offset });
510
+ }, { errorCode: 'idea_trail_failed' }));
511
+
473
512
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
474
513
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
475
514
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/activity.js — a builder's OWN track record, sliced by craft
4
+ // (task 1003281, goal 1000072, criterion legibility-activity-by-role).
5
+ //
6
+ // WHY IT EXISTS. The profile already answers "how much has this person shipped"
7
+ // (a count and the last five works). It could not answer "what did I do AS an
8
+ // artist", which is a different question from the engineer one and the reason
9
+ // the criterion asks for a view per role. These two reads are that axis: the
10
+ // tasks a builder completed and the goals they created, both filterable by
11
+ // craft and both PAGED — a prolific builder's record is long, and the goal's
12
+ // own scale check ("~1,357 tasks on BONGOS-V1 alone") applies to one person's
13
+ // slice of it too.
14
+ //
15
+ // "ROLE" HERE MEANS CRAFT — tasks.discipline (engineer | artist | ideator | ui |
16
+ // unclassified), the axis ADR 0086 already has and ADR 0174 reserves the word
17
+ // "role" for. A goal has no discipline column and deliberately must not get one
18
+ // (the same call task 1003278 made for the Goals page): a goal's craft set is
19
+ // what its tasks carry, derived here, never stored.
20
+ //
21
+ // UNCLASSIFIED IS NOT HIDDEN. Most tasks in the corpus carry
22
+ // discipline='unclassified', so a surface that only ever showed the four named
23
+ // crafts would silently drop the majority of a person's record and read as
24
+ // "you did less than you did". Passing no craft returns everything — that is
25
+ // the profile's default view, and the per-craft tabs narrow from it.
26
+ //
27
+ // PRIVATE GOALS STAY PRIVATE (ADR 0112's leak rule). A goal's prose — its title
28
+ // and subtitle — is the secret a non-member must not learn, so a private goal
29
+ // appears in this read ONLY for a member of it or an Archon. That makes the
30
+ // goals half viewer-dependent, which is why the route that serves it is
31
+ // signed-in rather than public.
32
+ //
33
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
34
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
35
+
36
+ const api = require('../../src/module-api');
37
+ const { pool } = api;
38
+ const { SMOKE_NOT_LIKE } = require('./db-shared.js');
39
+ const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
40
+
41
+ // The craft vocabulary this read accepts. Mirrors tasks_discipline_check
42
+ // (migrations 086 + 176). Anything else is treated as "no filter" rather than
43
+ // 400ing the read: a caller naming a craft that does not exist gets the honest
44
+ // full list, not an error card in the middle of a profile.
45
+ const CRAFTS = ['engineer', 'artist', 'ideator', 'ui', 'unclassified'];
46
+
47
+ // One page of an activity list. Clamped here as well as at the route, because
48
+ // these are the functions a CLI or a later surface will call directly.
49
+ const DEFAULT_LIMIT = 20;
50
+ const MAX_LIMIT = 100;
51
+
52
+ function normalizeCraft(role) {
53
+ return CRAFTS.includes(role) ? role : null;
54
+ }
55
+
56
+ function clampLimit(limit) {
57
+ const n = Number(limit);
58
+ if (!Number.isInteger(n) || n < 1) return DEFAULT_LIMIT;
59
+ return Math.min(n, MAX_LIMIT);
60
+ }
61
+
62
+ function clampOffset(offset) {
63
+ const n = Number(offset);
64
+ return Number.isInteger(n) && n > 0 ? n : 0;
65
+ }
66
+
67
+ // activityTasksByBuilder — the tasks this builder COMPLETED, newest first.
68
+ //
69
+ // "Completed" is `shipped_by = me AND status = 'shipped'` — the same definition
70
+ // the profile's Recent works already uses (recentShippedTasksByBuilder), so the
71
+ // ledger and the count above it cannot disagree. Smoke tasks are excluded for
72
+ // the same reason they are excluded there: they are the ship pipeline testing
73
+ // itself, not something a person did.
74
+ //
75
+ // Returns { rows, total } — the total counts the whole filtered set, not the
76
+ // page, so the pager can say "of N" without a second call site inventing it.
77
+ async function activityTasksByBuilder(builderId, { role = null, limit = DEFAULT_LIMIT, offset = 0 } = {}, deps = {}) {
78
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
79
+ const craft = normalizeCraft(role);
80
+ const lim = clampLimit(limit);
81
+ const off = clampOffset(offset);
82
+
83
+ const params = [builderId];
84
+ let where = `t.shipped_by = $1 AND t.status = 'shipped' AND t.${SMOKE_NOT_LIKE}`;
85
+ if (craft) {
86
+ params.push(craft);
87
+ where += ` AND COALESCE(t.discipline, 'unclassified') = $${params.length}`;
88
+ }
89
+
90
+ const { rows: countRows } = await activePool.query(
91
+ `SELECT count(*)::int AS total FROM tasks t WHERE ${where}`,
92
+ params
93
+ );
94
+
95
+ const pageParams = [...params, lim, off];
96
+ const { rows } = await activePool.query(
97
+ `SELECT t.id, t.title, t.value_summary, t.visual_url, t.visual_alt,
98
+ t.version_id, t.goal_id, t.kind, t.status, t.shipped_at,
99
+ COALESCE(t.discipline, 'unclassified') AS discipline,
100
+ t.credits_reward, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded
101
+ FROM tasks t
102
+ WHERE ${where}
103
+ ORDER BY t.shipped_at DESC NULLS LAST, t.id DESC
104
+ LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`,
105
+ pageParams
106
+ );
107
+ return { rows, total: countRows[0]?.total ?? 0 };
108
+ }
109
+
110
+ // activityGoalsByBuilder — the goals this builder CREATED, newest first, with
111
+ // the craft set their tasks carry and a readiness line.
112
+ //
113
+ // created_by is the goal's owner (task 1735), which is what "what did I create"
114
+ // means on this tier — holding a membership in someone else's goal is a
115
+ // different claim and is deliberately not folded in here.
116
+ //
117
+ // THE CRAFT FILTER APPLIES TO THE GOAL'S TASKS, not to the goal: a goal counts
118
+ // as artist work when at least one of its tasks is artist work. A goal with no
119
+ // tasks at all therefore matches NO craft filter and appears only in the
120
+ // unfiltered view — which is honest, because nothing has been done in it yet.
121
+ //
122
+ // VISIBILITY. `visibility='private'` goals are returned only to a member or an
123
+ // Archon (ADR 0112: a non-member must not learn a private goal's title). The
124
+ // filter is in SQL rather than a JS post-pass so the count and the page window
125
+ // agree — thinning rows after a LIMIT would make the pager lie.
126
+ async function activityGoalsByBuilder(
127
+ builderId,
128
+ { role = null, viewerId = null, viewerIsArchon = false, limit = DEFAULT_LIMIT, offset = 0 } = {},
129
+ deps = {}
130
+ ) {
131
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
132
+ const craft = normalizeCraft(role);
133
+ const lim = clampLimit(limit);
134
+ const off = clampOffset(offset);
135
+
136
+ // $1 creator · $2 viewer · $3 archon flag, then an optional craft.
137
+ const params = [builderId, viewerId ?? null, !!viewerIsArchon];
138
+ let craftClause = '';
139
+ if (craft) {
140
+ params.push(craft);
141
+ craftClause = `AND $${params.length} = ANY(c.disciplines)`;
142
+ }
143
+
144
+ // The rollup CTE is scoped to this builder's goals only, so it never walks the
145
+ // whole tasks table — the reason this is a join over `mine` rather than a call
146
+ // into the version-wide goalRollups() read.
147
+ const base = `
148
+ WITH mine AS (
149
+ SELECT g.id, g.version_id, g.title, g.subtitle, g.status, g.visibility,
150
+ g.created_at, g.updated_at
151
+ FROM goals g
152
+ WHERE g.created_by = $1
153
+ AND (g.visibility = 'public'
154
+ OR $3::boolean
155
+ OR EXISTS (SELECT 1 FROM goal_members gm
156
+ WHERE gm.goal_id = g.id AND gm.builder_id = $2::bigint))
157
+ ), c AS (
158
+ SELECT t.goal_id,
159
+ array_agg(DISTINCT COALESCE(t.discipline, 'unclassified')) AS disciplines,
160
+ count(*)::int AS task_total,
161
+ count(*) FILTER (WHERE t.status = 'shipped')::int AS task_shipped,
162
+ max(GREATEST(COALESCE(t.updated_at, 'epoch'::timestamptz),
163
+ COALESCE(t.shipped_at, 'epoch'::timestamptz))) AS last_activity_at
164
+ FROM tasks t
165
+ WHERE t.goal_id IN (SELECT id FROM mine)
166
+ AND t.${SMOKE_NOT_LIKE}
167
+ GROUP BY t.goal_id
168
+ )`;
169
+
170
+ const { rows: countRows } = await activePool.query(
171
+ `${base}
172
+ SELECT count(*)::int AS total
173
+ FROM mine m LEFT JOIN c ON c.goal_id = m.id
174
+ WHERE true ${craftClause}`,
175
+ params
176
+ );
177
+
178
+ const pageParams = [...params, lim, off];
179
+ const { rows } = await activePool.query(
180
+ `${base}
181
+ SELECT m.id, m.version_id, m.title, m.subtitle, m.status, m.visibility,
182
+ m.created_at, m.updated_at,
183
+ COALESCE(c.disciplines, ARRAY[]::text[]) AS disciplines,
184
+ COALESCE(c.task_total, 0) AS task_total,
185
+ COALESCE(c.task_shipped, 0) AS task_shipped,
186
+ c.last_activity_at
187
+ FROM mine m LEFT JOIN c ON c.goal_id = m.id
188
+ WHERE true ${craftClause}
189
+ ORDER BY m.created_at DESC, m.id DESC
190
+ LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`,
191
+ pageParams
192
+ );
193
+ return { rows, total: countRows[0]?.total ?? 0 };
194
+ }
195
+
196
+ module.exports = {
197
+ CRAFTS,
198
+ DEFAULT_LIMIT,
199
+ MAX_LIMIT,
200
+ activityGoalsByBuilder,
201
+ activityTasksByBuilder,
202
+ clampLimit,
203
+ clampOffset,
204
+ normalizeCraft,
205
+ };
@@ -20,6 +20,7 @@ const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
21
  const helpRequests = require('./help-requests');
22
22
  const taskRecommendations = require('./task-recommendations');
23
+ const activity = require('./activity');
23
24
 
24
25
  module.exports = {
25
26
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -58,6 +59,12 @@ module.exports = {
58
59
  recentGradesForBuilder: (...a) => db.recentGradesForBuilder(...a),
59
60
  countShippedTasksForBuilder: (...a) => db.countShippedTasksForBuilder(...a),
60
61
  recentShippedTasksByBuilder: (...a) => db.recentShippedTasksByBuilder(...a),
62
+ // builders.js (the profile's per-craft activity log, task 1003281): the paged
63
+ // "what did I do as an engineer / an artist" reads. Separate from the two
64
+ // above because those answer "how much, in total" and these answer "what,
65
+ // by craft" — the axis criterion legibility-activity-by-role asks for.
66
+ activityTasksByBuilder: (...a) => activity.activityTasksByBuilder(...a),
67
+ activityGoalsByBuilder: (...a) => activity.activityGoalsByBuilder(...a),
61
68
  // ADR 0120 part 5: the confirmed tasks a builder owes a rebase on — the same
62
69
  // read the part-3 claim gate uses, surfaced to me.js so the CLI + hall can
63
70
  // show the owed-rebase warning that gates new claims (reward lands on ship).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.381",
3
+ "version": "1.19.383",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.381",
9
+ "version": "1.19.383",
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.381",
3
+ "version": "1.19.383",
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",
@@ -82,6 +82,11 @@ const FIXTURE_FALLBACKS = [
82
82
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
83
83
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
84
84
  [/^builders\/\d+\/profile$/, 'builders__profile'],
85
+ // the profile's per-craft activity log (task 1003281). One fixture per SECTION
86
+ // rather than per id, because the section is what changes the row shape; the
87
+ // craft and the page window are query string, which fixture matching ignores.
88
+ [/^builders\/\d+\/activity$/, (q) => (q.section === 'goals' ? 'builders__activity-goals' : 'builders__activity-tasks')],
89
+ [/^inbox\/by-builder\/\d+$/, 'inbox__by-builder'],
85
90
  ];
86
91
 
87
92
  function loadFixture(name) {
@@ -102,12 +107,17 @@ function emptied(v) {
102
107
  return v;
103
108
  }
104
109
 
105
- function fixtureFor(apiPath) {
110
+ // A fallback name may be a plain string or a function of the query — the one
111
+ // case that needs it (task 1003281) is the profile activity log, where the ROW
112
+ // SHAPE is chosen by ?section=, not by the path, so one fixture per path would
113
+ // render goal rows into the tasks ledger.
114
+ function fixtureFor(apiPath, query = {}) {
106
115
  const exact = loadFixture(apiPath.replace(/\//g, '__'));
107
116
  if (exact !== undefined) return exact;
108
117
  for (const [re, name] of FIXTURE_FALLBACKS) {
109
118
  if (re.test(apiPath)) {
110
- const hit = loadFixture(name);
119
+ const resolved = typeof name === 'function' ? name(query) : name;
120
+ const hit = resolved && loadFixture(resolved);
111
121
  if (hit !== undefined) return hit;
112
122
  }
113
123
  }
@@ -141,7 +151,7 @@ app.use((req, res, next) => {
141
151
  if (req.method !== 'GET') {
142
152
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
143
153
  }
144
- const fix = fixtureFor(apiPath);
154
+ const fix = fixtureFor(apiPath, req.query || {});
145
155
  if (fix === undefined) {
146
156
  console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
147
157
  return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
@@ -545,6 +545,73 @@ module.exports = function buildBuildersRouter() {
545
545
  }
546
546
  });
547
547
 
548
+ // GET /builders/:id/activity — the profile's PER-CRAFT activity log (task
549
+ // 1003281, goal 1000072, criterion legibility-activity-by-role).
550
+ //
551
+ // WHAT IT ADDS OVER /profile. The profile answers "how much has this person
552
+ // shipped" — one count and the last five works, craft-blind. A person's artist
553
+ // track record is a different question from their engineer one, so this route
554
+ // serves ONE section (`tasks` or `goals`) for ONE craft, paged. It EXTENDS the
555
+ // profile surface rather than forking it: the page is the same page, the
556
+ // identity band and the achievement shelf are unchanged, and this fills the
557
+ // ledger below them. The third section of that log — the IDEA TRAIL — is
558
+ // served by the ideas module's GET /inbox/by-builder/:id, because an idea is
559
+ // its domain's row, not a lifecycle one.
560
+ //
561
+ // ONE SECTION PER CALL, not all three in one payload: each ledger pages
562
+ // independently, and a single response carrying three page windows would need
563
+ // three limit/offset pairs on one query string to say anything at all.
564
+ //
565
+ // rank: any-builder, NOT owner-only — a track record is a thing other people
566
+ // read; that is what makes the profile a portfolio rather than a dashboard, and
567
+ // it is the same posture /profile, /builders/roster and the leaderboard already
568
+ // take toward the same rows. The per-viewer filtering that DOES matter is the
569
+ // private-goal wall below, and it is applied to the DATA, not to the caller.
570
+ //
571
+ // But, unlike /profile directly above, this read IS gated to a sign-in.
572
+ // The goals half is viewer-dependent: a private goal's title is exactly what
573
+ // ADR 0112 says a non-member must not learn, so the read needs to know who is
574
+ // asking. Serving it publicly would mean either leaking private goals or
575
+ // silently omitting the viewer's own — both worse than a sign-in. The tasks
576
+ // half is the same already-public shipped-work data /profile returns.
577
+ router.get('/builders/:id/activity', auth.requireBuilder, async (req, res) => {
578
+ const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
579
+ if (targetId === null) return;
580
+ const section = req.query.section === 'goals' ? 'goals' : 'tasks';
581
+ // An unknown craft is normalized to "no filter" inside the module read
582
+ // rather than 400ing here — a stale bookmark should show the whole record,
583
+ // not an error card in the middle of a profile.
584
+ const role = typeof req.query.role === 'string' && req.query.role ? req.query.role : null;
585
+ const limit = Number(req.query.limit);
586
+ const offset = Number(req.query.offset);
587
+ try {
588
+ const b = await db.getBuilderById(targetId);
589
+ if (!b) return res.fail('builder_not_found', 404);
590
+ const viewer = req.builder || {};
591
+ const out = section === 'goals'
592
+ ? await lc().activityGoalsByBuilder(targetId, {
593
+ role,
594
+ viewerId: viewer.id ?? null,
595
+ viewerIsArchon: viewer.rank === 'archon',
596
+ limit,
597
+ offset,
598
+ })
599
+ : await lc().activityTasksByBuilder(targetId, { role, limit, offset });
600
+ // Viewer-dependent (the goals half) and cheap to recompute — never cached.
601
+ res.set('Cache-Control', 'no-store');
602
+ res.json({
603
+ builder_id: targetId,
604
+ section,
605
+ role: role || null,
606
+ rows: out.rows,
607
+ total: out.total,
608
+ });
609
+ } catch (err) {
610
+ log.error('[gds] GET /builders/:id/activity', err);
611
+ res.fail('activity_read_failed', { status: 500, message: 'internal error' });
612
+ }
613
+ });
614
+
548
615
  // GET /builders/me/pending-rank-change — V3.R28 / #249 ceremony signal #3.
549
616
  //
550
617
  // Returns the calling builder's most recent rank-change credit_log row, or
package/src/module-api.js CHANGED
@@ -1202,8 +1202,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1202
1202
  // landed since 1.19.379 with no explicit bump. run 33085516721. (task 1002620)
1203
1203
  // 1.19.381 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1204
1204
  // landed since 1.19.380 with no explicit bump. run 33088290961. (task 1002620)
1205
+ // 1.19.382 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1206
+ // landed since 1.19.381 with no explicit bump. run 33094545251. (task 1002620)
1207
+ // 1.19.383 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1208
+ // landed since 1.19.382 with no explicit bump. run 33101389971. (task 1002620)
1205
1209
  // ---------------------------------------------------------------------------
1206
- const CORE_VERSION = '1.19.381'; // CI auto-patch carrier (ADR 0161); see changelog above
1210
+ const CORE_VERSION = '1.19.383'; // CI auto-patch carrier (ADR 0161); see changelog above
1207
1211
 
1208
1212
  // A namespaced logger so a module's log lines are attributable + consistent.
1209
1213
  // Usage: const log = api.logger('dev-box'); log.info('mounted');