@teamscala/service-boot 0.23.481 → 0.23.492

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,256 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Best-effort branded Authority Index Report email for a public assessment
7
- * submit: org brand tokens, the email_template row's copy/layout, per-stage
8
- * report sections, Resend send with platform-sender fallback, and the
9
- * email_send_log audit row.
10
- *
11
- * Split out of fn-routers/public-assessment-router.ts (591 lines) —
12
- * byte-for-byte move; import paths adjusted only.
13
- */
14
-
15
- import * as v from "valibot";
16
- import { getAppLogger } from "@teamscala/logger/app-loggers";
17
- import { sendResendEmail } from "./resend-client.ts";
18
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
19
- import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
20
- import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
21
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
22
- import type { PrismaClient } from "@teamscala/db/client";
23
- import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
24
- import type { StageBand } from "./assessment-report-data.ts";
25
- import {
26
- generateNarrative,
27
- orderRoadmap,
28
- } from "./assessment-report-data.ts";
29
-
30
- export async function sendAuthorityReportEmail(opts: {
31
- to: string;
32
- firstName: string;
33
- totalScore: number;
34
- bandLabel: string;
35
- stageBand: StageBand | null;
36
- reportTitle: string;
37
- categoryScores: Record<string, number>;
38
- ctaUrl?: string;
39
- organisationId?: string | null;
40
- prisma: PrismaClient;
41
- /**
42
- * Absolute origin (e.g. "https://quiz.scala.business") of the service that
43
- * received the submit — the origin that serves /api/assets. Mail clients
44
- * cannot resolve a relative src, so an org logo stored as a relative path
45
- * only renders when this is provided. Null → a relative logo stays absent
46
- * (previous behaviour) rather than emitting a broken src.
47
- */
48
- publicBaseUrl?: string | null;
49
- }): Promise<void> {
50
- const log = getAppLogger();
51
- const rawSecret = await loadRegistryConfig(
52
- "secret",
53
- "resend-api-key",
54
- v.looseObject({ token: v.optional(v.string()) }),
55
- ).catch(() => null);
56
- const decrypted = rawSecret
57
- ? (decryptSecretConfig(rawSecret as never) as { token?: string })
58
- : null;
59
- const resendToken = decrypted?.token;
60
- if (!resendToken) {
61
- log.warn("[authority-report-email] Resend token unavailable — skipping");
62
- return;
63
- }
64
-
65
- const brandRow =
66
- opts.organisationId && opts.prisma
67
- ? await fetchOrgBrand(
68
- opts.prisma as unknown as Parameters<typeof fetchOrgBrand>[0],
69
- opts.organisationId,
70
- ).catch(() => null)
71
- : null;
72
- // Brand assets are stored as service-relative paths ("/api/assets/…") —
73
- // the same origin that served the submit request serves them, so the email
74
- // derives the absolute URL from that origin at resolve time instead of the
75
- // profile row being mutated per consumer. Already-absolute logos pass through.
76
- const absolutizeAssetUrl = (url: string | null): string | null => {
77
- if (!url) return null;
78
- if (/^https?:\/\//.test(url)) return url;
79
- if (url.startsWith("/") && opts.publicBaseUrl) return `${opts.publicBaseUrl}${url}`;
80
- return null;
81
- };
82
- const sender = await resolveEmailSender(opts.organisationId ?? null, {
83
- brand: brandRow
84
- ? {
85
- // The FRIENDLY name is the customer-facing one; company_name is the
86
- // legal entity. Recipients were getting "GMG APAC: your Authority
87
- // Index" and alt="GMG APAC" on the logo, for an org whose brand is
88
- // Launcha. Same precedence the platform already uses in
89
- // agreement-gate (friendly ?? legal), so an org that never set a
90
- // friendly name is unchanged.
91
- orgName: brandRow.companyFriendlyName || brandRow.companyName || "",
92
- primaryColor: brandRow.primaryColor,
93
- accentColor: brandRow.accentColor ?? null,
94
- logoUrl: absolutizeAssetUrl(brandRow.logo.light),
95
- }
96
- : undefined,
97
- });
98
- const primary = sender.brand.primaryColor;
99
- const muted = "#6b7280"; // structural neutral for body labels
100
- const logoUrl = sender.brand.logoUrl;
101
- const logoHtml =
102
- logoUrl && /^https?:\/\//.test(logoUrl)
103
- ? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
104
- : "";
105
-
106
- // Subject + body from the `authority-index-report` email_template row, not
107
- // literals here (email-template-registry-type). Dynamic markup is rendered
108
- // here and passed as merge vars — the row owns copy + layout; colours +
109
- // logo come from the org brand tokens, NOT inline literals.
110
- const pillarRows = (
111
- Object.entries(opts.categoryScores) as Array<[string, number]>
112
- )
113
- .map(
114
- ([name, score]) =>
115
- `<tr><td style="padding:6px 0;color:${muted}">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:${primary}">${score} / 100</td></tr>`,
116
- )
117
- .join("");
118
- const pillarsText = (
119
- Object.entries(opts.categoryScores) as Array<[string, number]>
120
- )
121
- .map(([name, score]) => `${name}: ${score} / 100`)
122
- .join("\n");
123
- // REPORT SECTIONS. Authored per stage on assessments.stage_bands and rendered
124
- // here as markup, so the template row stays copy + layout. Every section is
125
- // optional: a stage that has not authored one yields an empty string and the
126
- // row simply renders nothing for it.
127
- const b = opts.stageBand;
128
- const sectionHtml = (heading: string, inner: string) =>
129
- inner
130
- ? `<h3 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:${primary}">${heading}</h3>${inner}`
131
- : "";
132
- const paraHtml = (text?: string) =>
133
- text ? `<p style="margin:0 0 12px;color:${muted}">${text}</p>` : "";
134
- const bulletsHtml = (items?: string[]) =>
135
- Array.isArray(items) && items.length > 0
136
- ? `<ul style="margin:0 0 12px;padding-left:20px;color:${muted}">${items
137
- .map((i) => `<li style="margin:0 0 4px">${i}</li>`)
138
- .join("")}</ul>`
139
- : "";
140
- const actionsHtml =
141
- Array.isArray(b?.priorityActions) && b.priorityActions.length > 0
142
- ? b.priorityActions
143
- .map(
144
- (a) =>
145
- `<p style="margin:0 0 10px"><strong style="font-size:14px;color:${primary}">${a.title}</strong><br><span style="color:${muted}">${a.description ?? ""}</span></p>`,
146
- )
147
- .join("")
148
- : "";
149
- const roadmapHtml = orderRoadmap(b?.roadmap)
150
- .map(
151
- (phase) =>
152
- `<p style="margin:0 0 10px"><strong style="font-size:14px;color:${primary}">${phase.focus ?? ""}</strong><br><span style="color:${muted}">${(phase.actions ?? []).join(" · ")}</span></p>`,
153
- )
154
- .join("");
155
- const notes = b?.pillarNotes ?? {};
156
- const pillarNotesHtml = Object.keys(notes).length
157
- ? Object.entries(notes)
158
- .map(
159
- ([k, v]) =>
160
- `<p style="margin:0 0 8px;color:${muted}"><strong style="font-size:14px;color:${primary};text-transform:capitalize">${k}</strong> — ${v}</p>`,
161
- )
162
- .join("")
163
- : "";
164
- const narrative = generateNarrative(opts.totalScore, b ?? null);
165
-
166
- const tpl = applyTemplateVars(await lookupTemplate("authority-index-report"), {
167
- firstName: opts.firstName || "there",
168
- totalScore: String(opts.totalScore),
169
- bandLabel: opts.bandLabel,
170
- pillarRows,
171
- pillarsText,
172
- reportTitle: opts.reportTitle,
173
- tagline: b?.tagline ?? "",
174
- narrativeHtml: paraHtml(narrative),
175
- pillarNotesHtml: sectionHtml("Where you stand", pillarNotesHtml),
176
- priorityActionsHtml: sectionHtml("Your priority actions", actionsHtml),
177
- roadmapHtml: sectionHtml("Your next 90 days", roadmapHtml),
178
- strategicFocusHtml: sectionHtml("Strategic focus", paraHtml(b?.strategicFocus)),
179
- opportunitiesHtml: sectionHtml("Opportunities", bulletsHtml(b?.opportunities)),
180
- twelveMonthTargetHtml: sectionHtml("Twelve-month target", paraHtml(b?.twelveMonthTarget)),
181
- narrativeText: narrative,
182
- brandPrimaryColor: primary,
183
- brandAccentColor: sender.brand.accentColor ?? primary,
184
- brandLogoHtml: logoHtml,
185
- orgName: sender.brand.orgName,
186
- ctaHtml: opts.ctaUrl
187
- ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:${primary};color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
188
- : "",
189
- ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
190
- });
191
-
192
- const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
193
- sendResendEmail(resendToken, {
194
- from: fromHeader,
195
- to: opts.to,
196
- subject: tpl.subject,
197
- html: tpl.bodyHtml,
198
- text: tpl.bodyText,
199
- reply_to: replyToHeader,
200
- });
201
-
202
- let res = await sendOnce(sender.from, sender.replyTo);
203
- let usedFrom = sender.from;
204
- if (!res.success) {
205
- // Resend refused the org sender (likely unverified domain). Fall back
206
- // to the platform sender; never fail because a verification lookup
207
- // failed — the resolver returns the row, the failure is at Resend.
208
- const platformRow = await loadRegistryConfig(
209
- "config",
210
- "email-defaults",
211
- v.looseObject({ default_sender: v.optional(v.string()) }),
212
- ).catch(() => null);
213
- const fallbackFrom =
214
- (platformRow?.default_sender as string | undefined)?.trim() ||
215
- "scala <noreply@gateway.scala.business>";
216
- log.warn(
217
- `[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
218
- );
219
- res = await sendOnce(fallbackFrom, undefined);
220
- usedFrom = fallbackFrom;
221
- }
222
-
223
- if (res.success) {
224
- log.info(
225
- `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
226
- );
227
- } else {
228
- log.error(
229
- `[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
230
- );
231
- }
232
-
233
- // email-send-audited: best-effort audit row. Guarded so a stale prisma
234
- // client (scala-quiz schema predates email_send_log) skips cleanly.
235
- try {
236
- const auditPrisma = opts.prisma as {
237
- email_send_log?: { create: (args: unknown) => Promise<unknown> };
238
- };
239
- if (auditPrisma?.email_send_log) {
240
- await auditPrisma.email_send_log.create({
241
- data: {
242
- to_address: opts.to,
243
- template_slug: "authority-index-report",
244
- status: res.success ? "sent" : "failed",
245
- provider_message_id: res.id ?? null,
246
- error: res.success ? null : (res.error ?? "unknown"),
247
- from_address: usedFrom,
248
- },
249
- });
250
- }
251
- } catch (auditErr) {
252
- log.warn(
253
- `[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
254
- );
255
- }
256
- }
@@ -1,136 +0,0 @@
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
- });
108
-
109
- describe("superseded-pass leak prevention", () => {
110
- test("the live pass reaches the reader and the superseded pass's wording never does", async () => {
111
- const SUPERSEDED_MARKER = "SUPERSEDED-PASS-64-PARKED-DOMAIN-CLAIM";
112
- const superseded = annaResearch({
113
- id: "research-superseded",
114
- superseded_at: "2026-09-04T01:00:00Z",
115
- findings: { intro: `${SUPERSEDED_MARKER}: no website exists.` },
116
- });
117
- const live = annaResearch({ id: "research-live", findings: { intro: "launcha.marketing is live and names you." } });
118
- const client = {
119
- assessment_research: {
120
- findFirst: async () => live,
121
- },
122
- assessment_responses: {
123
- findFirst: async () => null,
124
- findMany: async () => [],
125
- },
126
- };
127
- const halves = await resolveReportHalves({
128
- input: { response: null, research: superseded, id: superseded.id },
129
- client,
130
- params: {},
131
- });
132
- const text = JSON.stringify(halves.research);
133
- expect(text).not.toContain(SUPERSEDED_MARKER);
134
- expect(text).toContain("launcha.marketing is live and names you");
135
- });
136
- });
@@ -1,155 +0,0 @@
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
- }