@teamscala/service-boot 0.23.481 → 0.23.493

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-ws-routes.ts +1 -1
  7. package/src/fn-routers/agreement-certificate.ts +0 -224
  8. package/src/fn-routers/agreement-completion-email.ts +0 -114
  9. package/src/fn-routers/agreement-send-router.ts +0 -303
  10. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  11. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  12. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  13. package/src/fn-routers/assessment-report-data.ts +0 -485
  14. package/src/fn-routers/assessment-report-email.ts +0 -256
  15. package/src/fn-routers/assessment-report-pair.test.ts +0 -136
  16. package/src/fn-routers/assessment-report-pair.ts +0 -155
  17. package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
  18. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  19. package/src/fn-routers/assessment-scoring.ts +0 -105
  20. package/src/fn-routers/auth-router.ts +0 -65
  21. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  22. package/src/fn-routers/chat-session-router.ts +0 -356
  23. package/src/fn-routers/conversation-ownership.ts +0 -24
  24. package/src/fn-routers/email-defaults.ts +0 -28
  25. package/src/fn-routers/email-link-ttl.ts +0 -28
  26. package/src/fn-routers/index.ts +0 -76
  27. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  28. package/src/fn-routers/linkedin-identity.ts +0 -36
  29. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  30. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  31. package/src/fn-routers/panel-data-router.ts +0 -104
  32. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  33. package/src/fn-routers/persist-chat-messages.ts +0 -98
  34. package/src/fn-routers/proposal-send-router.ts +0 -207
  35. package/src/fn-routers/public-agreement-router.ts +0 -407
  36. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  37. package/src/fn-routers/public-assessment-router.ts +0 -268
  38. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  39. package/src/fn-routers/resend-client.ts +0 -39
  40. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  41. package/src/fn-routers/user-preferences-router.ts +0 -130
  42. package/src/fn-routers/work-items-router.ts +0 -342
  43. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  44. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  45. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,233 +0,0 @@
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
- import { assessmentResearchFindingsSchema } from "@teamscala/db-validation/generated/assessment-research-findings";
18
-
19
- const ANNA_LINKEDIN_RAW = "https://www.linkedin.com/in/annaosherov/";
20
- const ANNA_KEY = "linkedin.com/in/annaosherov";
21
-
22
- const STAGE_BANDS: StageBand[] = [
23
- { name: "Unknown", max: 20 },
24
- { name: "Testing", max: 40 },
25
- { name: "Experimenting", max: 60 },
26
- { name: "Recognised", max: 80 },
27
- { name: "Industry Influencer" },
28
- ];
29
-
30
- const ASSESSMENT: ReportAssessmentInput = {
31
- title: "The Launcha Authority Index",
32
- categories: ["Be Seen", "Be Heard", "Be Chosen"],
33
- category_descriptions: {
34
- "Be Seen": { subtitle: "Visibility + Positioning", color: "#2563eb" },
35
- "Be Heard": { subtitle: "Credibility + Trust", color: "#FF4500" },
36
- "Be Chosen": { subtitle: "Leads + Conversion", color: "#16a34a" },
37
- },
38
- stage_bands: { bands: STAGE_BANDS },
39
- };
40
-
41
- const ANNA_RESPONSE = {
42
- id: "response-anna",
43
- assessment_id: "assessment-launcha",
44
- first_name: "Anna",
45
- last_name: "Osherov",
46
- linkedin_url: ANNA_LINKEDIN_RAW,
47
- total_score: 54,
48
- category_scores: { "Be Seen": 43, "Be Heard": 57, "Be Chosen": 63 },
49
- completed_at: "2026-09-01T00:00:00Z",
50
- };
51
-
52
- function annaResearch(overrides: Partial<ReportResearchInput> = {}): ReportResearchInput {
53
- return {
54
- id: "research-anna",
55
- assessment_id: "assessment-launcha",
56
- display_name: "Anna Osherov",
57
- role_line: "CEO, Launcha",
58
- linkedin_url: ANNA_LINKEDIN_RAW,
59
- identity_key: ANNA_KEY,
60
- organisation_id: "org-launcha",
61
- validation_note:
62
- "Confirmed against linkedin.com/in/annaosherov — five sources agree; the materials scientist who shares the name is excluded entirely.",
63
- total_score: 71,
64
- category_scores: { "Be Seen": 57, "Be Heard": 76, "Be Chosen": 80 },
65
- findings: {
66
- execSummary: "Measured from public evidence, you sit a full band higher than you think.",
67
- intro: "Your LinkedIn profile is the anchor for everything counted here.",
68
- groups: [
69
- { title: "Websites", items: ["launcha.marketing is live, current, and identifies you"] },
70
- ],
71
- claims: [
72
- {
73
- question: "I regularly appear on stages, podcasts, or other people's platforms.",
74
- selfValue: "1 / 5",
75
- evidenceValue: "5 / 5",
76
- why: "Two podcasts and seven years of MC work say otherwise.",
77
- },
78
- ],
79
- weakestAreas: ["A name search you do not own"],
80
- strategicFocus: "Convert proof you already own into surface you control.",
81
- sourceSummary: "Sources · linkedin.com/in/annaosherov · launcha.marketing",
82
- },
83
- researched_at: "2026-09-04T00:00:00Z",
84
- ...overrides,
85
- };
86
- }
87
-
88
- describe("normaliseLinkedinIdentity", () => {
89
- test("collapses every spelling of the same profile to one key", () => {
90
- expect(normaliseLinkedinIdentity(ANNA_LINKEDIN_RAW)).toBe(ANNA_KEY);
91
- expect(normaliseLinkedinIdentity("linkedin.com/in/AnnaOsherov")).toBe(ANNA_KEY);
92
- expect(normaliseLinkedinIdentity(" LinkedIn.com/in/annaosherov/ ")).toBe(ANNA_KEY);
93
- });
94
-
95
- test("refuses non-linkedin and empty input rather than guessing a key", () => {
96
- expect(normaliseLinkedinIdentity("https://example.com/in/someone")).toBeNull();
97
- expect(normaliseLinkedinIdentity(" ")).toBeNull();
98
- expect(normaliseLinkedinIdentity(null)).toBeNull();
99
- });
100
- });
101
-
102
- describe("research shaping", () => {
103
- test("shapes the five-section research half: identity, findings, gaps, combined standing", () => {
104
- const data = computeReportData({
105
- input: {
106
- response: ANNA_RESPONSE,
107
- research: annaResearch(),
108
- assessment: ASSESSMENT,
109
- brandName: "Launcha Marketing",
110
- },
111
- });
112
-
113
- expect(data.research.present).toBe(true);
114
- expect(data.research.hasSelfResponse).toBe(true);
115
- expect(data.research.preparedFor).toBe("Launcha Marketing and Anna Osherov");
116
- expect(data.research.identifiedBy).toBe(ANNA_LINKEDIN_RAW);
117
- expect(data.research.validationStatement).toContain("excluded");
118
- expect(data.research.groups[0]?.title).toBe("Websites");
119
- expect(data.research.claims[0]?.question).toContain("stages, podcasts");
120
-
121
- const overall = data.research.gapRows.at(-1);
122
- expect(overall).toEqual({
123
- label: "Overall",
124
- subtitle: "",
125
- self: "54",
126
- found: "71",
127
- gap: "+17",
128
- });
129
- expect(data.research.combinedStatement).toContain("17 points higher");
130
- expect(data.research.combinedBand).toBe("Recognised");
131
- expect(data.research.reportDate).toBe("2026-09-04");
132
- });
133
-
134
- test("research-only person: the research score drives the snapshot and the self sections stay absent", () => {
135
- const data = computeReportData({
136
- input: {
137
- response: null,
138
- research: annaResearch(),
139
- assessment: ASSESSMENT,
140
- brandName: "Launcha Marketing",
141
- },
142
- });
143
- expect(data.totalScore).toBe(71);
144
- expect(data.research.hasSelfResponse).toBe(false);
145
- expect(data.research.gapRows).toEqual([]);
146
- expect(data.narrative).toBe(
147
- "Measured from public evidence, you sit a full band higher than you think.",
148
- );
149
- });
150
-
151
- test("person-specific next steps win field-by-field; the band copy fills the rest", () => {
152
- const data = computeReportData({
153
- input: {
154
- response: ANNA_RESPONSE,
155
- research: annaResearch(),
156
- assessment: ASSESSMENT,
157
- },
158
- });
159
- expect(data.strategicFocus).toBe(
160
- "Convert proof you already own into surface you control.",
161
- );
162
- expect(data.strongestAreas).toEqual([]);
163
- });
164
-
165
- test("a superseded research pass is refused — it must never shape a report", () => {
166
- expect(() =>
167
- buildResearchReportData({
168
- research: annaResearch({ superseded_at: "2026-09-04T01:00:00Z" }),
169
- response: ANNA_RESPONSE,
170
- assessment: ASSESSMENT,
171
- bands: STAGE_BANDS,
172
- resolveBand: (score) => selectStageBand(STAGE_BANDS, score),
173
- }),
174
- ).toThrow(/superseded/);
175
- });
176
-
177
- // The db-validation generated schema is the contract this parse enforces.
178
- // While the generated artifact is still the permissive v.record fallback
179
- // (the codegen engine that emits the faithful schema embeds
180
- // STRUCTURED_JSONB_KEYS at its build and needs an engine rebuild with
181
- // @teamscala/codegen >= 2.1.585 — then `scala-tools codegen run --output
182
- // db-column-validation --force`, publish db-validation, bump here), this
183
- // test activates itself: the skip is a property of the shipped artifact,
184
- // not of the expectation.
185
- const schemaIsFaithful = !("key" in (assessmentResearchFindingsSchema as object));
186
- const enforceTest = schemaIsFaithful ? test : test.skip;
187
- enforceTest("malformed authored findings throw instead of silently rendering garbage", () => {
188
- expect(() => readResearchFindings({ groups: "not-an-array" })).toThrow();
189
- });
190
- });
191
-
192
- describe("the rendered text surface is one-person clean", () => {
193
- // The report page is a pure projection of the loaderData strings: every
194
- // block binds a fieldPath into reportData/response/quiz, so the rendered
195
- // text cannot contain anything these strings do not. Serialising every
196
- // string leaf of the shaped report therefore exercises exactly the text
197
- // the page can render.
198
-
199
- function collectStrings(value: unknown, out: string[] = []): string[] {
200
- if (typeof value === "string") {
201
- out.push(value);
202
- } else if (Array.isArray(value)) {
203
- for (const item of value) collectStrings(item, out);
204
- } else if (value != null && typeof value === "object") {
205
- for (const item of Object.values(value)) collectStrings(item, out);
206
- }
207
- return out;
208
- }
209
-
210
- const OTHER_PERSON_MARKERS = ["Malcolm McLean", "malcolm", "McLean"];
211
- const GHL_MARKERS = ["GoHighLevel", "ghl_contact", "ghl_", "leadconnectorhq"];
212
-
213
- test("Anna's report carries no second person's name and no GoHighLevel tag", () => {
214
- const data = computeReportData({
215
- input: {
216
- // the loader select deliberately excludes the ghl_* columns; this
217
- // response shape is exactly what the row select delivers
218
- response: ANNA_RESPONSE,
219
- research: annaResearch(),
220
- assessment: ASSESSMENT,
221
- brandName: "Launcha Marketing",
222
- },
223
- });
224
- const strings = collectStrings(data).join(" \n ");
225
- for (const marker of OTHER_PERSON_MARKERS) {
226
- expect(strings).not.toContain(marker);
227
- }
228
- for (const marker of GHL_MARKERS) {
229
- expect(strings).not.toContain(marker);
230
- }
231
- });
232
- });
233
-
@@ -1,424 +0,0 @@
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
- ReportPairResponseShape,
37
- StageBand,
38
- TitledAction,
39
- } from "./assessment-report-data.ts";
40
- import { normaliseLinkedinIdentity } from "./linkedin-identity.ts";
41
-
42
- /** One authored contrast: the question, both sides, and the reasoning. */
43
- export interface ResearchClaim {
44
- question: string;
45
- selfValue: string;
46
- evidenceValue: string;
47
- why: string;
48
- }
49
-
50
- /** One authored findings group (websites, visibility, media, frictions, …). */
51
- export interface ResearchGroup {
52
- title: string;
53
- items: string[];
54
- }
55
-
56
- /** The authored findings blob on `assessment_research.findings`. */
57
- export interface ResearchFindings {
58
- execSummary?: string;
59
- intro?: string;
60
- calloutTitle?: string;
61
- calloutBody?: string;
62
- groups?: ResearchGroup[];
63
- claims?: ResearchClaim[];
64
- weakestAreas?: string[];
65
- strongestAreas?: string[];
66
- priorityActions?: TitledAction[];
67
- strategicFocus?: string;
68
- strengths?: string[];
69
- opportunities?: string[];
70
- twelveMonthTarget?: string;
71
- sprintPhases?: Array<{ days: string; focus: string; actions: string[] }>;
72
- rawScoresNote?: string;
73
- sourceSummary?: string;
74
- }
75
-
76
- /** Minimal view of a live assessment_research row as the loader selects it. */
77
- export interface ReportResearchInput {
78
- id: string;
79
- assessment_id: string;
80
- display_name: string;
81
- role_line?: string | null;
82
- linkedin_url: string;
83
- identity_key: string;
84
- website?: string | null;
85
- organisation_id?: string | null;
86
- validation_note: string;
87
- total_score?: number | null;
88
- category_scores?: Record<string, number> | null;
89
- answers?: unknown;
90
- findings?: unknown;
91
- researched_at?: Date | string | null;
92
- superseded_at?: Date | string | null;
93
- }
94
-
95
- /** Extended response view: the identity fields the pairing reads. */
96
- export interface ReportPairResponseInput {
97
- id: string;
98
- assessment_id: string;
99
- first_name?: string | null;
100
- last_name?: string | null;
101
- linkedin_url?: string | null;
102
- website?: string | null;
103
- total_score?: number | null;
104
- category_scores?: Record<string, number> | null;
105
- completed_at?: Date | string | null;
106
- }
107
-
108
- /**
109
- * Validate authored findings against the db-validation contract. Returns {}
110
- * for absent findings; THROWS for malformed findings — authored content that
111
- * does not match the schema is a writer defect to surface, not an empty
112
- * section to silently render.
113
- */
114
- export function readResearchFindings(raw: unknown): ResearchFindings {
115
- if (raw == null) return {};
116
- const parsed = v.parse(
117
- assessmentResearchFindingsSchema,
118
- raw,
119
- ) as unknown as ResearchFindings;
120
- return parsed;
121
- }
122
-
123
- /** A research row may shape a report only while it is the live pass. */
124
- export function assertLiveResearch(research: ReportResearchInput | null | undefined): void {
125
- if (research && research.superseded_at != null) {
126
- throw new Error(
127
- `[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`,
128
- );
129
- }
130
- }
131
-
132
- /** The person's display name: quiz response names first, research second. */
133
- export function resolvePersonName(
134
- response: ReportPairResponseShape | null | undefined,
135
- research: ReportResearchInput | null | undefined,
136
- ): string {
137
- const fromResponse = [response?.first_name, response?.last_name]
138
- .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
139
- .join(" ")
140
- .trim();
141
- if (fromResponse.length > 0) return fromResponse;
142
- const fromResearch = research?.display_name?.trim();
143
- return fromResearch ?? "";
144
- }
145
-
146
- /**
147
- * The prepared-for line, exactly: `<brand> and <person>`. Brand first, as the
148
- * report is prepared FOR the brand about the person.
149
- */
150
- export function buildPreparedFor(brandName: string, personName: string): string {
151
- const brand = brandName.trim();
152
- const person = personName.trim();
153
- if (brand.length === 0) return person;
154
- if (person.length === 0) return brand;
155
- return `${brand} and ${person}`;
156
- }
157
-
158
- function numberOrZero(value: number | null | undefined): number {
159
- const n = Number(value);
160
- return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0;
161
- }
162
-
163
- /** Signed, plain gap text: "+14" / "-3" / "0". */
164
- export function gapText(self: number, found: number): string {
165
- const delta = found - self;
166
- return delta > 0 ? `+${delta}` : String(delta);
167
- }
168
-
169
- export interface GapRow {
170
- label: string;
171
- subtitle: string;
172
- self: string;
173
- found: string;
174
- gap: string;
175
- }
176
-
177
- const BAND_STEP_WORDS = ["zero", "one", "two", "three", "four", "five"];
178
-
179
- /**
180
- * The combined standing statement, assembled from the numbers and band
181
- * positions the caller derives (it owns band selection): "Measured against
182
- * the evidence, you sit 17 points higher than you placed yourself — one band
183
- * higher, in Recognised."
184
- */
185
- export function buildCombinedStatement(opts: {
186
- selfScore: number;
187
- researchScore: number;
188
- /** band count between self and research: >0 when the band moved */
189
- bandSteps: number;
190
- researchBandName: string;
191
- }): string {
192
- const { selfScore, researchScore, bandSteps, researchBandName } = opts;
193
- const delta = researchScore - selfScore;
194
- if (delta === 0) {
195
- return "Measured against the evidence, the evidence agrees with where you placed yourself.";
196
- }
197
- const direction = delta > 0 ? "higher" : "lower";
198
- let bandClause = "";
199
- if (bandSteps !== 0) {
200
- const word = BAND_STEP_WORDS[Math.abs(bandSteps)] ?? String(Math.abs(bandSteps));
201
- bandClause = `, ${word} band${Math.abs(bandSteps) === 1 ? "" : "s"} ${direction}${researchBandName ? `, in ${researchBandName}` : ""}`;
202
- }
203
- return `Measured against the evidence, you sit ${Math.abs(delta)} points ${direction} than you placed yourself${bandClause}.`;
204
- }
205
-
206
- /** Resolved research half of a report — bound as `reportData.research` fields. */
207
- export interface ResearchReportData {
208
- present: boolean;
209
- /** true when a quiz response also exists — section one renders only then */
210
- hasSelfResponse: boolean;
211
- personName: string;
212
- preparedFor: string;
213
- identifiedBy: string;
214
- validationStatement: string;
215
- roleLine: string;
216
- execSummary: string;
217
- intro: string;
218
- calloutTitle: string;
219
- calloutBody: string;
220
- groups: Array<{ title: string; items: string[] }>;
221
- claims: ResearchClaim[];
222
- gapRows: GapRow[];
223
- combinedBand: string;
224
- combinedDelta: string;
225
- combinedStatement: string;
226
- weakestAreas: string[];
227
- /** weakest areas numbered for badge-list rendering (shaped by the caller) */
228
- weakestAreasList?: Array<{ index: string; text: string }>;
229
- methodRawScores: string;
230
- methodSources: string;
231
- researchedAt: string;
232
- /** the report's date: always the research pass date (when the report was produced) */
233
- reportDate: string;
234
- }
235
-
236
- function isoDay(value: Date | string | null | undefined): string {
237
- if (value == null) return "";
238
- const d = value instanceof Date ? value : new Date(value);
239
- if (Number.isNaN(d.getTime())) return "";
240
- return d.toISOString().slice(0, 10);
241
- }
242
-
243
- /**
244
- * Shape the research half. When research is absent the result is
245
- * `{ present: false, …empty }` — callers bind the quiz-only report exactly as
246
- * before, and the page hides the research sections with visibleWhenPath.
247
- *
248
- * `resolveBand` is the caller's own band selector (selectStageBand over the
249
- * assessment's parsed bands) injected rather than imported — the band
250
- * primitives stay single-homed in assessment-report-data and this unit keeps
251
- * no runtime dependency on it.
252
- */
253
- export function buildResearchReportData(opts: {
254
- research: ReportResearchInput | null | undefined;
255
- response: ReportPairResponseShape | null | undefined;
256
- assessment: ReportAssessmentInput | null | undefined;
257
- resolveBand: (score: number) => StageBand | null;
258
- /** the parsed bands, for band-distance derivation */
259
- bands: StageBand[];
260
- brandName?: string | null;
261
- }): ResearchReportData {
262
- const { research, response, assessment } = opts;
263
- const resolveBand = opts.resolveBand;
264
- const bands = opts.bands;
265
- const brandName = opts.brandName?.trim() ?? "";
266
- const personName = resolvePersonName(response, research);
267
- const preparedFor = buildPreparedFor(brandName, personName);
268
-
269
- const empty: ResearchReportData = {
270
- present: false,
271
- hasSelfResponse: response != null,
272
- personName,
273
- preparedFor,
274
- identifiedBy: "",
275
- validationStatement: "",
276
- roleLine: "",
277
- execSummary: "",
278
- intro: "",
279
- calloutTitle: "",
280
- calloutBody: "",
281
- groups: [],
282
- claims: [],
283
- gapRows: [],
284
- combinedBand: "",
285
- combinedDelta: "",
286
- combinedStatement: "",
287
- weakestAreas: [],
288
- methodRawScores: "",
289
- methodSources: "",
290
- researchedAt: "",
291
- reportDate: "",
292
- };
293
- if (!research) return empty;
294
-
295
- assertLiveResearch(research);
296
-
297
- const findings = readResearchFindings(research.findings);
298
-
299
- const selfScores: Record<string, number> = response?.category_scores ?? {};
300
- const foundScores: Record<string, number> = research.category_scores ?? {};
301
- const categories = Array.isArray(assessment?.categories)
302
- ? (assessment.categories as unknown[]).filter(
303
- (c): c is string => typeof c === "string" && c.length > 0,
304
- )
305
- : Object.keys(foundScores);
306
-
307
- const gapRows: GapRow[] = [];
308
- if (response) {
309
- for (const label of categories) {
310
- const self = selfScores[label];
311
- const found = numberOrZero(foundScores[label]);
312
- const display = (assessment?.category_descriptions &&
313
- typeof assessment.category_descriptions === "object"
314
- ? (assessment.category_descriptions as Record<string, { subtitle?: string }>)
315
- : {})[label];
316
- gapRows.push({
317
- label,
318
- subtitle: display?.subtitle ?? "",
319
- self: typeof self === "number" ? String(Math.round(self)) : "—",
320
- found: String(found),
321
- gap: typeof self === "number" ? gapText(Math.round(self), found) : "—",
322
- });
323
- }
324
- const selfTotal = numberOrZero(response?.total_score);
325
- const foundTotal = numberOrZero(research.total_score);
326
- gapRows.push({
327
- label: "Overall",
328
- subtitle: "",
329
- self: String(selfTotal),
330
- found: String(foundTotal),
331
- gap: gapText(selfTotal, foundTotal),
332
- });
333
- }
334
-
335
- const selfScore = numberOrZero(response?.total_score);
336
- const researchScore = numberOrZero(research.total_score);
337
- const researchBand = resolveBand(researchScore);
338
- let bandSteps = 0;
339
- if (response && researchBand) {
340
- const selfBand = resolveBand(selfScore);
341
- bandSteps = selfBand && researchBand ? Math.abs(bands.indexOf(researchBand) - bands.indexOf(selfBand)) : 0;
342
- }
343
-
344
- return {
345
- present: true,
346
- hasSelfResponse: response != null,
347
- personName,
348
- preparedFor,
349
- identifiedBy: research.linkedin_url,
350
- validationStatement: research.validation_note,
351
- roleLine: research.role_line ?? "",
352
- execSummary: findings.execSummary ?? "",
353
- intro: findings.intro ?? "",
354
- calloutTitle: findings.calloutTitle ?? "",
355
- calloutBody: findings.calloutBody ?? "",
356
- groups: (findings.groups ?? []).slice(0, 4).map((group) => ({
357
- title: group.title,
358
- items: group.items,
359
- })),
360
- claims: (findings.claims ?? []).slice(0, 3),
361
- gapRows,
362
- combinedBand: researchBand?.name ?? "",
363
- combinedDelta: gapText(selfScore, researchScore),
364
- combinedStatement: response
365
- ? buildCombinedStatement({
366
- selfScore,
367
- researchScore,
368
- bandSteps,
369
- researchBandName: researchBand?.name ?? "",
370
- })
371
- : "",
372
- weakestAreas: findings.weakestAreas ?? [],
373
- methodRawScores: findings.rawScoresNote ?? "",
374
- methodSources: findings.sourceSummary ?? "",
375
- researchedAt: isoDay(research.researched_at),
376
- reportDate: isoDay(research.researched_at),
377
- };
378
- }
379
-
380
- /**
381
- * Section five merge: person-specific research content wins field-by-field;
382
- * an omitted field falls through to the band's copy; the band copy stands
383
- * alone when no research exists.
384
- */
385
- export function mergeNextSteps(
386
- band: StageBand | null,
387
- findings: ResearchFindings,
388
- ): Pick<
389
- ReportData,
390
- | "strongestAreas"
391
- | "priorityActions"
392
- | "strategicFocus"
393
- | "strengths"
394
- | "opportunities"
395
- | "twelveMonthTarget"
396
- | "roadmap"
397
- > {
398
- return {
399
- strongestAreas: findings.strongestAreas ?? band?.strongestAreas ?? [],
400
- priorityActions: findings.priorityActions ?? band?.priorityActions ?? [],
401
- strategicFocus: findings.strategicFocus ?? band?.strategicFocus ?? "",
402
- strengths: findings.strengths ?? band?.strengths ?? [],
403
- opportunities: findings.opportunities ?? band?.opportunities ?? [],
404
- twelveMonthTarget: findings.twelveMonthTarget ?? band?.twelveMonthTarget ?? "",
405
- roadmap:
406
- findings.sprintPhases ??
407
- (band?.roadmap
408
- ? Object.keys(band.roadmap)
409
- .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
410
- .map((key) => band.roadmap?.[key])
411
- .filter((phase): phase is NonNullable<typeof phase> => !!phase)
412
- : []),
413
- };
414
- }
415
-
416
- /**
417
- * Pairing lookup used by the report loader's pair resolver: the identity key
418
- * BOTH sides share, computed with the one shared normalisation.
419
- */
420
- export function pairingKeyForResponse(
421
- response: Pick<ReportPairResponseInput, "linkedin_url"> | null | undefined,
422
- ): string | null {
423
- return normaliseLinkedinIdentity(response?.linkedin_url);
424
- }