@teamscala/service-boot 0.23.479 → 0.23.492

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.
Files changed (46) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-registry-page-route/region-slots-page-data.ts +2 -2
  7. package/src/register-ws-routes.ts +1 -1
  8. package/src/fn-routers/agreement-certificate.ts +0 -224
  9. package/src/fn-routers/agreement-completion-email.ts +0 -114
  10. package/src/fn-routers/agreement-send-router.ts +0 -303
  11. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  12. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  13. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  14. package/src/fn-routers/assessment-report-data.ts +0 -485
  15. package/src/fn-routers/assessment-report-email.ts +0 -256
  16. package/src/fn-routers/assessment-report-pair.test.ts +0 -107
  17. package/src/fn-routers/assessment-report-pair.ts +0 -155
  18. package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
  19. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  20. package/src/fn-routers/assessment-scoring.ts +0 -105
  21. package/src/fn-routers/auth-router.ts +0 -65
  22. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  23. package/src/fn-routers/chat-session-router.ts +0 -356
  24. package/src/fn-routers/conversation-ownership.ts +0 -24
  25. package/src/fn-routers/email-defaults.ts +0 -28
  26. package/src/fn-routers/email-link-ttl.ts +0 -28
  27. package/src/fn-routers/index.ts +0 -76
  28. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  29. package/src/fn-routers/linkedin-identity.ts +0 -36
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  31. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  32. package/src/fn-routers/panel-data-router.ts +0 -104
  33. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  34. package/src/fn-routers/persist-chat-messages.ts +0 -98
  35. package/src/fn-routers/proposal-send-router.ts +0 -207
  36. package/src/fn-routers/public-agreement-router.ts +0 -407
  37. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  38. package/src/fn-routers/public-assessment-router.ts +0 -268
  39. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  40. package/src/fn-routers/resend-client.ts +0 -39
  41. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  42. package/src/fn-routers/user-preferences-router.ts +0 -130
  43. package/src/fn-routers/work-items-router.ts +0 -342
  44. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  45. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  46. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,485 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten @indivisible-unit owner=lane/idle-pools-hold-the-ceiling reason="one report-shaping unit: band selection, narrative and chart shaping change together"
4
- * @edit edit directly
5
- *
6
- * report-data.ts — quiz-results → report data generation.
7
- *
8
- * Shapes a stored assessment response into the data the report puck_page
9
-
10
- * renders. The response already carries total_score + category_scores
11
- * (computed at submit time), so this is pure shaping — no re-scoring and no
12
- * authored copy. The `computeReportData` resolver_fn (a resource_loader
13
- * source, key `reportData`) runs this at SSR; the report blocks bind its
14
- * fields through the unified page-data surface (`dataSource:"page-data"` +
15
- * `fieldPath:"reportData.<key>"`).
16
- *
17
- * @see scala-docs/curated/apps/messaging.md is unrelated; the stage model
18
- * lives on `assessments.stage_bands` (migration 560/561).
19
- */
20
-
21
- import type { ReportResearchInput, ResearchReportData } from "./assessment-research-report-data.ts";
22
- import {
23
- buildResearchReportData,
24
- mergeNextSteps,
25
- readResearchFindings,
26
- } from "./assessment-research-report-data.ts";
27
-
28
- /** A chart data point — structural match for @teamscala/chart-block's ChartPoint. */
29
- export interface ReportChartPoint {
30
- label: string;
31
- value: number;
32
- color?: string;
33
- }
34
-
35
- /** A derived pillar position for the Positioning card (label + advisory summary). */
36
- export interface PillarPosition {
37
- label: string;
38
- summary: string;
39
- }
40
-
41
- /** One roadmap phase — a focus word plus its actions. */
42
- export interface RoadmapPhase {
43
- focus: string;
44
- actions: string[];
45
- }
46
-
47
- /**
48
- * A list item carrying its own ordinal. The number lives WITH the item so a
49
- * fixed-slot layout cannot print a badge for an item that does not exist: a
50
- * stage authoring four strengths into six slots leaves slots five and six with
51
- * no number and no text, rather than a bare numeral beside empty space. Numbers
52
- * spelled in the page instead would be a second copy of the list's length.
53
- */
54
- export interface NumberedItem {
55
- index: string;
56
- text: string;
57
- }
58
-
59
- /** A titled action with its explanatory line. */
60
- export interface TitledAction {
61
- title: string;
62
- description: string;
63
- }
64
-
65
- /**
66
- * One stage band, as authored on `assessments.stage_bands`. Every field is
67
- * copy the business owns; nothing here is computed. `max` is the inclusive
68
- * upper bound of the band — the final band omits it and catches the rest.
69
- */
70
- export interface StageBand {
71
- name: string;
72
- max?: number;
73
- tagline?: string;
74
- executiveSummary?: string;
75
- strongestAreas?: string[];
76
- priorityActions?: TitledAction[];
77
- strategicFocus?: string;
78
- strengths?: string[];
79
- opportunities?: string[];
80
- twelveMonthTarget?: string;
81
- /**
82
- * The remaining elements of Anna's five-page template. Every one is COPY,
83
- * authored per stage like the rest — none can be derived from the response.
84
- * Wiring them here makes each a row edit rather than a build task: the page
85
- * binds them and hides the section (`visibleWhenPath`) until a stage fills
86
- * it in, so Anna can land them one at a time without a deploy.
87
- */
88
- whoYouHelp?: string;
89
- knownFor?: string;
90
- insights?: string[];
91
- contentThemes?: TitledAction[];
92
- contentRhythm?: string;
93
- northStar?: string;
94
- priorityFocus?: string;
95
- priorityFocusSummary?: string;
96
- recommendedTier?: string;
97
- tierSummary?: string;
98
- whyItFits?: string;
99
- whatItSolves?: string;
100
- implementationFocus?: string;
101
- expectedOutcome?: string;
102
- finalWord?: string;
103
- roadmap?: Record<string, RoadmapPhase>;
104
- pillarNotes?: Record<string, string>;
105
- }
106
-
107
- /**
108
- * One pillar as the report presents it: the authored label and subtitle, this
109
- * person's score, the stage's note, and the pillar's colour. Held in the
110
- * assessment's own category order (not sorted by score) because the design
111
- * shows the three pillars in a fixed sequence with fixed colours.
112
- */
113
- export interface PillarCard {
114
- label: string;
115
- subtitle: string;
116
- value: number;
117
- max: number;
118
- note: string;
119
- color: string;
120
- }
121
-
122
- /** Per-category display authored on `assessments.category_descriptions`. */
123
- export interface CategoryDisplay {
124
- description?: string;
125
- subtitle?: string;
126
- color?: string;
127
- max?: number;
128
- }
129
-
130
- /** The shape a report resource_loader stamps into loaderData for the blocks. */
131
- export interface ReportData {
132
- /** the three pillars in authored order, for the pillar cards */
133
- pillarCards: PillarCard[];
134
- reportScore: ReportChartPoint[];
135
- reportPillars: ReportChartPoint[];
136
- narrative: string;
137
- totalScore: number;
138
- /** overall band label — the Snapshot stage badge */
139
- stage: string;
140
- /** band advice — the Snapshot stage summary */
141
- stageSummary: string;
142
- /** highest-scoring pillar — the Positioning "Strongest Pillar" column */
143
- strongest: PillarPosition | null;
144
- /** mid pillar — the Positioning "Biggest Opportunity" column */
145
- opportunity: PillarPosition | null;
146
- /** lowest pillar — the Positioning "Authority Gap" column */
147
- authorityGap: PillarPosition | null;
148
- /** section 4 — what this person already has going for them */
149
- strongestAreas: string[];
150
- /** section 6 — the numbered actions for this stage */
151
- priorityActions: TitledAction[];
152
- /** section 7 */
153
- strategicFocus: string;
154
- /** section 8, left column */
155
- strengths: string[];
156
- /** section 8, right column */
157
- opportunities: string[];
158
- /** section 8 left, numbered for badge rendering */
159
- strengthsList: NumberedItem[];
160
- /** section 8 right, numbered for badge rendering */
161
- opportunitiesList: NumberedItem[];
162
- /** section 9 */
163
- twelveMonthTarget: string;
164
- /** section 10 — three phases, each a focus word plus four actions */
165
- roadmap: RoadmapPhase[];
166
- /** the research half of the report — `{present:false}` when no research pass exists */
167
- research: ResearchReportData;
168
- /** Anna's remaining template elements — empty until a stage authors them. */
169
- whoYouHelp: string;
170
- knownFor: string;
171
- insights: string[];
172
- contentThemes: TitledAction[];
173
- contentRhythm: string;
174
- northStar: string;
175
- priorityFocus: string;
176
- priorityFocusSummary: string;
177
- recommendedTier: string;
178
- tierSummary: string;
179
- whyItFits: string;
180
- whatItSolves: string;
181
- implementationFocus: string;
182
- expectedOutcome: string;
183
- finalWord: string;
184
- }
185
-
186
- /** Minimal view of the stored response — total_score + category_scores. */
187
- export interface ReportResponseInput {
188
- total_score?: number | null;
189
- category_scores?: Record<string, number> | null;
190
- }
191
-
192
- /**
193
- * The response fields the paired report needs beyond the score: the person's
194
- * names (prepared-for line) and the LinkedIn URL the research pairing
195
- * normalises.
196
- */
197
- export interface ReportPairResponseShape extends ReportResponseInput {
198
- id?: string;
199
- assessment_id?: string;
200
- first_name?: string | null;
201
- last_name?: string | null;
202
- linkedin_url?: string | null;
203
- website?: string | null;
204
- completed_at?: Date | string | null;
205
- }
206
-
207
- /** Minimal view of the assessment metadata — title plus the stage model. */
208
- export interface ReportAssessmentInput {
209
- title?: string | null;
210
- stage_bands?: unknown;
211
- /** authored pillar order, e.g. ["Be Seen", "Be Heard", "Be Chosen"] */
212
- categories?: unknown;
213
- /** per-pillar display: subtitle, colour, description */
214
- category_descriptions?: unknown;
215
- }
216
-
217
- /** The fallback pillar colour when the assessment authors none. */
218
- const DEFAULT_PILLAR_COLOR = "var(--color-primary)";
219
-
220
- /**
221
- * Match a pillar label to its authored stage note.
222
- *
223
- * The stage designs key these notes by the distinctive word — `seen`, `heard`,
224
- * `chosen` — while the assessment names the pillars "Be Seen", "Be Heard",
225
- * "Be Chosen". A plain lowercase lookup therefore missed EVERY note, and the
226
- * positioning cards silently fell back to their generic role lines: present,
227
- * plausible, and identical for every respondent, so nothing looked broken.
228
-
229
- * Tries the whole label first, then the last word, so either authoring style
230
- * resolves and neither has to know about the other.
231
- */
232
- export function pillarNote(band: StageBand | null, label: string): string {
233
- const notes = band?.pillarNotes;
234
- if (!notes) return "";
235
- const whole = label.trim().toLowerCase();
236
- const lastWord = whole.split(/\s+/).pop() ?? whole;
237
- return (notes[whole] ?? notes[lastWord] ?? "").trim();
238
- }
239
-
240
- /**
241
- * Build the pillar cards in the assessment's OWN category order. Sorting by
242
- * score is right for "strongest / gap" positioning and wrong here: a reader
243
- * comparing two reports needs the pillars in the same place every time, and
244
- * each pillar keeps one colour across every report.
245
- */
246
- export function buildPillarCards(
247
- assessment: ReportAssessmentInput | null | undefined,
248
- categoryScores: Record<string, number>,
249
- band: StageBand | null,
250
- ): PillarCard[] {
251
- const categories = Array.isArray(assessment?.categories)
252
- ? (assessment.categories as unknown[]).filter(
253
- (c): c is string => typeof c === "string" && c.length > 0,
254
- )
255
- : Object.keys(categoryScores);
256
- const display = (
257
- assessment?.category_descriptions && typeof assessment.category_descriptions === "object"
258
- ? assessment.category_descriptions
259
- : {}
260
- ) as Record<string, CategoryDisplay | string>;
261
- return categories.map((label) => {
262
- const raw = display[label];
263
- const cfg: CategoryDisplay = typeof raw === "string" ? { description: raw } : (raw ?? {});
264
- const score = Number(categoryScores[label] ?? 0);
265
- return {
266
- label,
267
- subtitle: cfg.subtitle ?? "",
268
- value: Number.isFinite(score) ? Math.max(0, Math.round(score)) : 0,
269
- max: typeof cfg.max === "number" && cfg.max > 0 ? cfg.max : 100,
270
- note: pillarNote(band, label) || cfg.description || "",
271
- color: cfg.color || DEFAULT_PILLAR_COLOR,
272
- };
273
- });
274
- }
275
-
276
- /**
277
- * Read the authored bands off the assessment row. Returns [] when the column
278
- * is null or malformed, which is the signal to fall back rather than throw —
279
- * a report that renders a score with no stage copy is degraded; one that 500s
280
- * because a JSON column is empty is broken.
281
- */
282
- export function readStageBands(assessment?: ReportAssessmentInput | null): StageBand[] {
283
- const raw = assessment?.stage_bands;
284
- if (!raw || typeof raw !== "object") return [];
285
- const bands = (raw as { bands?: unknown }).bands;
286
- if (!Array.isArray(bands)) return [];
287
- return bands.filter(
288
- (band): band is StageBand =>
289
- !!band && typeof band === "object" && typeof (band as StageBand).name === "string",
290
- );
291
- }
292
-
293
- /**
294
- * Select the band a score falls in. Bands are ascending by `max`; the first
295
- * whose `max` the score does not exceed wins, and a band without `max` is the
296
- * catch-all. Authored order is respected rather than re-sorted, so a
297
- * mis-ordered row is visible in the output instead of silently corrected.
298
- */
299
- export function selectStageBand(bands: StageBand[], score: number): StageBand | null {
300
- for (const band of bands) {
301
- if (typeof band.max !== "number") return band;
302
- if (score <= band.max) return band;
303
- }
304
- return bands.length > 0 ? (bands[bands.length - 1] ?? null) : null;
305
- }
306
-
307
- /**
308
- * The narrative is authored per band and carries `{{score}}` where the design
309
- * spelled a number, so one band serves every score inside it. With no band,
310
- * the fallback states the score and nothing it cannot support.
311
- */
312
- export function generateNarrative(
313
- totalScore: number,
314
- band: StageBand | null,
315
- assessment?: ReportAssessmentInput | null,
316
- ): string {
317
- const authored = band?.executiveSummary?.trim();
318
- if (authored) return authored.replaceAll("{{score}}", String(totalScore));
319
- const title = assessment?.title?.trim() || "your assessment";
320
- return `You scored ${totalScore} on ${title}.`;
321
- }
322
-
323
- /** Pair each item with its 1-based ordinal for badge rendering. */
324
- export function numberItems(items: string[] | undefined): NumberedItem[] {
325
- if (!Array.isArray(items)) return [];
326
- return items
327
- .filter((text): text is string => typeof text === "string" && text.trim().length > 0)
328
- .map((text, i) => ({ index: String(i + 1), text: text.trim() }));
329
- }
330
-
331
- /** Flatten the authored `{month1, month2, month3}` map into render order. */
332
- export function orderRoadmap(roadmap: Record<string, RoadmapPhase> | undefined): RoadmapPhase[] {
333
- if (!roadmap || typeof roadmap !== "object") return [];
334
- return Object.keys(roadmap)
335
- .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
336
- .map((key) => roadmap[key])
337
- .filter((phase): phase is RoadmapPhase => !!phase && Array.isArray(phase.actions));
338
- }
339
-
340
- /**
341
- * Resolver call shape: the resolver_fn executor invokes every resolver as
342
- * `fn({ input, client, params })` (see resolveByResolverFn in
343
- * @teamscala/service-boot/resolve-from-loader-config). computeReportData is
344
- * registered as the `computeReportData` resolver_fn row, so it receives that
345
- * context — input.response / input.research / input.assessment come from the
346
- * loader's resolver_input, already PAIRED by the resolveReportHalves resolver
347
- * (research attaches to a response by normalised LinkedIn identity — both
348
- * halves resolved, or the research half alone for a research-only person).
349
- */
350
- export function computeReportData({
351
- input,
352
- }: {
353
- input: {
354
- response?: ReportPairResponseShape | null;
355
- assessment?: ReportAssessmentInput | null;
356
- research?: ReportResearchInput | null;
357
- brandName?: string | null;
358
- };
359
- }): ReportData {
360
- const { response, assessment } = input;
361
- const research = input.research ?? null;
362
- const bands = readStageBands(assessment);
363
- const researchData = buildResearchReportData({
364
- research,
365
- response,
366
- assessment,
367
- bands,
368
- resolveBand: (score: number) => selectStageBand(bands, score),
369
- brandName: input.brandName,
370
- });
371
- // Research-only reports (no quiz response) take their headline score from
372
- // the research pass so the Snapshot and pillar sections render from the
373
- // only scoring that exists; both halves present, the response stays the
374
- // subject of section one and the research feeds sections two through five.
375
- const scores = response ??
376
- (research
377
- ? {
378
- total_score: research.total_score,
379
- category_scores: research.category_scores,
380
- }
381
- : null);
382
- const totalScore = Math.max(0, Math.round(Number(scores?.total_score ?? 0)));
383
- const categoryScores = scores?.category_scores ?? {};
384
- const reportScore: ReportChartPoint[] = [{ label: "Score", value: totalScore }];
385
- const reportPillars: ReportChartPoint[] = Object.entries(categoryScores)
386
- .filter(([, value]) => Number.isFinite(Number(value)))
387
- .map(([label, value]) => ({ label, value: Math.max(0, Math.round(Number(value))) }))
388
- .filter((p) => p.label.length > 0)
389
- .sort((a, b) => b.value - a.value);
390
-
391
- const band = selectStageBand(bands, totalScore);
392
- const findings = research ? readResearchFindings(research.findings) : {};
393
- const nextSteps = research ? mergeNextSteps(band, findings) : null;
394
- const narrative =
395
- research && !response && researchData.execSummary
396
- ? researchData.execSummary
397
- : generateNarrative(totalScore, band, assessment);
398
- const pillarCards = buildPillarCards(assessment, categoryScores, band);
399
- // The chart bars take each pillar's own colour, so the bar chart and the
400
- // pillar cards cannot disagree about which colour means which pillar.
401
- // Only an AUTHORED colour travels to the chart. When the assessment sets
402
- // none, the bars keep the chart's own default rather than being stamped with
403
- // the cards' fallback — an assessment that never asked for pillar colours
404
- // gets exactly the output it got before.
405
- const colorFor = new Map(
406
- pillarCards.filter((c) => c.color !== DEFAULT_PILLAR_COLOR).map((c) => [c.label, c.color]),
407
- );
408
- for (const point of reportPillars) {
409
- const color = colorFor.get(point.label);
410
- if (color) point.color = color;
411
- }
412
-
413
- // The positioning trio names the pillar; the advisory line beside it is the
414
- // band's note for that pillar when one is authored, otherwise the generic
415
- // role of that column. Falling back per-column rather than dropping the
416
- // whole card keeps the section rendering while copy is still being written.
417
- const noteFor = (label: string, fallback: string): string =>
418
- pillarNote(band, label) || fallback;
419
- const pos = (p: ReportChartPoint | undefined, fallback: string): PillarPosition | null =>
420
- p ? { label: p.label, summary: noteFor(p.label, fallback) } : null;
421
-
422
- const strongest = pos(
423
- reportPillars[0],
424
- "Your strongest pillar — protect and compound this advantage.",
425
- );
426
- const opportunity =
427
- reportPillars.length >= 3
428
- ? pos(
429
- reportPillars[1],
430
- "Your biggest growth opportunity — focused effort here lifts overall authority.",
431
- )
432
- : null;
433
- const authorityGap =
434
- reportPillars.length >= 2
435
- ? pos(
436
- reportPillars[reportPillars.length - 1],
437
- "Your lowest pillar — the single biggest lever for improvement right now.",
438
- )
439
- : null;
440
-
441
- return {
442
- pillarCards,
443
- reportScore,
444
- reportPillars,
445
- narrative,
446
- totalScore,
447
- stage: band?.name ?? "",
448
- stageSummary: band?.tagline ?? "",
449
- strongest,
450
- opportunity,
451
- authorityGap,
452
- // Section five: the research pass's person-specific content wins
453
- // field-by-field; without research these are the band's copy, exactly
454
- // as every report before this table rendered them.
455
- strongestAreas: nextSteps?.strongestAreas ?? band?.strongestAreas ?? [],
456
- priorityActions: nextSteps?.priorityActions ?? band?.priorityActions ?? [],
457
- strategicFocus: nextSteps?.strategicFocus ?? band?.strategicFocus ?? "",
458
- strengths: nextSteps?.strengths ?? band?.strengths ?? [],
459
- opportunities: nextSteps?.opportunities ?? band?.opportunities ?? [],
460
- strengthsList: numberItems(nextSteps?.strengths ?? band?.strengths),
461
- opportunitiesList: numberItems(nextSteps?.opportunities ?? band?.opportunities),
462
- twelveMonthTarget: nextSteps?.twelveMonthTarget ?? band?.twelveMonthTarget ?? "",
463
- roadmap: nextSteps?.roadmap ?? orderRoadmap(band?.roadmap),
464
- research: {
465
- ...researchData,
466
- groups: researchData.groups,
467
- weakestAreasList: numberItems(researchData.weakestAreas),
468
- },
469
- whoYouHelp: band?.whoYouHelp ?? "",
470
- knownFor: band?.knownFor ?? "",
471
- insights: band?.insights ?? [],
472
- contentThemes: band?.contentThemes ?? [],
473
- contentRhythm: band?.contentRhythm ?? "",
474
- northStar: band?.northStar ?? "",
475
- priorityFocus: band?.priorityFocus ?? "",
476
- priorityFocusSummary: band?.priorityFocusSummary ?? "",
477
- recommendedTier: band?.recommendedTier ?? "",
478
- tierSummary: band?.tierSummary ?? "",
479
- whyItFits: band?.whyItFits ?? "",
480
- whatItSolves: band?.whatItSolves ?? "",
481
- implementationFocus: band?.implementationFocus ?? "",
482
- expectedOutcome: band?.expectedOutcome ?? "",
483
- finalWord: band?.finalWord ?? "",
484
- };
485
- }