@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Public ORPC procedure for submitting a public assessment (quiz) response.
7
+ * Mirrors public-gpt-session-router: a public, system-trusted write path for an
8
+ * anonymous/guest visitor — find-or-create the contact in client_database, score
9
+ * the answers, and persist an assessment_responses row. This is the submit path
10
+ * the deleted public-assessment wrapper used to provide via an injected onSubmit;
11
+ * recovering it here brings the quiz in line with the shared machinery (the GPT's
12
+ * publicGptSession is the sibling pattern) instead of a per-page bespoke handler.
13
+ *
14
+ * Scoring (recovered from live data, 2026-06-13): each scale answer contributes
15
+ * (scaleValue - 1) earned out of 4 max (1–5 scale → 0–4); choice answers
16
+ * contribute the selected option's points out of the question's max option
17
+ * points. A category's score is round(100 * earned / max) over that category's
18
+ * questions; the overall total is the same over all questions. Unanswered
19
+ * questions count toward the denominator (contribute 0) — matches the historical
20
+ * rows (e.g. Σ(v-1)=78 over 36 questions → 78/144 = 54).
21
+ */
22
+
23
+ import { os } from "@orpc/server";
24
+ import * as v from "valibot";
25
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
26
+ import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
27
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
28
+ import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
29
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
30
+ import { recordEvent } from "@teamscala/event-log/record-event";
31
+ import { getDefaultEmailSender } from "./email-defaults.ts";
32
+
33
+ interface Context {
34
+ prisma: any;
35
+ headers: Headers;
36
+ resolveUser?: (headers: Headers, prisma: any) => Promise<any>;
37
+ }
38
+
39
+ const pub = os.$context<Context>();
40
+
41
+ const submitSchema = v.object({
42
+ assessmentId: v.string(),
43
+ firstName: v.string(),
44
+ email: v.string(),
45
+ company: v.optional(v.string()),
46
+ phone: v.optional(v.string()),
47
+ website: v.optional(v.string()),
48
+ industry: v.optional(v.string()),
49
+ teamSize: v.optional(v.string()),
50
+ revenueRange: v.optional(v.string()),
51
+ answers: v.array(
52
+ v.object({
53
+ questionId: v.string(),
54
+ scaleValue: v.optional(v.number()),
55
+ textValue: v.optional(v.string()),
56
+ choiceValue: v.optional(v.string()),
57
+ choiceValues: v.optional(v.array(v.string())),
58
+ }),
59
+ ),
60
+ });
61
+
62
+ interface StoredQuestion {
63
+ id: string;
64
+ category?: string | null;
65
+ type?: string | null;
66
+ weight?: number | null;
67
+ options?: Array<{ value: string; points: number }> | null;
68
+ }
69
+
70
+ /** Scale answers are 1–5; earned = value-1, max = 4. */
71
+ const SCALE_MAX = 4;
72
+
73
+ /**
74
+ * Bands are DATA, not code: each assessment that wants score-bands declares them
75
+ * in a `config` registry row named `<assessment-slug>-bands` (shape:
76
+ * `{bands: [{max, slug, label}, …]}`). An assessment with no such row is scored
77
+ * but not banded — so the Authority Index bands without affecting other quizzes.
78
+ * The band NARRATIVE is generated by the Strategist agent from its system prompt.
79
+ */
80
+ const bandsRowSchema = v.looseObject({
81
+ bands: v.array(
82
+ v.object({ max: v.number(), slug: v.string(), label: v.string() }),
83
+ ),
84
+ });
85
+
86
+ async function getBands(
87
+ assessmentId: string | null | undefined,
88
+ ): Promise<Array<{ max: number; slug: string; label: string }> | null> {
89
+ if (!assessmentId) return null;
90
+ const row = await loadRegistryConfig(
91
+ "config",
92
+ `${assessmentId}-bands`,
93
+ bandsRowSchema,
94
+ ).catch(() => null);
95
+ if (!row || !Array.isArray(row.bands) || row.bands.length === 0) return null;
96
+ return row.bands;
97
+ }
98
+
99
+ function bandForScore(
100
+ total: number,
101
+ bands: Array<{ max: number; slug: string; label: string }>,
102
+ ): { slug: string; label: string } {
103
+ return bands.find((b) => total <= b.max) ?? bands[bands.length - 1];
104
+ }
105
+
106
+
107
+ /**
108
+ * Best-effort branded Authority Index Report email. Self-contained: loads its
109
+ * own Resend secret, swallows every error — a send failure is logged and NEVER
110
+ * undoes the submit (the agreement-completion-email pattern). Fire-and-forget
111
+ * from the submit handler.
112
+ */
113
+ async function sendAuthorityReportEmail(opts: {
114
+ to: string;
115
+ firstName: string;
116
+ totalScore: number;
117
+ bandLabel: string;
118
+ categoryScores: Record<string, number>;
119
+ ctaUrl?: string;
120
+ }): Promise<void> {
121
+ const log = getAppLogger();
122
+ const rawSecret = await loadRegistryConfig(
123
+ "secret",
124
+ "resend-api-key",
125
+ v.looseObject({ token: v.optional(v.string()) }),
126
+ ).catch(() => null);
127
+ const decrypted = rawSecret
128
+ ? (decryptSecretConfig(rawSecret as never) as { token?: string })
129
+ : null;
130
+ if (!decrypted?.token) {
131
+ log.warn("[authority-report-email] Resend token unavailable — skipping");
132
+ return;
133
+ }
134
+ initResendClient(decrypted.token);
135
+ const from = await getDefaultEmailSender();
136
+ // Subject + body from the `authority-index-report` email_template row, not
137
+ // literals here (email-template-registry-type). The per-pillar rows and the
138
+ // optional CTA are the only DYNAMIC markup, so they are rendered here and
139
+ // passed as merge vars — the row still owns all the copy and the layout.
140
+ const pillarRows = (
141
+ Object.entries(opts.categoryScores) as Array<[string, number]>
142
+ )
143
+ .map(
144
+ ([name, score]) =>
145
+ `<tr><td style="padding:6px 0;color:#374151">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:#111827">${score} / 100</td></tr>`,
146
+ )
147
+ .join("");
148
+ const pillarsText = (
149
+ Object.entries(opts.categoryScores) as Array<[string, number]>
150
+ )
151
+ .map(([name, score]) => `${name}: ${score} / 100`)
152
+ .join("\n");
153
+ const tpl = applyTemplateVars(await lookupTemplate("authority-index-report"), {
154
+ firstName: opts.firstName || "there",
155
+ totalScore: String(opts.totalScore),
156
+ bandLabel: opts.bandLabel,
157
+ pillarRows,
158
+ pillarsText,
159
+ ctaHtml: opts.ctaUrl
160
+ ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
161
+ : "",
162
+ ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
163
+ });
164
+ const res = await sendEmail({
165
+ from,
166
+ to: opts.to,
167
+ subject: tpl.subject,
168
+ html: tpl.bodyHtml,
169
+ text: tpl.bodyText,
170
+ });
171
+ if (res.success) {
172
+ log.info(
173
+ `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
174
+ );
175
+ } else {
176
+ log.error(
177
+ `[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
178
+ );
179
+ }
180
+ }
181
+
182
+ function scoreAnswers(
183
+ questions: StoredQuestion[],
184
+ answers: Array<{
185
+ questionId: string;
186
+ scaleValue?: number;
187
+ choiceValue?: string;
188
+ choiceValues?: string[];
189
+ }>,
190
+ ): { totalScore: number; categoryScores: Record<string, number> } {
191
+ const answerByQ = new Map(answers.map((a) => [a.questionId, a]));
192
+ // Per-category and overall earned/max accumulators.
193
+ const cat = new Map<string, { earned: number; max: number }>();
194
+ let totalEarned = 0;
195
+ let totalMax = 0;
196
+
197
+ for (const q of questions) {
198
+ if (q.type === "text") continue; // text questions are qualitative — never scored
199
+ const category = q.category || "General";
200
+ const a = answerByQ.get(q.id);
201
+ let earned = 0;
202
+ let max = 0;
203
+
204
+ if (q.options && q.options.length > 0) {
205
+ // Choice question: max is the highest single option's points; earned is
206
+ // the selected option(s)' points (capped at max).
207
+ max = Math.max(0, ...q.options.map((o) => o.points || 0));
208
+ if (a?.choiceValue) {
209
+ earned = q.options.find((o) => o.value === a.choiceValue)?.points ?? 0;
210
+ } else if (a?.choiceValues?.length) {
211
+ earned = Math.min(
212
+ max,
213
+ a.choiceValues.reduce(
214
+ (s, val) =>
215
+ s + (q.options?.find((o) => o.value === val)?.points ?? 0),
216
+ 0,
217
+ ),
218
+ );
219
+ }
220
+ } else {
221
+ // Scale question (the common case): 1–5 → earned 0–4.
222
+ max = SCALE_MAX;
223
+ if (typeof a?.scaleValue === "number") {
224
+ earned = Math.max(0, Math.min(SCALE_MAX, a.scaleValue - 1));
225
+ }
226
+ }
227
+
228
+ if (max <= 0) continue; // text/unscored questions don't affect the score
229
+ const entry = cat.get(category) ?? { earned: 0, max: 0 };
230
+ entry.earned += earned;
231
+ entry.max += max;
232
+ cat.set(category, entry);
233
+ totalEarned += earned;
234
+ totalMax += max;
235
+ }
236
+
237
+ const categoryScores: Record<string, number> = {};
238
+ for (const [name, { earned, max }] of cat) {
239
+ categoryScores[name] = max > 0 ? Math.round((100 * earned) / max) : 0;
240
+ }
241
+ const totalScore = totalMax > 0 ? Math.round((100 * totalEarned) / totalMax) : 0;
242
+ return { totalScore, categoryScores };
243
+ }
244
+
245
+ export const publicAssessmentRouter = {
246
+ submit: pub.input(submitSchema).handler(async ({ input, context }) => {
247
+ const prisma = context.prisma;
248
+ const log = getAppLogger();
249
+ const email = (input.email ?? "").trim().toLowerCase();
250
+
251
+ try {
252
+ const assessment = await prisma.assessments.findUnique({
253
+ where: { id: input.assessmentId },
254
+ select: {
255
+ id: true,
256
+ organisation_id: true,
257
+ questions: true,
258
+ cta_url: true,
259
+ slug: true,
260
+ },
261
+ });
262
+ if (!assessment) return { error: "Assessment not found" } as const;
263
+
264
+ const questions = (Array.isArray(assessment.questions)
265
+ ? assessment.questions
266
+ : []) as StoredQuestion[];
267
+ const { totalScore, categoryScores } = scoreAnswers(
268
+ questions,
269
+ input.answers,
270
+ );
271
+ const bands = await getBands(assessment.id);
272
+ const band = bands ? bandForScore(totalScore, bands) : null;
273
+
274
+ // Find-or-create the contact in client_database (the single contact
275
+ // store) — same shape as publicGptSession's gate. OPTIONAL: only
276
+ // services whose prisma client includes client_database (e.g. the GPT)
277
+ // link a CRM contact here. The quiz service's client is scoped without
278
+ // it, so skip gracefully — the assessment_responses row below already
279
+ // captures name/last_name/email/company, matching the historical quiz
280
+ // (which never wrote CRM contacts either).
281
+ const nameParts = (input.firstName ?? "").trim().split(/\s+/);
282
+ const firstName = nameParts[0] || "Unknown";
283
+ const lastName = nameParts.slice(1).join(" ") || "-";
284
+ const orgId = assessment.organisation_id;
285
+ const bandTag = band ? `authority:${band.slug}` : null;
286
+ const firmographicTags = [
287
+ input.industry ? `industry:${input.industry}` : null,
288
+ input.teamSize ? `team-size:${input.teamSize}` : null,
289
+ input.revenueRange ? `revenue:${input.revenueRange}` : null,
290
+ ].filter((t): t is string => t !== null);
291
+ const assignedTags = [bandTag, ...firmographicTags].filter(
292
+ (t): t is string => t !== null,
293
+ );
294
+ const assignedPrefixes = [
295
+ "authority:",
296
+ "industry:",
297
+ "team-size:",
298
+ "revenue:",
299
+ ];
300
+ if (orgId && prisma.client_database) {
301
+ const existing = await prisma.client_database.findFirst({
302
+ where: { organisation_id: orgId, primary_email: email },
303
+ select: { id: true, tags: true },
304
+ });
305
+ const websiteData = input.website
306
+ ? { company_website: input.website }
307
+ : {};
308
+ if (existing) {
309
+ // Replace any prior assessment-assigned tags with the new set.
310
+ const nextTags = [
311
+ ...(existing.tags ?? []).filter(
312
+ (t: string) => !assignedPrefixes.some((p) => t.startsWith(p)),
313
+ ),
314
+ ...assignedTags,
315
+ ];
316
+ await prisma.client_database.update({
317
+ where: { id: existing.id },
318
+ data: {
319
+ first_name: firstName,
320
+ last_name: lastName,
321
+ ...(input.phone ? { mobile_phone: input.phone } : {}),
322
+ ...(input.company ? { company_name: input.company } : {}),
323
+ ...websiteData,
324
+ tags: nextTags,
325
+ last_updated_by: "assessment-gate",
326
+ },
327
+ });
328
+ } else {
329
+ await prisma.client_database.create({
330
+ data: {
331
+ organisation_id: orgId,
332
+ first_name: firstName,
333
+ last_name: lastName,
334
+ primary_email: email,
335
+ mobile_phone: input.phone ?? null,
336
+ company_name: input.company ?? null,
337
+ ...websiteData,
338
+ lead_source: "assessment-gate",
339
+ data_source: "public-form",
340
+ referral_source: input.assessmentId,
341
+ lifecycle_stage: "lead",
342
+ tags: assignedTags,
343
+ created_by: "assessment-gate",
344
+ last_updated_by: "assessment-gate",
345
+ },
346
+ });
347
+ }
348
+ }
349
+
350
+ const response = await prisma.assessment_responses.create({
351
+ data: {
352
+ assessment_id: input.assessmentId,
353
+ email,
354
+ first_name: firstName,
355
+ last_name: lastName,
356
+ company: input.company ?? null,
357
+ total_score: totalScore,
358
+ category_scores: categoryScores,
359
+ answers: input.answers.map((a) => ({
360
+ question_id: a.questionId,
361
+ scale_value: a.scaleValue ?? null,
362
+ text_value: a.textValue ?? null,
363
+ choice_value: a.choiceValue ?? null,
364
+ choice_values: a.choiceValues ?? null,
365
+ })),
366
+ organisation_id: orgId,
367
+ completed_at: new Date(),
368
+ },
369
+ select: { id: true },
370
+ });
371
+
372
+ // Detached post-submit automation: branded report email + sales notify.
373
+ // Failures are logged and never undo the submit.
374
+ const ctaUrl =
375
+ (typeof assessment.cta_url === "string" && assessment.cta_url) ||
376
+ undefined;
377
+ if (band) {
378
+ void sendAuthorityReportEmail({
379
+ to: email,
380
+ firstName,
381
+ totalScore,
382
+ bandLabel: band.label,
383
+ categoryScores,
384
+ ctaUrl,
385
+ }).catch((e) =>
386
+ log.error(
387
+ `[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
388
+ ),
389
+ );
390
+ }
391
+ if (orgId) {
392
+ recordEvent({
393
+ kind: "assessment.authority.completed",
394
+ payload: {
395
+ assessment_id: input.assessmentId,
396
+ response_id: response.id,
397
+ email,
398
+ organisation_id: orgId,
399
+ total_score: totalScore,
400
+ band: band?.slug ?? null,
401
+ category_scores: categoryScores,
402
+ },
403
+ });
404
+ }
405
+
406
+ return {
407
+ success: true as const,
408
+ responseId: response.id,
409
+ reportUrl: `/report/${response.id}`,
410
+ totalScore,
411
+ categoryScores,
412
+ ...(band ? { band: band.label } : {}),
413
+ };
414
+ } catch (err) {
415
+ log.error(
416
+ `[publicAssessment.submit] failed: ${err instanceof Error ? err.message : String(err)}`,
417
+ );
418
+ throw err;
419
+ }
420
+ }),
421
+ };