@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.
- package/.bongos-core.json +69 -29
- 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 +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +141 -3
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +14 -9
- package/docs/copy-registry.json +87 -42
- package/docs/design/landing-page-direction-v2.md +90 -0
- package/docs/design/mocks/landing/landing-sheet.html +131 -0
- package/docs/design/mocks/landing/v1.html +956 -0
- package/docs/design/mocks/landing/v2.html +708 -0
- package/docs/design/mocks/landing/v3.html +911 -0
- package/migrations/core_228_idea_inbox_captured_by_idx.sql +61 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/hall-kit.js +67 -6
- package/modules/hall-ui/public/profile.html +34 -2
- package/modules/hall-ui/public/profile.js +279 -0
- package/modules/ideas/inbox.js +98 -0
- package/modules/ideas/routes/inbox.js +39 -0
- package/modules/lifecycle/activity.js +205 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +13 -3
- package/src/bongos/routes/builders.js +67 -0
- package/src/module-api.js +5 -1
- package/tests/profile_activity.mjs +625 -0
package/modules/ideas/inbox.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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');
|