@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 +25 -1
- package/src/fn-routers/assessment-report-data.ts +81 -16
- package/src/fn-routers/assessment-report-pair.test.ts +107 -0
- package/src/fn-routers/assessment-report-pair.ts +155 -0
- package/src/fn-routers/assessment-research-report-data.test.ts +251 -0
- package/src/fn-routers/assessment-research-report-data.ts +421 -0
- package/src/fn-routers/linkedin-identity.ts +36 -0
- package/src/mount-orpc-router.ts +0 -1
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
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
|
|
322
|
-
* resolver_input
|
|
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?:
|
|
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
|
|
334
|
-
const
|
|
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(
|
|
343
|
-
const
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
}
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -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}`,
|