@bongos/core 1.19.380 → 1.19.382
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 +46 -31
- 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/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/gds/ship-memory-sync.js +62 -5
- package/scripts/gds/ship-session-upload.js +12 -10
- 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/tests/session_records.mjs +122 -5
|
@@ -242,6 +242,278 @@
|
|
|
242
242
|
section.hidden = false;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// ---- render: activity, by craft -----------------------------------------
|
|
246
|
+
//
|
|
247
|
+
// Task 1003281, goal 1000072 / criterion legibility-activity-by-role. The
|
|
248
|
+
// sections above answer "how much has this person shipped"; this one answers
|
|
249
|
+
// "what did they do, AS an engineer / an artist / an ideator" — a different
|
|
250
|
+
// question per craft, which is why it is a view per craft and not a filter.
|
|
251
|
+
//
|
|
252
|
+
// THE IDEA TRAIL is the part that exists nowhere else, and the reason the
|
|
253
|
+
// criterion was written: every other idea surface is scoped to what is still
|
|
254
|
+
// OPEN, so an idea drops out of the system the moment it is triaged — exactly
|
|
255
|
+
// when its outcome becomes interesting. Here nothing you filed ever leaves the
|
|
256
|
+
// list, and each row carries its verdict: became a task (linked), discarded
|
|
257
|
+
// (with the triager's note), merged into another idea (linked), or STILL OPEN.
|
|
258
|
+
// Open reads as open, never as an absence.
|
|
259
|
+
//
|
|
260
|
+
// WHY IT NEEDS A SIGN-IN when the rest of the page does not: idea titles and
|
|
261
|
+
// triage notes are builder-visible, not world-visible, and a private goal's
|
|
262
|
+
// title is what ADR 0112 says a non-member must not learn. Signed out, this
|
|
263
|
+
// one section says so and the public portfolio above it is untouched.
|
|
264
|
+
//
|
|
265
|
+
// PAGING IS SERVER-SIDE. A prolific builder's record is long and the criterion
|
|
266
|
+
// asks for *every* idea, so each ledger asks for one page at a time through
|
|
267
|
+
// OTBKit's server-offset pager mode — the kit's own mode, added by this task,
|
|
268
|
+
// rather than an eleventh hand-rolled pager (hall-ui/CLAUDE.md's hard rule).
|
|
269
|
+
|
|
270
|
+
// The craft tabs. 'all' leads because most tasks in the corpus carry
|
|
271
|
+
// discipline='unclassified': a page that only offered the four named crafts
|
|
272
|
+
// would hide the majority of a person's record and read as "you did less than
|
|
273
|
+
// you did". The four named crafts match the Goals page's Craft filter
|
|
274
|
+
// (goals-page.js f-goal-role) so the two surfaces speak one vocabulary.
|
|
275
|
+
const CRAFT_TABS = [
|
|
276
|
+
{ id: 'all', label: 'All', role: null },
|
|
277
|
+
{ id: 'engineer', label: 'Engineer', role: 'engineer' },
|
|
278
|
+
{ id: 'artist', label: 'Artist', role: 'artist' },
|
|
279
|
+
{ id: 'ideator', label: 'Ideator', role: 'ideator' },
|
|
280
|
+
{ id: 'ui', label: 'UI', role: 'ui' },
|
|
281
|
+
];
|
|
282
|
+
// Filing an idea is ideator work whatever craft the resulting task turned out
|
|
283
|
+
// to be, so the trail belongs to the ideator view — and to 'all', which is the
|
|
284
|
+
// whole record by definition. It is not a per-craft slice of the ideas.
|
|
285
|
+
const TRAIL_TABS = new Set(['all', 'ideator']);
|
|
286
|
+
|
|
287
|
+
// Every href below goes through BOTH encodings, and it needs both: the ids are
|
|
288
|
+
// DB integers today, but encodeURIComponent leaves a literal `'` alone, so on
|
|
289
|
+
// its own it is a URL encoder standing in for an HTML-attribute escape. escape
|
|
290
|
+
// the URL for the path, escape the result for the attribute (grader nit).
|
|
291
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
292
|
+
const href = (pathStr) => escapeHtml(pathStr);
|
|
293
|
+
const taskHref = (id) => href(`${hallBase()}/task/${encodeURIComponent(id)}`);
|
|
294
|
+
const ideaHref = (id) => href(`${hallBase()}/idea/${encodeURIComponent(id)}`);
|
|
295
|
+
const goalHref = (id) => href(`${hallBase()}/goals#/goal/${encodeURIComponent(id)}`);
|
|
296
|
+
|
|
297
|
+
// What became of one idea, in a sentence a person can read. The four outcomes
|
|
298
|
+
// the criterion names, each linked through to the thing it became.
|
|
299
|
+
function ideaOutcomeHtml(idea) {
|
|
300
|
+
const note = idea.triage_note
|
|
301
|
+
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
|
|
302
|
+
: '';
|
|
303
|
+
if (idea.status === 'promoted') {
|
|
304
|
+
// ideas_task_lineage is the complete answer (one idea can become many
|
|
305
|
+
// tasks, task 1002997); promoted_to_task_id is the only answer for
|
|
306
|
+
// promotions that predate that table, so fall back to it.
|
|
307
|
+
const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
|
|
308
|
+
const links = lineage.length
|
|
309
|
+
? lineage.map((t) => `<a href="${taskHref(t.task_id)}">#${escapeHtml(String(t.task_id))} ${escapeHtml(t.title || '')}</a>`)
|
|
310
|
+
: (idea.promoted_to_task_id
|
|
311
|
+
? [`<a href="${taskHref(idea.promoted_to_task_id)}">#${escapeHtml(String(idea.promoted_to_task_id))} ${escapeHtml(idea.promoted_task_title || '')}</a>`]
|
|
312
|
+
: []);
|
|
313
|
+
// 'promoted' with nothing to point at is a real (old) state, not a bug to
|
|
314
|
+
// paper over — say what is known rather than inventing a link.
|
|
315
|
+
if (!links.length) return `<span class="task__tag">Promoted</span>${note}`;
|
|
316
|
+
return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}`;
|
|
317
|
+
}
|
|
318
|
+
if (idea.status === 'merged') {
|
|
319
|
+
const target = idea.merged_into_idea_id
|
|
320
|
+
? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
|
|
321
|
+
: '';
|
|
322
|
+
return `<span class="task__tag">Merged into</span>${target}${note}`;
|
|
323
|
+
}
|
|
324
|
+
if (idea.status === 'discarded') {
|
|
325
|
+
return `<span class="task__tag">Discarded</span>${note}`;
|
|
326
|
+
}
|
|
327
|
+
return `<span class="task__tag">Still open</span>${note}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
|
|
331
|
+
// resolves to { rows, total }; everything else — the pager wiring, the empty
|
|
332
|
+
// state, the reload-on-craft-change — is the same for all three lists, so it
|
|
333
|
+
// is written once here rather than three times below.
|
|
334
|
+
function makeActivityList({ mountId, storageKey, columns, rowHtml, emptyMsg, fetchPage }) {
|
|
335
|
+
const mount = $(`#${mountId}`);
|
|
336
|
+
if (!mount || !window.OTBKit) return null;
|
|
337
|
+
const pager = window.OTBKit.makePager(mountId, storageKey, { defaultPerPage: 10, minPerPage: 10 });
|
|
338
|
+
let inFlight = 0;
|
|
339
|
+
const ledger = window.OTBKit.makeLedger({
|
|
340
|
+
mount, columns, rowHtml, emptyMsg,
|
|
341
|
+
serverPager: pager,
|
|
342
|
+
onPage: () => load(),
|
|
343
|
+
});
|
|
344
|
+
async function load() {
|
|
345
|
+
const seq = ++inFlight;
|
|
346
|
+
try {
|
|
347
|
+
const out = await fetchPage(window.OTBKit.pageOffset(pager), pager.perPage);
|
|
348
|
+
// A craft switch or a fast pager click can land two loads at once; only
|
|
349
|
+
// the newest may paint, or the ledger shows a page nobody asked for.
|
|
350
|
+
if (seq !== inFlight) return;
|
|
351
|
+
ledger.render(out.rows, { total: out.total });
|
|
352
|
+
return out.total;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (seq !== inFlight) return;
|
|
355
|
+
console.error(`[profile] activity list ${mountId} failed`, err);
|
|
356
|
+
ledger.render([], { total: 0 });
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return { reset() { pager.page = 1; }, load, ledger };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Built once, on first render, and kept — switching craft re-fetches, it does
|
|
364
|
+
// not rebuild the DOM (which would drop the reader's per-page preference).
|
|
365
|
+
let activity = null;
|
|
366
|
+
|
|
367
|
+
function buildActivity(builderId) {
|
|
368
|
+
const kit = window.OTBKit;
|
|
369
|
+
if (!kit) return null;
|
|
370
|
+
let role = null;
|
|
371
|
+
|
|
372
|
+
const tasks = makeActivityList({
|
|
373
|
+
mountId: 'activity-tasks',
|
|
374
|
+
storageKey: 'otb.profile.activityTasksPerPage',
|
|
375
|
+
columns: [
|
|
376
|
+
{ label: '#', class: 'ledger__num' },
|
|
377
|
+
'Work',
|
|
378
|
+
{ label: 'Craft' },
|
|
379
|
+
{ label: 'Shipped', class: 'ledger__date' },
|
|
380
|
+
],
|
|
381
|
+
emptyMsg: 'No completed works in this craft.',
|
|
382
|
+
fetchPage: async (offset, limit) => {
|
|
383
|
+
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
|
|
384
|
+
+ `?section=tasks&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
|
|
385
|
+
if (!res.ok) throw new Error(`activity tasks → ${res.status}`);
|
|
386
|
+
return { rows: res.data.rows || [], total: Number(res.data.total) || 0 };
|
|
387
|
+
},
|
|
388
|
+
rowHtml: (t) => {
|
|
389
|
+
const summary = t.value_summary
|
|
390
|
+
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(t.value_summary)}</span>`
|
|
391
|
+
: '';
|
|
392
|
+
const visual = kit.shipVisualThumbHtml ? kit.shipVisualThumbHtml(t) : '';
|
|
393
|
+
return `<tr>
|
|
394
|
+
<td class="ledger__num">#${escapeHtml(String(t.id))}</td>
|
|
395
|
+
<td><a href="${taskHref(t.id)}">${escapeHtml(t.title || `Task ${t.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
|
|
396
|
+
<td><span class="task__tag">${escapeHtml(t.discipline || 'unclassified')}</span></td>
|
|
397
|
+
<td class="ledger__date">${fmtDate(t.shipped_at)}</td>
|
|
398
|
+
</tr>`;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const goals = makeActivityList({
|
|
403
|
+
mountId: 'activity-goals',
|
|
404
|
+
storageKey: 'otb.profile.activityGoalsPerPage',
|
|
405
|
+
columns: [
|
|
406
|
+
{ label: '#', class: 'ledger__num' },
|
|
407
|
+
'Goal',
|
|
408
|
+
'Crafts',
|
|
409
|
+
'Progress',
|
|
410
|
+
],
|
|
411
|
+
emptyMsg: 'No goals created in this craft.',
|
|
412
|
+
fetchPage: async (offset, limit) => {
|
|
413
|
+
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
|
|
414
|
+
+ `?section=goals&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
|
|
415
|
+
if (!res.ok) throw new Error(`activity goals → ${res.status}`);
|
|
416
|
+
return { rows: res.data.rows || [], total: Number(res.data.total) || 0 };
|
|
417
|
+
},
|
|
418
|
+
rowHtml: (g) => {
|
|
419
|
+
const sub = g.subtitle
|
|
420
|
+
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(g.subtitle)}</span>`
|
|
421
|
+
: '';
|
|
422
|
+
const crafts = (Array.isArray(g.disciplines) ? g.disciplines : [])
|
|
423
|
+
.map((d) => `<span class="task__tag">${escapeHtml(d)}</span>`).join(' ') || '—';
|
|
424
|
+
const shipped = Number(g.task_shipped) || 0;
|
|
425
|
+
const total = Number(g.task_total) || 0;
|
|
426
|
+
return `<tr>
|
|
427
|
+
<td class="ledger__num">#${escapeHtml(String(g.id))}</td>
|
|
428
|
+
<td><a href="${goalHref(g.id)}">${escapeHtml(g.title || `Goal ${g.id}`)}</a>${sub}</td>
|
|
429
|
+
<td>${crafts}</td>
|
|
430
|
+
<td>${fmtNum(shipped)} of ${fmtNum(total)} shipped</td>
|
|
431
|
+
</tr>`;
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const ideas = makeActivityList({
|
|
436
|
+
mountId: 'activity-ideas',
|
|
437
|
+
storageKey: 'otb.profile.activityIdeasPerPage',
|
|
438
|
+
columns: [
|
|
439
|
+
{ label: '#', class: 'ledger__num' },
|
|
440
|
+
'Idea',
|
|
441
|
+
'What became of it',
|
|
442
|
+
{ label: 'Filed', class: 'ledger__date' },
|
|
443
|
+
],
|
|
444
|
+
emptyMsg: 'No ideas filed yet.',
|
|
445
|
+
fetchPage: async (offset, limit) => {
|
|
446
|
+
const res = await api.request('GET', `${API}/inbox/by-builder/${encodeURIComponent(builderId)}`
|
|
447
|
+
+ `?limit=${limit}&offset=${offset}`);
|
|
448
|
+
if (!res.ok) throw new Error(`idea trail → ${res.status}`);
|
|
449
|
+
return { rows: res.data.ideas || [], total: Number(res.data.total) || 0 };
|
|
450
|
+
},
|
|
451
|
+
rowHtml: (i) => `<tr>
|
|
452
|
+
<td class="ledger__num">#${escapeHtml(String(i.id))}</td>
|
|
453
|
+
<td><a href="${ideaHref(i.id)}">${escapeHtml(i.title || `Idea ${i.id}`)}</a></td>
|
|
454
|
+
<td>${ideaOutcomeHtml(i)}</td>
|
|
455
|
+
<td class="ledger__date">${fmtDate(i.captured_at)}</td>
|
|
456
|
+
</tr>`,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// NO `panel` PER TAB, and that is deliberate. makeTabs toggles [hidden] on
|
|
460
|
+
// one panel PER TAB; here every craft shows the same three ledgers with
|
|
461
|
+
// different rows, so pointing all five tabs at one element would make the
|
|
462
|
+
// last tab's `hidden = (id !== 'ui')` win and blank the section. Omitting
|
|
463
|
+
// `panel` makes the toggle a no-op ($id(undefined) is null) and leaves this
|
|
464
|
+
// file owning what a craft change means: a re-fetch, not a panel swap.
|
|
465
|
+
const tabs = kit.makeTabs({
|
|
466
|
+
mount: $('#activity-tabs'),
|
|
467
|
+
tabs: CRAFT_TABS.map((t) => ({ id: t.id, label: t.label })),
|
|
468
|
+
onChange: (id) => {
|
|
469
|
+
const tab = CRAFT_TABS.find((t) => t.id === id) || CRAFT_TABS[0];
|
|
470
|
+
role = tab.role;
|
|
471
|
+
show(id);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
function show(tabId) {
|
|
476
|
+
const trail = TRAIL_TABS.has(tabId);
|
|
477
|
+
const ideasPanel = $('#activity-ideas-panel');
|
|
478
|
+
if (ideasPanel) ideasPanel.hidden = !trail;
|
|
479
|
+
[tasks, goals].forEach((l) => { if (l) { l.reset(); l.load(); } });
|
|
480
|
+
if (trail && ideas) { ideas.reset(); ideas.load(); }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// makeTabs activates its initial tab during construction, which already ran
|
|
484
|
+
// onChange → show(). So the caller must NOT kick it again on first build.
|
|
485
|
+
return { show, active: () => tabs.active() || 'all' };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderActivity(builderId, signedIn) {
|
|
489
|
+
const section = $('#activity-scroll');
|
|
490
|
+
const placeholder = $('#activity-placeholder');
|
|
491
|
+
if (!section) return;
|
|
492
|
+
if (!signedIn) {
|
|
493
|
+
// The public portfolio above stays public; this one section says plainly
|
|
494
|
+
// why it is not, rather than rendering an empty log that reads as "this
|
|
495
|
+
// person did nothing".
|
|
496
|
+
if (placeholder) {
|
|
497
|
+
placeholder.hidden = false;
|
|
498
|
+
placeholder.innerHTML = `Idea trails and goals are visible to signed-in builders — `
|
|
499
|
+
+ `<a href="${API}/auth/web/start">sign in</a> to see this builder's activity.`;
|
|
500
|
+
}
|
|
501
|
+
['activity-tasks-panel', 'activity-goals-panel', 'activity-ideas-panel']
|
|
502
|
+
.forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = true; });
|
|
503
|
+
const tabsMount = $('#activity-tabs');
|
|
504
|
+
if (tabsMount) tabsMount.innerHTML = '';
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (placeholder) { placeholder.hidden = true; placeholder.innerHTML = ''; }
|
|
508
|
+
['activity-tasks-panel', 'activity-goals-panel']
|
|
509
|
+
.forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = false; });
|
|
510
|
+
if (!activity) {
|
|
511
|
+
activity = buildActivity(builderId); // its makeTabs already loaded page 1
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
activity.show(activity.active());
|
|
515
|
+
}
|
|
516
|
+
|
|
245
517
|
// ---- states -------------------------------------------------------------
|
|
246
518
|
|
|
247
519
|
function showMessage(text) {
|
|
@@ -259,6 +531,9 @@
|
|
|
259
531
|
renderCrossProject(data, selfId);
|
|
260
532
|
renderAchievements(data.achievements);
|
|
261
533
|
renderWorks(data.recent_ships);
|
|
534
|
+
// The activity log needs a session (private goals + the idea trail), so it
|
|
535
|
+
// keys off whether /me resolved — the same signal the self-detect uses.
|
|
536
|
+
renderActivity(builder.id, selfId != null);
|
|
262
537
|
}
|
|
263
538
|
|
|
264
539
|
// Render the friendly "no builder chosen" empty state for a signed-out visitor
|
|
@@ -281,12 +556,16 @@
|
|
|
281
556
|
const works = $('#works-table tbody'); if (works) works.innerHTML = '';
|
|
282
557
|
const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
|
|
283
558
|
const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
|
|
559
|
+
const act = $('#activity-scroll'); if (act) act.hidden = true;
|
|
284
560
|
}
|
|
285
561
|
|
|
286
562
|
async function fetchProfile(id, selfId) {
|
|
287
563
|
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(id)}/profile`);
|
|
288
564
|
if (res.status === 404) {
|
|
289
565
|
showMessage('No builder found with that id.');
|
|
566
|
+
// There is no builder, so there is no activity to log — hide the section
|
|
567
|
+
// rather than leaving its loading dots on a page about nobody.
|
|
568
|
+
const act = $('#activity-scroll'); if (act) act.hidden = true;
|
|
290
569
|
return;
|
|
291
570
|
}
|
|
292
571
|
if (!res.ok) throw new Error(`profile → ${res.status}`);
|
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
|
+
};
|