@teamscala/service-boot 0.23.479 → 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 (46) 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-registry-page-route/region-slots-page-data.ts +2 -2
  7. package/src/register-ws-routes.ts +1 -1
  8. package/src/fn-routers/agreement-certificate.ts +0 -224
  9. package/src/fn-routers/agreement-completion-email.ts +0 -114
  10. package/src/fn-routers/agreement-send-router.ts +0 -303
  11. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  12. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  13. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  14. package/src/fn-routers/assessment-report-data.ts +0 -485
  15. package/src/fn-routers/assessment-report-email.ts +0 -256
  16. package/src/fn-routers/assessment-report-pair.test.ts +0 -107
  17. package/src/fn-routers/assessment-report-pair.ts +0 -155
  18. package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
  19. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  20. package/src/fn-routers/assessment-scoring.ts +0 -105
  21. package/src/fn-routers/auth-router.ts +0 -65
  22. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  23. package/src/fn-routers/chat-session-router.ts +0 -356
  24. package/src/fn-routers/conversation-ownership.ts +0 -24
  25. package/src/fn-routers/email-defaults.ts +0 -28
  26. package/src/fn-routers/email-link-ttl.ts +0 -28
  27. package/src/fn-routers/index.ts +0 -76
  28. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  29. package/src/fn-routers/linkedin-identity.ts +0 -36
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  31. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  32. package/src/fn-routers/panel-data-router.ts +0 -104
  33. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  34. package/src/fn-routers/persist-chat-messages.ts +0 -98
  35. package/src/fn-routers/proposal-send-router.ts +0 -207
  36. package/src/fn-routers/public-agreement-router.ts +0 -407
  37. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  38. package/src/fn-routers/public-assessment-router.ts +0 -268
  39. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  40. package/src/fn-routers/resend-client.ts +0 -39
  41. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  42. package/src/fn-routers/user-preferences-router.ts +0 -130
  43. package/src/fn-routers/work-items-router.ts +0 -342
  44. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  45. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  46. 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,107 +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
- });
@@ -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
- }