@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,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
|
-
}
|