@teamscala/service-boot 0.23.472 → 0.23.473

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.472",
9
+ "version": "0.23.473",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -436,6 +436,22 @@
436
436
  "types": "./src/fn-routers/assessment-report-email.d.ts",
437
437
  "default": "./src/fn-routers/assessment-report-email.ts"
438
438
  },
439
+ "./fn-routers/assessment-report-pair": {
440
+ "types": "./src/fn-routers/assessment-report-pair.d.ts",
441
+ "default": "./src/fn-routers/assessment-report-pair.ts"
442
+ },
443
+ "./fn-routers/assessment-report-pair.ts": {
444
+ "types": "./src/fn-routers/assessment-report-pair.d.ts",
445
+ "default": "./src/fn-routers/assessment-report-pair.ts"
446
+ },
447
+ "./fn-routers/assessment-research-report-data": {
448
+ "types": "./src/fn-routers/assessment-research-report-data.d.ts",
449
+ "default": "./src/fn-routers/assessment-research-report-data.ts"
450
+ },
451
+ "./fn-routers/assessment-research-report-data.ts": {
452
+ "types": "./src/fn-routers/assessment-research-report-data.d.ts",
453
+ "default": "./src/fn-routers/assessment-research-report-data.ts"
454
+ },
439
455
  "./fn-routers/assessment-scoring": {
440
456
  "types": "./src/fn-routers/assessment-scoring.d.ts",
441
457
  "default": "./src/fn-routers/assessment-scoring.ts"
@@ -504,6 +520,14 @@
504
520
  "types": "./src/fn-routers/index.d.ts",
505
521
  "default": "./src/fn-routers/index.ts"
506
522
  },
523
+ "./fn-routers/linkedin-identity": {
524
+ "types": "./src/fn-routers/linkedin-identity.d.ts",
525
+ "default": "./src/fn-routers/linkedin-identity.ts"
526
+ },
527
+ "./fn-routers/linkedin-identity.ts": {
528
+ "types": "./src/fn-routers/linkedin-identity.d.ts",
529
+ "default": "./src/fn-routers/linkedin-identity.ts"
530
+ },
507
531
  "./fn-routers/messaging-whatsapp-router": {
508
532
  "types": "./src/fn-routers/messaging-whatsapp-router.d.ts",
509
533
  "default": "./src/fn-routers/messaging-whatsapp-router.ts"
@@ -18,6 +18,13 @@
18
18
  * lives on `assessments.stage_bands` (migration 560/561).
19
19
  */
20
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
+
21
28
  /** A chart data point — structural match for @teamscala/chart-block's ChartPoint. */
22
29
  export interface ReportChartPoint {
23
30
  label: string;
@@ -156,6 +163,8 @@ export interface ReportData {
156
163
  twelveMonthTarget: string;
157
164
  /** section 10 — three phases, each a focus word plus four actions */
158
165
  roadmap: RoadmapPhase[];
166
+ /** the research half of the report — `{present:false}` when no research pass exists */
167
+ research: ResearchReportData;
159
168
  /** Anna's remaining template elements — empty until a stage authors them. */
160
169
  whoYouHelp: string;
161
170
  knownFor: string;
@@ -180,6 +189,21 @@ export interface ReportResponseInput {
180
189
  category_scores?: Record<string, number> | null;
181
190
  }
182
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
+
183
207
  /** Minimal view of the assessment metadata — title plus the stage model. */
184
208
  export interface ReportAssessmentInput {
185
209
  title?: string | null;
@@ -318,20 +342,45 @@ export function orderRoadmap(roadmap: Record<string, RoadmapPhase> | undefined):
318
342
  * `fn({ input, client, params })` (see resolveByResolverFn in
319
343
  * @teamscala/service-boot/resolve-from-loader-config). computeReportData is
320
344
  * registered as the `computeReportData` resolver_fn row, so it receives that
321
- * context — input.response / input.assessment come from the loader's
322
- * resolver_input ({response: ':response', assessment: ':assessment'}).
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).
323
349
  */
324
350
  export function computeReportData({
325
351
  input,
326
352
  }: {
327
353
  input: {
328
- response?: ReportResponseInput | null;
354
+ response?: ReportPairResponseShape | null;
329
355
  assessment?: ReportAssessmentInput | null;
356
+ research?: ReportResearchInput | null;
357
+ brandName?: string | null;
330
358
  };
331
359
  }): ReportData {
332
360
  const { response, assessment } = input;
333
- const totalScore = Math.max(0, Math.round(Number(response?.total_score ?? 0)));
334
- const categoryScores = response?.category_scores ?? {};
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 ?? {};
335
384
  const reportScore: ReportChartPoint[] = [{ label: "Score", value: totalScore }];
336
385
  const reportPillars: ReportChartPoint[] = Object.entries(categoryScores)
337
386
  .filter(([, value]) => Number.isFinite(Number(value)))
@@ -339,8 +388,13 @@ export function computeReportData({
339
388
  .filter((p) => p.label.length > 0)
340
389
  .sort((a, b) => b.value - a.value);
341
390
 
342
- const band = selectStageBand(readStageBands(assessment), totalScore);
343
- const narrative = generateNarrative(totalScore, band, assessment);
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);
344
398
  const pillarCards = buildPillarCards(assessment, categoryScores, band);
345
399
  // The chart bars take each pillar's own colour, so the bar chart and the
346
400
  // pillar cards cannot disagree about which colour means which pillar.
@@ -395,15 +449,26 @@ export function computeReportData({
395
449
  strongest,
396
450
  opportunity,
397
451
  authorityGap,
398
- strongestAreas: band?.strongestAreas ?? [],
399
- priorityActions: band?.priorityActions ?? [],
400
- strategicFocus: band?.strategicFocus ?? "",
401
- strengths: band?.strengths ?? [],
402
- opportunities: band?.opportunities ?? [],
403
- strengthsList: numberItems(band?.strengths),
404
- opportunitiesList: numberItems(band?.opportunities),
405
- twelveMonthTarget: band?.twelveMonthTarget ?? "",
406
- roadmap: orderRoadmap(band?.roadmap),
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.map((group) => ({
467
+ title: group.title,
468
+ items: numberItems(group.items),
469
+ })),
470
+ weakestAreasList: numberItems(researchData.weakestAreas),
471
+ },
407
472
  whoYouHelp: band?.whoYouHelp ?? "",
408
473
  knownFor: band?.knownFor ?? "",
409
474
  insights: band?.insights ?? [],
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveReportHalves } from "./assessment-report-pair.ts";
3
+ import type { ReportResearchInput } from "./assessment-research-report-data.ts";
4
+
5
+ const ANNA_LINKEDIN_RAW = "https://www.linkedin.com/in/annaosherov/";
6
+ const ANNA_KEY = "linkedin.com/in/annaosherov";
7
+
8
+ function annaResearch(overrides: Partial<ReportResearchInput> = {}): ReportResearchInput {
9
+ return {
10
+ id: "research-anna",
11
+ assessment_id: "assessment-launcha",
12
+ display_name: "Anna Osherov",
13
+ linkedin_url: ANNA_LINKEDIN_RAW,
14
+ identity_key: ANNA_KEY,
15
+ validation_note: "Confirmed.",
16
+ total_score: 71,
17
+ category_scores: { "Be Seen": 57, "Be Heard": 76, "Be Chosen": 80 },
18
+ researched_at: "2026-09-04T00:00:00Z",
19
+ superseded_at: null,
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ const ANNA_RESPONSE = {
25
+ id: "response-anna",
26
+ assessment_id: "assessment-launcha",
27
+ first_name: "Anna",
28
+ linkedin_url: ANNA_LINKEDIN_RAW,
29
+ total_score: 54,
30
+ };
31
+
32
+ function fakeClient(overrides: {
33
+ researchLive?: ReportResearchInput | null;
34
+ responses?: Array<{ linkedin_url: string | null } & Record<string, unknown>>;
35
+ }) {
36
+ return {
37
+ assessment_research: {
38
+ findFirst: async () => overrides.researchLive ?? null,
39
+ },
40
+ assessment_responses: {
41
+ findFirst: async () => null,
42
+ findMany: async () => overrides.responses ?? [],
43
+ },
44
+ };
45
+ }
46
+
47
+ describe("resolveReportHalves", () => {
48
+ test("response-anchored :id pairs the research by normalised identity", async () => {
49
+ const halves = await resolveReportHalves({
50
+ input: { response: ANNA_RESPONSE, research: null, id: "response-anna" },
51
+ client: fakeClient({ researchLive: annaResearch() }),
52
+ params: {},
53
+ });
54
+ expect(halves.research?.identity_key).toBe(ANNA_KEY);
55
+ });
56
+
57
+ test("research-anchored :id pairs the quiz response by normalised identity", async () => {
58
+ const halves = await resolveReportHalves({
59
+ input: { response: null, research: annaResearch(), id: "research-anna" },
60
+ client: fakeClient({
61
+ responses: [
62
+ { id: "response-anna", linkedin_url: ANNA_LINKEDIN_RAW, total_score: 54 },
63
+ ],
64
+ }),
65
+ params: {},
66
+ });
67
+ expect(halves.response?.id).toBe("response-anna");
68
+ });
69
+
70
+ test("a different person's response is never paired", async () => {
71
+ const halves = await resolveReportHalves({
72
+ input: { response: null, research: annaResearch(), id: "research-anna" },
73
+ client: fakeClient({
74
+ responses: [
75
+ {
76
+ id: "response-other",
77
+ linkedin_url: "https://www.linkedin.com/in/malcolm-mclean/",
78
+ total_score: 12,
79
+ },
80
+ ],
81
+ }),
82
+ params: {},
83
+ });
84
+ expect(halves.response).toBeNull();
85
+ });
86
+
87
+ test("a superseded anchor resolves the person's live pass", async () => {
88
+ const superseded = annaResearch({ superseded_at: "2026-09-04T01:00:00Z" });
89
+ const halves = await resolveReportHalves({
90
+ input: { response: null, research: superseded, id: "research-superseded" },
91
+ client: fakeClient({ researchLive: annaResearch() }),
92
+ params: {},
93
+ });
94
+ expect(halves.research?.superseded_at).toBeNull();
95
+ expect(halves.research?.total_score).toBe(71);
96
+ });
97
+
98
+ test("an id matching neither surface refuses instead of rendering an empty report", async () => {
99
+ await expect(
100
+ resolveReportHalves({
101
+ input: { response: null, research: null, id: "unknown-id" },
102
+ client: fakeClient({}),
103
+ params: {},
104
+ }),
105
+ ).rejects.toThrow(/matches no quiz response/);
106
+ });
107
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten @indivisible-unit owner=lane/unified-authority-index-report reason="one pairing unit: the rule that joins a report id to its response+research halves lives here alone"
4
+ * @edit edit directly
5
+ *
6
+ * assessment-report-pair.ts — resolveReportHalves resolver_fn.
7
+ *
8
+ * /report/:id carries EITHER an assessment_responses id OR an
9
+ * assessment_research id. This resolver turns whichever anchor arrived into
10
+ * the complete PAIR by the person's normalised LinkedIn identity
11
+ * (identity_key, shared unit in linkedin-identity.ts) per assessment — so
12
+ * both URL forms render identical content, and a quiz response (re)taken
13
+ * after research still pairs with that person's research.
14
+ *
15
+ * WHY A RESOLVER AND NOT LOADER SOURCES: the join key is a DERIVED value
16
+ * (the normalised URL), not a column equality a row config can express, and
17
+ * the response side stores raw self-reported URLs. The resolver owns the
18
+ * lookup; the loader row stays declarative.
19
+ *
20
+ * SUPERSEDED PASSES NEVER REACH A READER (operator correction 2026-09-04): a
21
+ * research pass can be WRONG and get corrected; the superseded row keeps its
22
+ * history but the report resolves the live pass (superseded_at IS NULL) even
23
+ * when the :id anchor points at the superseded one, so an old link renders
24
+ * the corrected report, never the withdrawn one.
25
+ */
26
+ import { normaliseLinkedinIdentity } from "./linkedin-identity.ts";
27
+ import type { ReportPairResponseInput, ReportResearchInput } from "./assessment-research-report-data.ts";
28
+
29
+ const RESEARCH_SELECT = {
30
+ id: true,
31
+ assessment_id: true,
32
+ display_name: true,
33
+ role_line: true,
34
+ linkedin_url: true,
35
+ identity_key: true,
36
+ website: true,
37
+ organisation_id: true,
38
+ validation_note: true,
39
+ total_score: true,
40
+ category_scores: true,
41
+ answers: true,
42
+ findings: true,
43
+ researched_at: true,
44
+ superseded_at: true,
45
+ } as const;
46
+
47
+ const RESPONSE_SELECT = {
48
+ id: true,
49
+ assessment_id: true,
50
+ first_name: true,
51
+ last_name: true,
52
+ linkedin_url: true,
53
+ website: true,
54
+ total_score: true,
55
+ category_scores: true,
56
+ answers: true,
57
+ completed_at: true,
58
+ organisation_id: true,
59
+ } as const;
60
+
61
+ interface ResearchDelegate {
62
+ findFirst: (args: {
63
+ where: Record<string, unknown>;
64
+ select: typeof RESEARCH_SELECT;
65
+ }) => Promise<ReportResearchInput | null>;
66
+ }
67
+
68
+ interface ResponseDelegate {
69
+ findFirst: (args: {
70
+ where: Record<string, unknown>;
71
+ select: typeof RESPONSE_SELECT;
72
+ }) => Promise<ReportPairResponseInput | null>;
73
+ findMany: (args: {
74
+ where: Record<string, unknown>;
75
+ select: typeof RESPONSE_SELECT;
76
+ orderBy?: Record<string, string>;
77
+ take?: number;
78
+ }) => Promise<Array<ReportPairResponseInput & { linkedin_url: string | null }>>;
79
+ }
80
+
81
+ export interface ReportHalves {
82
+ response: ReportPairResponseInput | null;
83
+ research: ReportResearchInput | null;
84
+ }
85
+
86
+ export async function resolveReportHalves({
87
+ input,
88
+ client,
89
+ }: {
90
+ input: {
91
+ response?: ReportPairResponseInput | null;
92
+ research?: ReportResearchInput | null;
93
+ id?: string;
94
+ };
95
+ client: unknown;
96
+ params: Record<string, unknown>;
97
+ }): Promise<ReportHalves> {
98
+ let response = input.response ?? null;
99
+ let research = input.research ?? null;
100
+ const prisma = client as {
101
+ assessment_research?: ResearchDelegate;
102
+ assessment_responses?: ResponseDelegate;
103
+ };
104
+
105
+ // A superseded anchor resolves to the person's LIVE pass — an old link
106
+ // must render the corrected report, never the withdrawn one.
107
+ if (research && research.superseded_at != null) {
108
+ research = (await prisma.assessment_research?.findFirst({
109
+ where: {
110
+ assessment_id: research.assessment_id,
111
+ identity_key: research.identity_key,
112
+ superseded_at: null,
113
+ },
114
+ select: RESEARCH_SELECT,
115
+ })) ?? null;
116
+ }
117
+
118
+ if (response && !research) {
119
+ const identityKey = normaliseLinkedinIdentity(response.linkedin_url);
120
+ if (identityKey) {
121
+ research = (await prisma.assessment_research?.findFirst({
122
+ where: {
123
+ assessment_id: response.assessment_id,
124
+ identity_key: identityKey,
125
+ superseded_at: null,
126
+ },
127
+ select: RESEARCH_SELECT,
128
+ })) ?? null;
129
+ }
130
+ } else if (research && !response) {
131
+ // Research-only :id — find this person's quiz response by normalised
132
+ // identity. The response side stores raw self-reported URLs, so the
133
+ // normalisation must run per candidate; ordered by recency so a
134
+ // (re)taken quiz pairs with the latest submission.
135
+ const candidates = (await prisma.assessment_responses?.findMany({
136
+ where: { assessment_id: research.assessment_id, linkedin_url: { not: null } },
137
+ select: RESPONSE_SELECT,
138
+ orderBy: { completed_at: "desc" },
139
+ take: 25,
140
+ })) ?? [];
141
+ response =
142
+ candidates.find(
143
+ (candidate) =>
144
+ normaliseLinkedinIdentity(candidate.linkedin_url) === research?.identity_key,
145
+ ) ?? null;
146
+ }
147
+
148
+ if (!response && !research) {
149
+ throw new Error(
150
+ `[assessment-report] /report/${input.id ?? ""} matches no quiz response and no research record — check the id`,
151
+ );
152
+ }
153
+
154
+ return { response, research };
155
+ }
@@ -0,0 +1,251 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildCombinedStatement,
4
+ buildPreparedFor,
5
+ buildResearchReportData,
6
+ gapText,
7
+ readResearchFindings,
8
+ type ReportResearchInput,
9
+ } from "./assessment-research-report-data.ts";
10
+ import { normaliseLinkedinIdentity } from "./linkedin-identity.ts";
11
+ import {
12
+ computeReportData,
13
+ selectStageBand,
14
+ type ReportAssessmentInput,
15
+ type StageBand,
16
+ } from "./assessment-report-data.ts";
17
+
18
+ const ANNA_LINKEDIN_RAW = "https://www.linkedin.com/in/annaosherov/";
19
+ const ANNA_KEY = "linkedin.com/in/annaosherov";
20
+
21
+ const STAGE_BANDS: StageBand[] = [
22
+ { name: "Unknown", max: 20 },
23
+ { name: "Testing", max: 40 },
24
+ { name: "Experimenting", max: 60 },
25
+ { name: "Recognised", max: 80 },
26
+ { name: "Industry Influencer" },
27
+ ];
28
+
29
+ const ASSESSMENT: ReportAssessmentInput = {
30
+ title: "The Launcha Authority Index",
31
+ categories: ["Be Seen", "Be Heard", "Be Chosen"],
32
+ category_descriptions: {
33
+ "Be Seen": { subtitle: "Visibility + Positioning", color: "#2563eb" },
34
+ "Be Heard": { subtitle: "Credibility + Trust", color: "#FF4500" },
35
+ "Be Chosen": { subtitle: "Leads + Conversion", color: "#16a34a" },
36
+ },
37
+ stage_bands: { bands: STAGE_BANDS },
38
+ };
39
+
40
+ const ANNA_RESPONSE = {
41
+ id: "response-anna",
42
+ assessment_id: "assessment-launcha",
43
+ first_name: "Anna",
44
+ last_name: "Osherov",
45
+ linkedin_url: ANNA_LINKEDIN_RAW,
46
+ total_score: 54,
47
+ category_scores: { "Be Seen": 43, "Be Heard": 57, "Be Chosen": 63 },
48
+ completed_at: "2026-09-01T00:00:00Z",
49
+ };
50
+
51
+ function annaResearch(overrides: Partial<ReportResearchInput> = {}): ReportResearchInput {
52
+ return {
53
+ id: "research-anna",
54
+ assessment_id: "assessment-launcha",
55
+ display_name: "Anna Osherov",
56
+ role_line: "CEO, Launcha",
57
+ linkedin_url: ANNA_LINKEDIN_RAW,
58
+ identity_key: ANNA_KEY,
59
+ organisation_id: "org-launcha",
60
+ validation_note:
61
+ "Confirmed against linkedin.com/in/annaosherov — five sources agree; the materials scientist who shares the name is excluded entirely.",
62
+ total_score: 71,
63
+ category_scores: { "Be Seen": 57, "Be Heard": 76, "Be Chosen": 80 },
64
+ findings: {
65
+ execSummary: "Measured from public evidence, you sit a full band higher than you think.",
66
+ intro: "Your LinkedIn profile is the anchor for everything counted here.",
67
+ groups: [
68
+ { title: "Websites", items: ["launcha.marketing is live, current, and identifies you"] },
69
+ ],
70
+ claims: [
71
+ {
72
+ question: "I regularly appear on stages, podcasts, or other people's platforms.",
73
+ selfValue: "1 / 5",
74
+ evidenceValue: "5 / 5",
75
+ why: "Two podcasts and seven years of MC work say otherwise.",
76
+ },
77
+ ],
78
+ weakestAreas: ["A name search you do not own"],
79
+ strategicFocus: "Convert proof you already own into surface you control.",
80
+ sourceSummary: "Sources · linkedin.com/in/annaosherov · launcha.marketing",
81
+ },
82
+ researched_at: "2026-09-04T00:00:00Z",
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ describe("normaliseLinkedinIdentity", () => {
88
+ test("collapses every spelling of the same profile to one key", () => {
89
+ expect(normaliseLinkedinIdentity(ANNA_LINKEDIN_RAW)).toBe(ANNA_KEY);
90
+ expect(normaliseLinkedinIdentity("linkedin.com/in/AnnaOsherov")).toBe(ANNA_KEY);
91
+ expect(normaliseLinkedinIdentity(" LinkedIn.com/in/annaosherov/ ")).toBe(ANNA_KEY);
92
+ });
93
+
94
+ test("refuses non-linkedin and empty input rather than guessing a key", () => {
95
+ expect(normaliseLinkedinIdentity("https://example.com/in/someone")).toBeNull();
96
+ expect(normaliseLinkedinIdentity(" ")).toBeNull();
97
+ expect(normaliseLinkedinIdentity(null)).toBeNull();
98
+ });
99
+ });
100
+
101
+ describe("research shaping", () => {
102
+ test("shapes the five-section research half: identity, findings, gaps, combined standing", () => {
103
+ const data = computeReportData({
104
+ input: {
105
+ response: ANNA_RESPONSE,
106
+ research: annaResearch(),
107
+ assessment: ASSESSMENT,
108
+ brandName: "Launcha Marketing",
109
+ },
110
+ });
111
+
112
+ expect(data.research.present).toBe(true);
113
+ expect(data.research.hasSelfResponse).toBe(true);
114
+ expect(data.research.preparedFor).toBe("Launcha Marketing and Anna Osherov");
115
+ expect(data.research.identifiedBy).toBe(ANNA_LINKEDIN_RAW);
116
+ expect(data.research.validationStatement).toContain("excluded");
117
+ expect(data.research.groups[0]?.title).toBe("Websites");
118
+ expect(data.research.claims[0]?.question).toContain("stages, podcasts");
119
+
120
+ const overall = data.research.gapRows.at(-1);
121
+ expect(overall).toEqual({
122
+ label: "Overall",
123
+ subtitle: "",
124
+ self: "54",
125
+ found: "71",
126
+ gap: "+17",
127
+ });
128
+ expect(data.research.combinedStatement).toContain("17 points higher");
129
+ expect(data.research.combinedBand).toBe("Recognised");
130
+ expect(data.research.reportDate).toBe("2026-09-01");
131
+ });
132
+
133
+ test("research-only person: the research score drives the snapshot and the self sections stay absent", () => {
134
+ const data = computeReportData({
135
+ input: {
136
+ response: null,
137
+ research: annaResearch(),
138
+ assessment: ASSESSMENT,
139
+ brandName: "Launcha Marketing",
140
+ },
141
+ });
142
+ expect(data.totalScore).toBe(71);
143
+ expect(data.research.hasSelfResponse).toBe(false);
144
+ expect(data.research.gapRows).toEqual([]);
145
+ expect(data.narrative).toBe(
146
+ "Measured from public evidence, you sit a full band higher than you think.",
147
+ );
148
+ });
149
+
150
+ test("person-specific next steps win field-by-field; the band copy fills the rest", () => {
151
+ const data = computeReportData({
152
+ input: {
153
+ response: ANNA_RESPONSE,
154
+ research: annaResearch(),
155
+ assessment: ASSESSMENT,
156
+ },
157
+ });
158
+ expect(data.strategicFocus).toBe(
159
+ "Convert proof you already own into surface you control.",
160
+ );
161
+ expect(data.strongestAreas).toEqual([]);
162
+ });
163
+
164
+ test("a superseded research pass is refused — it must never shape a report", () => {
165
+ expect(() =>
166
+ buildResearchReportData({
167
+ research: annaResearch({ superseded_at: "2026-09-04T01:00:00Z" }),
168
+ response: ANNA_RESPONSE,
169
+ assessment: ASSESSMENT,
170
+ bands: STAGE_BANDS,
171
+ resolveBand: (score) => selectStageBand(STAGE_BANDS, score),
172
+ }),
173
+ ).toThrow(/superseded/);
174
+ });
175
+
176
+ test("malformed authored findings throw instead of silently rendering garbage", () => {
177
+ expect(() => readResearchFindings({ groups: "not-an-array" })).toThrow();
178
+ });
179
+ });
180
+
181
+ describe("the rendered text surface is one-person clean", () => {
182
+ // The report page is a pure projection of the loaderData strings: every
183
+ // block binds a fieldPath into reportData/response/quiz, so the rendered
184
+ // text cannot contain anything these strings do not. Serialising every
185
+ // string leaf of the shaped report therefore exercises exactly the text
186
+ // the page can render.
187
+
188
+ function collectStrings(value: unknown, out: string[] = []): string[] {
189
+ if (typeof value === "string") {
190
+ out.push(value);
191
+ } else if (Array.isArray(value)) {
192
+ for (const item of value) collectStrings(item, out);
193
+ } else if (value != null && typeof value === "object") {
194
+ for (const item of Object.values(value)) collectStrings(item, out);
195
+ }
196
+ return out;
197
+ }
198
+
199
+ const OTHER_PERSON_MARKERS = ["Malcolm McLean", "malcolm", "McLean"];
200
+ const GHL_MARKERS = ["GoHighLevel", "ghl_contact", "ghl_", "leadconnectorhq"];
201
+
202
+ test("Anna's report carries no second person's name and no GoHighLevel tag", () => {
203
+ const data = computeReportData({
204
+ input: {
205
+ // the loader select deliberately excludes the ghl_* columns; this
206
+ // response shape is exactly what the row select delivers
207
+ response: ANNA_RESPONSE,
208
+ research: annaResearch(),
209
+ assessment: ASSESSMENT,
210
+ brandName: "Launcha Marketing",
211
+ },
212
+ });
213
+ const strings = collectStrings(data).join(" \n ");
214
+ for (const marker of OTHER_PERSON_MARKERS) {
215
+ expect(strings).not.toContain(marker);
216
+ }
217
+ for (const marker of GHL_MARKERS) {
218
+ expect(strings).not.toContain(marker);
219
+ }
220
+ });
221
+
222
+ // "a different person's research never renders here" is enforced UPSTREAM:
223
+ // the pairing resolver matches halves by normalised LinkedIn identity, and
224
+ // its refusal tests live in fn-routers/assessment-report-pair.test.ts.
225
+ });
226
+
227
+ describe("small derivations", () => {
228
+ test("gapText is signed and plain", () => {
229
+ expect(gapText(54, 71)).toBe("+17");
230
+ expect(gapText(71, 54)).toBe("-17");
231
+ expect(gapText(54, 54)).toBe("0");
232
+ });
233
+
234
+ test("prepared-for line reads exactly brand and person", () => {
235
+ expect(buildPreparedFor("Launcha Marketing", "Anna Osherov")).toBe(
236
+ "Launcha Marketing and Anna Osherov",
237
+ );
238
+ });
239
+
240
+ test("combined statement names the direction, size and band", () => {
241
+ const statement = buildCombinedStatement({
242
+ selfScore: 54,
243
+ researchScore: 71,
244
+ bandSteps: 1,
245
+ researchBandName: "Recognised",
246
+ });
247
+ expect(statement).toContain("17 points higher");
248
+ expect(statement).toContain("one band higher");
249
+ expect(statement).toContain("Recognised");
250
+ });
251
+ });
@@ -0,0 +1,421 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten @indivisible-unit owner=lane/unified-authority-index-report reason="one research-shaping unit: findings validation, gap derivation and next-step precedence change together"
4
+ * @edit edit directly
5
+ *
6
+ * assessment-research-report-data.ts — research pass → report data.
7
+ *
8
+ * Shapes a stored `assessment_research` row into the report fields the quiz
9
+ * report page renders for the research half (sections: what we found online,
10
+ * where you stand, why, and the research-authored next steps). The findings
11
+ * JSONB is AUTHORING DATA owned by the research pass; this unit validates it
12
+ * against the db-validation contract and derives only what derivation owes —
13
+ * the per-pillar gap table, the combined band statement and the merged
14
+ * next-steps — never new opinions about the person.
15
+ *
16
+ * PAIRING (see linkedin-identity.ts): research attaches to a quiz response by
17
+ * normalised LinkedIn identity per assessment, never by being a response row.
18
+ * A superseded pass never reaches a reader: the caller resolves the live pass
19
+ * (superseded_at IS NULL) and this unit re-checks, refusing to shape a
20
+ * superseded record into a report.
21
+ *
22
+ * NEXT-STEPS PRECEDENCE: the research pass authors per-person next steps;
23
+ * the assessment's stage bands author per-band copy. When both exist the
24
+ * research wins field-by-field (findings.strongestAreas ?? band.strongestAreas)
25
+ * so a person-specific finding can refine — never be overridden by — generic
26
+ * band advice, and an omitted field falls through to the band.
27
+ */
28
+
29
+ import * as v from "valibot";
30
+ import {
31
+ assessmentResearchFindingsSchema,
32
+ } from "@teamscala/db-validation/generated/assessment-research-findings";
33
+ import type {
34
+ ReportAssessmentInput,
35
+ ReportData,
36
+ StageBand,
37
+ TitledAction,
38
+ } from "./assessment-report-data.ts";
39
+ import { normaliseLinkedinIdentity } from "./linkedin-identity.ts";
40
+
41
+ /** One authored contrast: the question, both sides, and the reasoning. */
42
+ export interface ResearchClaim {
43
+ question: string;
44
+ selfValue: string;
45
+ evidenceValue: string;
46
+ why: string;
47
+ }
48
+
49
+ /** One authored findings group (websites, visibility, media, frictions, …). */
50
+ export interface ResearchGroup {
51
+ title: string;
52
+ items: string[];
53
+ }
54
+
55
+ /** The authored findings blob on `assessment_research.findings`. */
56
+ export interface ResearchFindings {
57
+ execSummary?: string;
58
+ intro?: string;
59
+ calloutTitle?: string;
60
+ calloutBody?: string;
61
+ groups?: ResearchGroup[];
62
+ claims?: ResearchClaim[];
63
+ weakestAreas?: string[];
64
+ strongestAreas?: string[];
65
+ priorityActions?: TitledAction[];
66
+ strategicFocus?: string;
67
+ strengths?: string[];
68
+ opportunities?: string[];
69
+ twelveMonthTarget?: string;
70
+ sprintPhases?: Array<{ days: string; focus: string; actions: string[] }>;
71
+ rawScoresNote?: string;
72
+ sourceSummary?: string;
73
+ }
74
+
75
+ /** Minimal view of a live assessment_research row as the loader selects it. */
76
+ export interface ReportResearchInput {
77
+ id: string;
78
+ assessment_id: string;
79
+ display_name: string;
80
+ role_line?: string | null;
81
+ linkedin_url: string;
82
+ identity_key: string;
83
+ website?: string | null;
84
+ organisation_id?: string | null;
85
+ validation_note: string;
86
+ total_score?: number | null;
87
+ category_scores?: Record<string, number> | null;
88
+ answers?: unknown;
89
+ findings?: unknown;
90
+ researched_at?: Date | string | null;
91
+ superseded_at?: Date | string | null;
92
+ }
93
+
94
+ /** Extended response view: the identity fields the pairing reads. */
95
+ export interface ReportPairResponseInput {
96
+ id: string;
97
+ assessment_id: string;
98
+ first_name?: string | null;
99
+ last_name?: string | null;
100
+ linkedin_url?: string | null;
101
+ website?: string | null;
102
+ total_score?: number | null;
103
+ category_scores?: Record<string, number> | null;
104
+ completed_at?: Date | string | null;
105
+ }
106
+
107
+ /**
108
+ * Validate authored findings against the db-validation contract. Returns {}
109
+ * for absent findings; THROWS for malformed findings — authored content that
110
+ * does not match the schema is a writer defect to surface, not an empty
111
+ * section to silently render.
112
+ */
113
+ export function readResearchFindings(raw: unknown): ResearchFindings {
114
+ if (raw == null) return {};
115
+ const parsed = v.parse(
116
+ assessmentResearchFindingsSchema,
117
+ raw,
118
+ ) as unknown as ResearchFindings;
119
+ return parsed;
120
+ }
121
+
122
+ /** A research row may shape a report only while it is the live pass. */
123
+ export function assertLiveResearch(research: ReportResearchInput | null | undefined): void {
124
+ if (research && research.superseded_at != null) {
125
+ throw new Error(
126
+ `[assessment-research] research pass ${research.id} is superseded (${String(research.superseded_at)}) and must never render — resolve the live pass (superseded_at IS NULL) for this person`,
127
+ );
128
+ }
129
+ }
130
+
131
+ /** The person's display name: quiz response names first, research second. */
132
+ export function resolvePersonName(
133
+ response: ReportPairResponseInput | null | undefined,
134
+ research: ReportResearchInput | null | undefined,
135
+ ): string {
136
+ const fromResponse = [response?.first_name, response?.last_name]
137
+ .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
138
+ .join(" ")
139
+ .trim();
140
+ if (fromResponse.length > 0) return fromResponse;
141
+ const fromResearch = research?.display_name?.trim();
142
+ return fromResearch ?? "";
143
+ }
144
+
145
+ /**
146
+ * The prepared-for line, exactly: `<brand> and <person>`. Brand first, as the
147
+ * report is prepared FOR the brand about the person.
148
+ */
149
+ export function buildPreparedFor(brandName: string, personName: string): string {
150
+ const brand = brandName.trim();
151
+ const person = personName.trim();
152
+ if (brand.length === 0) return person;
153
+ if (person.length === 0) return brand;
154
+ return `${brand} and ${person}`;
155
+ }
156
+
157
+ function numberOrZero(value: number | null | undefined): number {
158
+ const n = Number(value);
159
+ return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0;
160
+ }
161
+
162
+ /** Signed, plain gap text: "+14" / "-3" / "0". */
163
+ export function gapText(self: number, found: number): string {
164
+ const delta = found - self;
165
+ return delta > 0 ? `+${delta}` : String(delta);
166
+ }
167
+
168
+ export interface GapRow {
169
+ label: string;
170
+ subtitle: string;
171
+ self: string;
172
+ found: string;
173
+ gap: string;
174
+ }
175
+
176
+ const BAND_STEP_WORDS = ["zero", "one", "two", "three", "four", "five"];
177
+
178
+ /**
179
+ * The combined standing statement, assembled from the numbers and band
180
+ * positions the caller derives (it owns band selection): "Measured against
181
+ * the evidence, you sit 17 points higher than you placed yourself — one band
182
+ * higher, in Recognised."
183
+ */
184
+ export function buildCombinedStatement(opts: {
185
+ selfScore: number;
186
+ researchScore: number;
187
+ /** band count between self and research: >0 when the band moved */
188
+ bandSteps: number;
189
+ researchBandName: string;
190
+ }): string {
191
+ const { selfScore, researchScore, bandSteps, researchBandName } = opts;
192
+ const delta = researchScore - selfScore;
193
+ if (delta === 0) {
194
+ return "Measured against the evidence, the evidence agrees with where you placed yourself.";
195
+ }
196
+ const direction = delta > 0 ? "higher" : "lower";
197
+ let bandClause = "";
198
+ if (bandSteps !== 0) {
199
+ const word = BAND_STEP_WORDS[Math.abs(bandSteps)] ?? String(Math.abs(bandSteps));
200
+ bandClause = `, ${word} band${Math.abs(bandSteps) === 1 ? "" : "s"} ${direction}${researchBandName ? `, in ${researchBandName}` : ""}`;
201
+ }
202
+ return `Measured against the evidence, you sit ${Math.abs(delta)} points ${direction} than you placed yourself${bandClause}.`;
203
+ }
204
+
205
+ /** Resolved research half of a report — bound as `reportData.research` fields. */
206
+ export interface ResearchReportData {
207
+ present: boolean;
208
+ /** true when a quiz response also exists — section one renders only then */
209
+ hasSelfResponse: boolean;
210
+ personName: string;
211
+ preparedFor: string;
212
+ identifiedBy: string;
213
+ validationStatement: string;
214
+ roleLine: string;
215
+ execSummary: string;
216
+ intro: string;
217
+ calloutTitle: string;
218
+ calloutBody: string;
219
+ groups: Array<{ title: string; items: Array<{ index: string; text: string }> }>;
220
+ claims: ResearchClaim[];
221
+ gapRows: GapRow[];
222
+ combinedBand: string;
223
+ combinedDelta: string;
224
+ combinedStatement: string;
225
+ weakestAreas: string[];
226
+ methodRawScores: string;
227
+ methodSources: string;
228
+ researchedAt: string;
229
+ /** the report's date: the quiz completion when a response exists, else the research date */
230
+ reportDate: string;
231
+ }
232
+
233
+ function isoDay(value: Date | string | null | undefined): string {
234
+ if (value == null) return "";
235
+ const d = value instanceof Date ? value : new Date(value);
236
+ if (Number.isNaN(d.getTime())) return "";
237
+ return d.toISOString().slice(0, 10);
238
+ }
239
+
240
+ /**
241
+ * Shape the research half. When research is absent the result is
242
+ * `{ present: false, …empty }` — callers bind the quiz-only report exactly as
243
+ * before, and the page hides the research sections with visibleWhenPath.
244
+ *
245
+ * `resolveBand` is the caller's own band selector (selectStageBand over the
246
+ * assessment's parsed bands) injected rather than imported — the band
247
+ * primitives stay single-homed in assessment-report-data and this unit keeps
248
+ * no runtime dependency on it.
249
+ */
250
+ export function buildResearchReportData(opts: {
251
+ research: ReportResearchInput | null | undefined;
252
+ response: ReportPairResponseInput | null | undefined;
253
+ assessment: ReportAssessmentInput | null | undefined;
254
+ resolveBand: (score: number) => StageBand | null;
255
+ /** the parsed bands, for band-distance derivation */
256
+ bands: StageBand[];
257
+ brandName?: string | null;
258
+ }): ResearchReportData {
259
+ const { research, response, assessment } = opts;
260
+ const resolveBand = opts.resolveBand;
261
+ const bands = opts.bands;
262
+ const brandName = opts.brandName?.trim() ?? "";
263
+ const personName = resolvePersonName(response, research);
264
+ const preparedFor = buildPreparedFor(brandName, personName);
265
+
266
+ const empty: ResearchReportData = {
267
+ present: false,
268
+ hasSelfResponse: response != null,
269
+ personName,
270
+ preparedFor,
271
+ identifiedBy: "",
272
+ validationStatement: "",
273
+ roleLine: "",
274
+ execSummary: "",
275
+ intro: "",
276
+ calloutTitle: "",
277
+ calloutBody: "",
278
+ groups: [],
279
+ claims: [],
280
+ gapRows: [],
281
+ combinedBand: "",
282
+ combinedDelta: "",
283
+ combinedStatement: "",
284
+ weakestAreas: [],
285
+ methodRawScores: "",
286
+ methodSources: "",
287
+ researchedAt: "",
288
+ reportDate: "",
289
+ };
290
+ if (!research) return empty;
291
+
292
+ assertLiveResearch(research);
293
+
294
+ const findings = readResearchFindings(research.findings);
295
+
296
+ const selfScores: Record<string, number> = response?.category_scores ?? {};
297
+ const foundScores: Record<string, number> = research.category_scores ?? {};
298
+ const categories = Array.isArray(assessment?.categories)
299
+ ? (assessment.categories as unknown[]).filter(
300
+ (c): c is string => typeof c === "string" && c.length > 0,
301
+ )
302
+ : Object.keys(foundScores);
303
+
304
+ const gapRows: GapRow[] = [];
305
+ if (response) {
306
+ for (const label of categories) {
307
+ const self = selfScores[label];
308
+ const found = numberOrZero(foundScores[label]);
309
+ const display = (assessment?.category_descriptions &&
310
+ typeof assessment.category_descriptions === "object"
311
+ ? (assessment.category_descriptions as Record<string, { subtitle?: string }>)
312
+ : {})[label];
313
+ gapRows.push({
314
+ label,
315
+ subtitle: display?.subtitle ?? "",
316
+ self: typeof self === "number" ? String(Math.round(self)) : "—",
317
+ found: String(found),
318
+ gap: typeof self === "number" ? gapText(Math.round(self), found) : "—",
319
+ });
320
+ }
321
+ const selfTotal = numberOrZero(response?.total_score);
322
+ const foundTotal = numberOrZero(research.total_score);
323
+ gapRows.push({
324
+ label: "Overall",
325
+ subtitle: "",
326
+ self: String(selfTotal),
327
+ found: String(foundTotal),
328
+ gap: gapText(selfTotal, foundTotal),
329
+ });
330
+ }
331
+
332
+ const selfScore = numberOrZero(response?.total_score);
333
+ const researchScore = numberOrZero(research.total_score);
334
+ const researchBand = resolveBand(researchScore);
335
+ let bandSteps = 0;
336
+ if (response && researchBand) {
337
+ const selfBand = resolveBand(selfScore);
338
+ bandSteps = selfBand && researchBand ? Math.abs(bands.indexOf(researchBand) - bands.indexOf(selfBand)) : 0;
339
+ }
340
+
341
+ return {
342
+ present: true,
343
+ hasSelfResponse: response != null,
344
+ personName,
345
+ preparedFor,
346
+ identifiedBy: research.linkedin_url,
347
+ validationStatement: research.validation_note,
348
+ roleLine: research.role_line ?? "",
349
+ execSummary: findings.execSummary ?? "",
350
+ intro: findings.intro ?? "",
351
+ calloutTitle: findings.calloutTitle ?? "",
352
+ calloutBody: findings.calloutBody ?? "",
353
+ groups: (findings.groups ?? []).slice(0, 4).map((group) => ({
354
+ title: group.title,
355
+ items: group.items,
356
+ })),
357
+ claims: (findings.claims ?? []).slice(0, 3),
358
+ gapRows,
359
+ combinedBand: researchBand?.name ?? "",
360
+ combinedDelta: gapText(selfScore, researchScore),
361
+ combinedStatement: response
362
+ ? buildCombinedStatement({
363
+ selfScore,
364
+ researchScore,
365
+ bandSteps,
366
+ researchBandName: researchBand?.name ?? "",
367
+ })
368
+ : "",
369
+ weakestAreas: findings.weakestAreas ?? [],
370
+ methodRawScores: findings.rawScoresNote ?? "",
371
+ methodSources: findings.sourceSummary ?? "",
372
+ researchedAt: isoDay(research.researched_at),
373
+ reportDate: isoDay(response?.completed_at) || isoDay(research.researched_at),
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Section five merge: person-specific research content wins field-by-field;
379
+ * an omitted field falls through to the band's copy; the band copy stands
380
+ * alone when no research exists.
381
+ */
382
+ export function mergeNextSteps(
383
+ band: StageBand | null,
384
+ findings: ResearchFindings,
385
+ ): Pick<
386
+ ReportData,
387
+ | "strongestAreas"
388
+ | "priorityActions"
389
+ | "strategicFocus"
390
+ | "strengths"
391
+ | "opportunities"
392
+ | "twelveMonthTarget"
393
+ | "roadmap"
394
+ > {
395
+ return {
396
+ strongestAreas: findings.strongestAreas ?? band?.strongestAreas ?? [],
397
+ priorityActions: findings.priorityActions ?? band?.priorityActions ?? [],
398
+ strategicFocus: findings.strategicFocus ?? band?.strategicFocus ?? "",
399
+ strengths: findings.strengths ?? band?.strengths ?? [],
400
+ opportunities: findings.opportunities ?? band?.opportunities ?? [],
401
+ twelveMonthTarget: findings.twelveMonthTarget ?? band?.twelveMonthTarget ?? "",
402
+ roadmap:
403
+ findings.sprintPhases ??
404
+ (band?.roadmap
405
+ ? Object.keys(band.roadmap)
406
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
407
+ .map((key) => band.roadmap?.[key])
408
+ .filter((phase): phase is NonNullable<typeof phase> => !!phase)
409
+ : []),
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Pairing lookup used by the report loader's pair resolver: the identity key
415
+ * BOTH sides share, computed with the one shared normalisation.
416
+ */
417
+ export function pairingKeyForResponse(
418
+ response: Pick<ReportPairResponseInput, "linkedin_url"> | null | undefined,
419
+ ): string | null {
420
+ return normaliseLinkedinIdentity(response?.linkedin_url);
421
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten @indivisible-unit owner=lane/unified-authority-index-report reason="one identity normalisation shared by the research writer and the report pairing lookup — a second spelling of the rule is the drift the pairing exists to prevent"
4
+ * @edit edit directly
5
+ *
6
+ * linkedin-identity.ts — the ONE normalisation from a LinkedIn profile URL to
7
+ * the pairing key that joins research passes to quiz responses.
8
+ *
9
+ * WHY IT EXISTS AS A SHARED UNIT (operator pin, 2026-09-04): the same person
10
+ * arrives as `https://www.linkedin.com/in/annaosherov/` on a quiz response and
11
+ * as `linkedin.com/in/AnnaOsherov` in a research note. Both sides MUST
12
+ * collapse to the same `identity_key` or the report renders one half of the
13
+ * person. The normalisation therefore lives here, exported once, and every
14
+ * surface that writes or looks up an identity key calls THIS function — a
15
+ * second local copy of the rule is the drift the pairing exists to prevent.
16
+ *
17
+ * Rule: trim; lowercase; strip the scheme (http/https); strip a leading
18
+ * `www.`; strip one trailing slash. Everything else is kept verbatim, so
19
+ * `linkedin.com/in/annaosherov` and a custom slug both survive intact.
20
+ * Non-LinkedIn or empty input returns null — callers refuse rather than pair
21
+ * on a guessed key.
22
+ */
23
+
24
+ export function normaliseLinkedinIdentity(
25
+ rawUrl: string | null | undefined,
26
+ ): string | null {
27
+ if (typeof rawUrl !== "string") return null;
28
+ let value = rawUrl.trim().toLowerCase();
29
+ if (value.length === 0) return null;
30
+ value = value.replace(/^https?:\/\//, "");
31
+ value = value.replace(/^www\./, "");
32
+ value = value.replace(/\/+$/, "");
33
+ if (value.length === 0) return null;
34
+ if (!value.includes("linkedin.com")) return null;
35
+ return value;
36
+ }
@@ -239,7 +239,6 @@ export async function mountOrpcRouter(
239
239
  // only signal is a fleet-wide spike in 500s with no traceback —
240
240
  // measured 2026-09-04 when the regenerated prisma client shipped
241
241
  // an empty orm and every model action returned a silent 500.
242
- // This makes every 500 LOUD with the request path for routing.
243
242
  if (result.response.status >= 500) {
244
243
  log.error(
245
244
  `[ORPC] ${c.req.method} ${c.req.path} → ${result.response.status}`,