@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.
- package/package.json +1 -261
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/build-client-assets/styles-build.ts +11 -1
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-page-server-client.ts +1 -15
- package/src/register-ws-routes.ts +1 -1
- package/src/fn-routers/agreement-certificate.ts +0 -224
- package/src/fn-routers/agreement-completion-email.ts +0 -114
- package/src/fn-routers/agreement-send-router.ts +0 -303
- package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
- package/src/fn-routers/agreement-signing-evidence.ts +0 -105
- package/src/fn-routers/assessment-report-data.test.ts +0 -404
- package/src/fn-routers/assessment-report-data.ts +0 -485
- package/src/fn-routers/assessment-report-email.ts +0 -256
- package/src/fn-routers/assessment-report-pair.test.ts +0 -136
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
- package/src/fn-routers/assessment-research-report-data.ts +0 -424
- package/src/fn-routers/assessment-scoring.ts +0 -105
- package/src/fn-routers/auth-router.ts +0 -65
- package/src/fn-routers/chat-session-router-shared.ts +0 -122
- package/src/fn-routers/chat-session-router.ts +0 -356
- package/src/fn-routers/conversation-ownership.ts +0 -24
- package/src/fn-routers/email-defaults.ts +0 -28
- package/src/fn-routers/email-link-ttl.ts +0 -28
- package/src/fn-routers/index.ts +0 -76
- package/src/fn-routers/linkedin-identity.test.ts +0 -27
- package/src/fn-routers/linkedin-identity.ts +0 -36
- package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
- package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
- package/src/fn-routers/panel-data-router.ts +0 -104
- package/src/fn-routers/persist-chat-messages.test.ts +0 -86
- package/src/fn-routers/persist-chat-messages.ts +0 -98
- package/src/fn-routers/proposal-send-router.ts +0 -207
- package/src/fn-routers/public-agreement-router.ts +0 -407
- package/src/fn-routers/public-assessment-router.test.ts +0 -437
- package/src/fn-routers/public-assessment-router.ts +0 -268
- package/src/fn-routers/public-gpt-session-router.ts +0 -378
- package/src/fn-routers/resend-client.ts +0 -39
- package/src/fn-routers/sign-in-flow-router.ts +0 -196
- package/src/fn-routers/user-preferences-router.ts +0 -130
- package/src/fn-routers/work-items-router.ts +0 -342
- package/src/page-server-orpc/audit-revert-router.ts +0 -102
- package/src/page-server-orpc/puck-pages-router.ts +0 -170
- 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
|
-
}
|