@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,437 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Locks the single-band-source contract of the Authority Index completion
7
- * email (quiz-email-band-single-sourced): the band the email names, the tag
8
- * it records and the report narrative it renders all derive from
9
- * assessments.stage_bands through the same helpers the report page uses, so
10
- * the email can no longer name a different band than the report for one
11
- * score (the retired `<id>-bands` config row disagreed at 45 and 85), and an
12
- * authored `{{score}}` placeholder never reaches the recipient unsubstituted.
13
- */
14
-
15
- import { beforeEach, describe, expect, mock, test } from "bun:test";
16
- import { applyTemplateVars } from "@teamscala/email-template/lookup";
17
-
18
- interface CapturedEmail {
19
- to: string;
20
- subject: string;
21
- html: string;
22
- text: string;
23
- }
24
-
25
- let capturedResponseRow: Record<string, unknown> | null = null;
26
- let capturedEmail: CapturedEmail | null = null;
27
- let emailResolve: ((email: CapturedEmail) => void) | null = null;
28
- let capturedEvent: { kind: string; payload: Record<string, unknown> } | null = null;
29
- /** Mutable so one test can stub a brand row with a relative logo path. */
30
- let brandRowStub: {
31
- companyName: string | null;
32
- companyFriendlyName?: string | null;
33
- primaryColor: string;
34
- accentColor: string | null;
35
- logo: { light: string | null; dark: string | null; brand: string | null };
36
- } | null = null;
37
-
38
- const noopLogger = {
39
- info: () => {},
40
- warn: () => {},
41
- error: () => {},
42
- debug: () => {},
43
- };
44
-
45
- mock.module("@teamscala/logger/app-loggers", () => ({
46
- getAppLogger: () => noopLogger,
47
- }));
48
-
49
- mock.module("@teamscala/api-resend", () => ({
50
- createResendAdapter: () => ({ bridged: true }),
51
- emailsSend: async (_client: unknown, _params: unknown, args: CapturedEmail) => {
52
- capturedEmail = args;
53
- emailResolve?.(args);
54
- return { id: "re_test_123" };
55
- },
56
- }));
57
-
58
- // The row owns copy + layout; the test mirrors the authority-index-report
59
- // row's merge tokens so the substitution surface exercised is the real one.
60
- // applyTemplateVars stays the REAL implementation — only lookup is stubbed.
61
- mock.module("@teamscala/email-template/lookup", () => ({
62
- lookupTemplate: async () => ({
63
- // Mirrors the live authority-index-report row, which leads with {{orgName}}
64
- // — a stub without it cannot catch a wrong org name in the subject.
65
- subject: "{{orgName}}: your Authority Index — {{totalScore}}/100 ({{bandLabel}})",
66
- bodyHtml:
67
- "{{brandLogoHtml}}<h1>{{totalScore}}/100 — {{bandLabel}}</h1><h2>{{reportTitle}}</h2><p>{{tagline}}</p>{{narrativeHtml}}{{pillarNotesHtml}}{{priorityActionsHtml}}{{roadmapHtml}}{{strategicFocusHtml}}{{opportunitiesHtml}}{{twelveMonthTargetHtml}}",
68
- bodyText:
69
- "{{totalScore}}/100 — {{bandLabel}}\n{{reportTitle}}\n{{tagline}}\n{{narrativeText}}",
70
- }),
71
- applyTemplateVars,
72
- }));
73
-
74
- mock.module("@teamscala/db/registry/load-config", () => ({
75
- loadRegistryConfig: async () => ({ token: "test-token" }),
76
- }));
77
-
78
- mock.module("@teamscala/encryption/crypto/secret-config", () => ({
79
- decryptSecretConfig: (raw: unknown) => raw,
80
- }));
81
-
82
- mock.module("@teamscala/resolver-engine/data-resolvers/fetch-org-brand", () => ({
83
- fetchOrgBrand: async () => brandRowStub,
84
- }));
85
-
86
- mock.module("@teamscala/email/identity/resolve-email-sender", () => ({
87
- resolveEmailSender: async (
88
- _organisationId: string | null | undefined,
89
- opts?: { brand?: Record<string, unknown> },
90
- ) => ({
91
- from: "Test <test@example.com>",
92
- replyTo: undefined,
93
- isOrgIdentity: false,
94
- brand:
95
- opts?.brand ?? {
96
- orgName: "TestCo",
97
- primaryColor: "#123456",
98
- accentColor: "#abcdef",
99
- logoUrl: null,
100
- },
101
- }),
102
- }));
103
-
104
- mock.module("@teamscala/event-log/record-event", () => ({
105
- recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
106
- capturedEvent = e;
107
- },
108
- }));
109
-
110
- const { publicAssessmentRouter } = await import("./public-assessment-router.ts");
111
-
112
- // Mirrors the authored assessments.stage_bands shape (ascending max, the last
113
- // band the catch-all) with the real threshold edges 20/40/60/80 — the band
114
- // set the report page renders.
115
- const STAGE_BANDS = {
116
- bands: [
117
- {
118
- name: "Unknown",
119
- max: 20,
120
- tagline: "tagline-unknown",
121
- executiveSummary: "You scored {{score}} — Unknown stage.",
122
- pillarNotes: { seen: "seen-unknown" },
123
- priorityActions: [{ title: "Act U1", description: "desc-u1" }],
124
- roadmap: { month1: { focus: "Focus U", actions: ["u-a"] } },
125
- strategicFocus: "focus-unknown",
126
- opportunities: ["opp-u"],
127
- twelveMonthTarget: "target-unknown",
128
- },
129
- {
130
- name: "Testing",
131
- max: 40,
132
- tagline: "tagline-testing",
133
- executiveSummary: "You scored {{score}} — Testing stage.",
134
- pillarNotes: { seen: "seen-testing" },
135
- priorityActions: [{ title: "Act T1", description: "desc-t1" }],
136
- roadmap: { month1: { focus: "Focus T", actions: ["t-a"] } },
137
- strategicFocus: "focus-testing",
138
- opportunities: ["opp-t"],
139
- twelveMonthTarget: "target-testing",
140
- },
141
- {
142
- name: "Experimenting",
143
- max: 60,
144
- tagline: "tagline-experimenting",
145
- executiveSummary: "You scored {{score}} — Experimenting stage.",
146
- pillarNotes: { seen: "seen-experimenting", heard: "heard-experimenting" },
147
- priorityActions: [
148
- { title: "Act E1", description: "desc-e1" },
149
- { title: "Act E2", description: "desc-e2" },
150
- ],
151
- roadmap: {
152
- month1: { focus: "Focus E1", actions: ["e1-a"] },
153
- month2: { focus: "Focus E2", actions: ["e2-a"] },
154
- },
155
- strategicFocus: "focus-experimenting",
156
- opportunities: ["opp-e"],
157
- twelveMonthTarget: "target-experimenting",
158
- },
159
- {
160
- name: "Recognised",
161
- max: 80,
162
- tagline: "tagline-recognised",
163
- executiveSummary: "You scored {{score}} — Recognised stage.",
164
- pillarNotes: {},
165
- priorityActions: [],
166
- strategicFocus: "focus-recognised",
167
- twelveMonthTarget: "target-recognised",
168
- },
169
- {
170
- name: "Industry Influencer",
171
- tagline: "tagline-influencer",
172
- executiveSummary: "You scored {{score}} — Influencer stage.",
173
- pillarNotes: {},
174
- priorityActions: [],
175
- strategicFocus: "focus-influencer",
176
- twelveMonthTarget: "target-influencer",
177
- },
178
- ],
179
- };
180
-
181
- function makeFakePrisma() {
182
- return {
183
- assessments: {
184
- findUnique: async () => ({
185
- id: "asmt-1",
186
- organisation_id: "org-1",
187
- questions: Array.from({ length: 20 }, (_, i) => ({
188
- id: `q${i + 1}`,
189
- type: "scale",
190
- category: `Pillar ${(i % 4) + 1}`,
191
- })),
192
- cta_url: "https://book.example/session",
193
- slug: "authority-index",
194
- stage_bands: STAGE_BANDS,
195
- report_title: "Personalised Authority Blueprint",
196
- }),
197
- },
198
- // client_database deliberately absent — the quiz prisma client is
199
- // scoped without it, so the CRM branch must skip without error.
200
- assessment_responses: {
201
- create: async (args: { data: Record<string, unknown> }) => {
202
- capturedResponseRow = args?.data ?? null;
203
- return { id: "resp-1" };
204
- },
205
- },
206
- email_send_log: {
207
- create: async () => ({}),
208
- },
209
- };
210
- }
211
-
212
- /** 20 scale questions (max 80); 9 answered 5 (earned 36) + 11 answered 1 (earned 0) → 45%. */
213
- function answersForScore45() {
214
- return Array.from({ length: 20 }, (_, i) => ({
215
- questionId: `q${i + 1}`,
216
- scaleValue: i < 9 ? 5 : 1,
217
- }));
218
- }
219
-
220
- type SubmitHandler = (opts: {
221
- input: Record<string, unknown>;
222
- context: { prisma: unknown; headers: Headers };
223
- }) => Promise<Record<string, unknown>>;
224
-
225
- async function submitWith45(
226
- headers = new Headers(),
227
- extraInput: Record<string, unknown> = {},
228
- ) {
229
- // The raw handler under ~orpc only reads { input, context }; the other
230
- // ProcedureHandlerOptions fields are middleware plumbing this procedure
231
- // (no middlewares) never touches, so the narrowed call shape is cast.
232
- const handler = publicAssessmentRouter.submit["~orpc"].handler as unknown as SubmitHandler;
233
- return handler({
234
- input: {
235
- assessmentId: "asmt-1",
236
- firstName: "Alex Reader",
237
- email: "alex@example.com",
238
- answers: answersForScore45(),
239
- ...extraInput,
240
- },
241
- context: { prisma: makeFakePrisma(), headers },
242
- });
243
- }
244
-
245
- beforeEach(() => {
246
- capturedEmail = null;
247
- capturedEvent = null;
248
- capturedResponseRow = null;
249
- brandRowStub = null;
250
- });
251
-
252
- describe("publicAssessment.submit — email band single-sourced from stage_bands", () => {
253
- test("score 45 names the stage_bands band (Experimenting), not the retired config band (Testing)", async () => {
254
- const result = await submitWith45();
255
- expect(result.totalScore).toBe(45);
256
- expect(result.band).toBe("Experimenting");
257
- });
258
-
259
- test("the email arrives naming the same band, with the narrative substituted and no literal {{score}}", async () => {
260
- const emailPromise = new Promise<CapturedEmail>((resolve) => {
261
- emailResolve = resolve;
262
- });
263
- await submitWith45();
264
- const email = await emailPromise;
265
-
266
- expect(email.subject).toContain("45/100");
267
- expect(email.subject).toContain("Experimenting");
268
- expect(email.html).toContain("45/100 — Experimenting");
269
- expect(email.html).toContain("You scored 45 — Experimenting stage.");
270
- expect(email.html).not.toContain("{{score}}");
271
- expect(email.text).toContain("You scored 45 — Experimenting stage.");
272
- expect(email.text).not.toContain("{{score}}");
273
- });
274
-
275
- test("report sections render from the same band the header names", async () => {
276
- const emailPromise = new Promise<CapturedEmail>((resolve) => {
277
- emailResolve = resolve;
278
- });
279
- await submitWith45();
280
- const email = await emailPromise;
281
-
282
- expect(email.html).toContain("Personalised Authority Blueprint");
283
- expect(email.html).toContain("tagline-experimenting");
284
- expect(email.html).toContain("Act E1");
285
- expect(email.html).toContain("desc-e1");
286
- expect(email.html).toContain("Focus E1");
287
- expect(email.html).toContain("focus-experimenting");
288
- expect(email.html).toContain("opp-e");
289
- expect(email.html).toContain("target-experimenting");
290
- // other bands' copy must not leak into this band's email
291
- expect(email.html).not.toContain("Act T1");
292
- expect(email.html).not.toContain("Act U1");
293
- expect(email.html).not.toContain("Recognised stage");
294
- });
295
-
296
- test("the event tag slug derives from the authored band name", async () => {
297
- await submitWith45();
298
- expect(capturedEvent?.kind).toBe("assessment.authority.completed");
299
- expect(capturedEvent?.payload.band).toBe("experimenting");
300
- });
301
-
302
- test("the subject and logo alt use the FRIENDLY name, not the legal entity", async () => {
303
- // company_name is the legal entity (GMG APAC); company_friendly_name is
304
- // what customers know the org as (launcha). Recipients were getting
305
- // "GMG APAC: your Authority Index" and alt="GMG APAC".
306
- brandRowStub = {
307
- companyName: "GMG APAC",
308
- companyFriendlyName: "launcha",
309
- primaryColor: "#FF4500",
310
- accentColor: null,
311
- logo: { light: "/api/assets/5b742740/logos/launcha.png", dark: null, brand: null },
312
- };
313
- const emailPromise = new Promise<CapturedEmail>((resolve) => {
314
- emailResolve = resolve;
315
- });
316
- await submitWith45(new Headers({ "x-forwarded-host": "quiz.scala.business" }));
317
- const email = await emailPromise;
318
-
319
- expect(email.subject).toContain("launcha:");
320
- expect(email.subject).not.toContain("GMG APAC");
321
- expect(email.html).toContain('alt="launcha"');
322
- });
323
-
324
- test("an org with no friendly name still gets its legal name, unchanged", async () => {
325
- // The fallback must stay intact: most orgs have never set a friendly name.
326
- brandRowStub = {
327
- companyName: "GMG APAC",
328
- companyFriendlyName: null,
329
- primaryColor: "#FF4500",
330
- accentColor: null,
331
- logo: { light: "/api/assets/5b742740/logos/launcha.png", dark: null, brand: null },
332
- };
333
- const emailPromise = new Promise<CapturedEmail>((resolve) => {
334
- emailResolve = resolve;
335
- });
336
- await submitWith45(new Headers({ "x-forwarded-host": "quiz.scala.business" }));
337
- const email = await emailPromise;
338
-
339
- expect(email.subject).toContain("GMG APAC:");
340
- });
341
-
342
- test("a relative brand logo renders as an absolute img src from the serving host", async () => {
343
- brandRowStub = {
344
- companyName: "GMG APAC",
345
- primaryColor: "#FF4500",
346
- accentColor: null,
347
- logo: {
348
- light: "/api/assets/5b742740/logos/launcha.png",
349
- dark: null,
350
- brand: null,
351
- },
352
- };
353
- const emailPromise = new Promise<CapturedEmail>((resolve) => {
354
- emailResolve = resolve;
355
- });
356
- await submitWith45(
357
- new Headers({ "x-forwarded-host": "quiz.scala.business" }),
358
- );
359
- const email = await emailPromise;
360
-
361
- expect(email.html).toContain(
362
- '<img src="https://quiz.scala.business/api/assets/5b742740/logos/launcha.png"',
363
- );
364
- });
365
-
366
- test("a relative logo with no host header stays absent rather than emitting a broken src", async () => {
367
- brandRowStub = {
368
- companyName: "GMG APAC",
369
- primaryColor: "#FF4500",
370
- accentColor: null,
371
- logo: {
372
- light: "/api/assets/5b742740/logos/launcha.png",
373
- dark: null,
374
- brand: null,
375
- },
376
- };
377
- const emailPromise = new Promise<CapturedEmail>((resolve) => {
378
- emailResolve = resolve;
379
- });
380
- await submitWith45();
381
- const email = await emailPromise;
382
-
383
- expect(email.html).not.toMatch(/<img/i);
384
- });
385
- });
386
-
387
- describe("prospect segmentation fields are captured without touching the score", () => {
388
- const SEGMENTATION = {
389
- linkedinUrl: "https://www.linkedin.com/in/alex-reader",
390
- website: "https://alexreader.example",
391
- teamSize: "Me + team of 2–5",
392
- };
393
-
394
- test("all three persist to their own columns", async () => {
395
- await submitWith45(new Headers(), SEGMENTATION);
396
- expect(capturedResponseRow?.linkedin_url).toBe(SEGMENTATION.linkedinUrl);
397
- expect(capturedResponseRow?.website).toBe(SEGMENTATION.website);
398
- expect(capturedResponseRow?.team_size).toBe(SEGMENTATION.teamSize);
399
- });
400
-
401
- test("the Authority Index is IDENTICAL with and without them", async () => {
402
- // The operator's binding constraint: these are segmentation only and must
403
- // not move the score. Asserting the same answers produce the same score
404
- // either way is the only check that actually proves it — asserting the
405
- // columns were written says nothing about the scorer.
406
- const without = (await submitWith45()) as {
407
- totalScore: number;
408
- categoryScores: Record<string, number>;
409
- };
410
- const withFields = (await submitWith45(
411
- new Headers(),
412
- SEGMENTATION,
413
- )) as { totalScore: number; categoryScores: Record<string, number> };
414
-
415
- expect(withFields.totalScore).toBe(without.totalScore);
416
- expect(withFields.categoryScores).toEqual(without.categoryScores);
417
- });
418
-
419
- test("omitting them writes null, not empty string", async () => {
420
- // A skipped optional field must be absent in the data, so segmentation
421
- // queries can tell "not asked" from "answered blank".
422
- await submitWith45();
423
- expect(capturedResponseRow?.linkedin_url).toBeNull();
424
- expect(capturedResponseRow?.website).toBeNull();
425
- expect(capturedResponseRow?.team_size).toBeNull();
426
- });
427
-
428
- test("the existing answers payload is unchanged by their presence", async () => {
429
- // Guards the other direction: segmentation must not leak into `answers`,
430
- // which is the set the scorer reads.
431
- await submitWith45(new Headers(), SEGMENTATION);
432
- const answers = capturedResponseRow?.answers as Array<{ question_id: string }>;
433
- expect(answers).toHaveLength(20);
434
- expect(JSON.stringify(answers)).not.toContain("linkedin");
435
- expect(JSON.stringify(answers)).not.toContain("team of 2");
436
- });
437
- });
@@ -1,268 +0,0 @@
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 lives in assessment-scoring.ts; the branded report email in
15
- * assessment-report-email.ts — split when this file held them all at 591 lines.
16
- */
17
-
18
- import { os } from "@orpc/server";
19
- import * as v from "valibot";
20
- import { getAppLogger } from "@teamscala/logger/app-loggers";
21
- import { recordEvent } from "@teamscala/event-log/record-event";
22
- import {
23
- readStageBands,
24
- selectStageBand,
25
- } from "./assessment-report-data.ts";
26
- import { sendAuthorityReportEmail } from "./assessment-report-email.ts";
27
- import { scoreAnswers, toBandSlug, type StoredQuestion } from "./assessment-scoring.ts";
28
- import type { PrismaClient } from "@teamscala/db/client";
29
-
30
- interface Context {
31
- prisma: PrismaClient;
32
- headers: Headers;
33
- resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
34
- }
35
-
36
- const pub = os.$context<Context>();
37
-
38
- const submitSchema = v.object({
39
- assessmentId: v.string(),
40
- firstName: v.string(),
41
- email: v.string(),
42
- company: v.optional(v.string()),
43
- phone: v.optional(v.string()),
44
- // Prospect segmentation, captured at the company step. All optional and all
45
- // written to their own columns — none is an input to scoring, which reads
46
- // `answers` alone.
47
- linkedinUrl: v.optional(v.string()),
48
- website: v.optional(v.string()),
49
- industry: v.optional(v.string()),
50
- teamSize: v.optional(v.string()),
51
- revenueRange: v.optional(v.string()),
52
- answers: v.array(
53
- v.object({
54
- questionId: v.string(),
55
- scaleValue: v.optional(v.number()),
56
- textValue: v.optional(v.string()),
57
- choiceValue: v.optional(v.string()),
58
- choiceValues: v.optional(v.array(v.string())),
59
- }),
60
- ),
61
- });
62
-
63
- export const publicAssessmentRouter = {
64
- submit: pub.input(submitSchema).handler(async ({ input, context }) => {
65
- const prisma = context.prisma;
66
- const log = getAppLogger();
67
- const email = (input.email ?? "").trim().toLowerCase();
68
-
69
- try {
70
- const assessment = await prisma.assessments.findUnique({
71
- where: { id: input.assessmentId },
72
- select: {
73
- id: true,
74
- organisation_id: true,
75
- questions: true,
76
- cta_url: true,
77
- slug: true,
78
- stage_bands: true,
79
- report_title: true,
80
- },
81
- });
82
- if (!assessment) return { error: "Assessment not found" } as const;
83
-
84
- const questions = ((Array.isArray(assessment.questions)
85
- ? assessment.questions
86
- : []) as unknown) as StoredQuestion[];
87
- const { totalScore, categoryScores } = scoreAnswers(
88
- questions,
89
- input.answers,
90
- );
91
- // ONE BAND SOURCE. The report page selects from assessments.stage_bands;
92
- // the email used to select from a parallel `<id>-bands` config row whose
93
- // thresholds disagreed (45 was Testing to the email and Experimenting to
94
- // the report). Both now read the authored bands, so the band named in the
95
- // email is by construction the band the report shows.
96
- const stageBands = readStageBands(assessment);
97
- const stageBand = selectStageBand(stageBands, totalScore);
98
- const band = stageBand
99
- ? { label: stageBand.name, slug: toBandSlug(stageBand.name) }
100
- : null;
101
-
102
- // Find-or-create the contact in client_database (the single contact
103
- // store) — same shape as publicGptSession's gate. OPTIONAL: only
104
- // services whose prisma client includes client_database (e.g. the GPT)
105
- // link a CRM contact here. The quiz service's client is scoped without
106
- // it, so skip gracefully — the assessment_responses row below already
107
- // captures name/last_name/email/company, matching the historical quiz
108
- // (which never wrote CRM contacts either).
109
- const nameParts = (input.firstName ?? "").trim().split(/\s+/);
110
- const firstName = nameParts[0] || "Unknown";
111
- const lastName = nameParts.slice(1).join(" ") || "-";
112
- const orgId = assessment.organisation_id;
113
- const bandTag = band ? `authority:${band.slug}` : null;
114
- const firmographicTags = [
115
- input.industry ? `industry:${input.industry}` : null,
116
- input.teamSize ? `team-size:${input.teamSize}` : null,
117
- input.revenueRange ? `revenue:${input.revenueRange}` : null,
118
- ].filter((t): t is string => t !== null);
119
- const assignedTags = [bandTag, ...firmographicTags].filter(
120
- (t): t is string => t !== null,
121
- );
122
- const assignedPrefixes = [
123
- "authority:",
124
- "industry:",
125
- "team-size:",
126
- "revenue:",
127
- ];
128
- if (orgId && prisma.client_database) {
129
- const existing = await prisma.client_database.findFirst({
130
- where: { organisation_id: orgId, primary_email: email },
131
- select: { id: true, tags: true },
132
- });
133
- const websiteData = input.website
134
- ? { company_website: input.website }
135
- : {};
136
- if (existing) {
137
- // Replace any prior assessment-assigned tags with the new set.
138
- const nextTags = [
139
- ...(existing.tags ?? []).filter(
140
- (t: string) => !assignedPrefixes.some((p) => t.startsWith(p)),
141
- ),
142
- ...assignedTags,
143
- ];
144
- await prisma.client_database.update({
145
- where: { id: existing.id },
146
- data: {
147
- first_name: firstName,
148
- last_name: lastName,
149
- ...(input.phone ? { mobile_phone: input.phone } : {}),
150
- ...(input.company ? { company_name: input.company } : {}),
151
- ...websiteData,
152
- tags: nextTags,
153
- last_updated_by: "assessment-gate",
154
- },
155
- });
156
- } else {
157
- await prisma.client_database.create({
158
- data: {
159
- organisation_id: orgId,
160
- first_name: firstName,
161
- last_name: lastName,
162
- primary_email: email,
163
- mobile_phone: input.phone ?? null,
164
- company_name: input.company ?? null,
165
- ...websiteData,
166
- lead_source: "assessment-gate",
167
- data_source: "public-form",
168
- referral_source: input.assessmentId,
169
- lifecycle_stage: "lead",
170
- tags: assignedTags,
171
- created_by: "assessment-gate",
172
- last_updated_by: "assessment-gate",
173
- },
174
- });
175
- }
176
- }
177
-
178
- const response = await prisma.assessment_responses.create({
179
- data: {
180
- assessment_id: input.assessmentId,
181
- email,
182
- first_name: firstName,
183
- last_name: lastName,
184
- company: input.company ?? null,
185
- linkedin_url: input.linkedinUrl ?? null,
186
- website: input.website ?? null,
187
- team_size: input.teamSize ?? null,
188
- total_score: totalScore,
189
- category_scores: categoryScores,
190
- answers: input.answers.map((a) => ({
191
- question_id: a.questionId,
192
- scale_value: a.scaleValue ?? null,
193
- text_value: a.textValue ?? null,
194
- choice_value: a.choiceValue ?? null,
195
- choice_values: a.choiceValues ?? null,
196
- })),
197
- organisation_id: orgId,
198
- completed_at: new Date(),
199
- },
200
- select: { id: true },
201
- });
202
-
203
- // Detached post-submit automation: branded report email + sales notify.
204
- // Failures are logged and never undo the submit.
205
- const ctaUrl =
206
- (typeof assessment.cta_url === "string" && assessment.cta_url) ||
207
- undefined;
208
- // The serving origin for brand asset paths. Behind Railway/Cloudflare the
209
- // proxy host is authoritative, hence x-forwarded-host first. A
210
- // non-DNS-safe value is dropped rather than passed into an email.
211
- const hostHeader =
212
- context.headers.get("x-forwarded-host") ?? context.headers.get("host");
213
- const publicBaseUrl =
214
- hostHeader && /^[A-Za-z0-9.-]+$/.test(hostHeader)
215
- ? `https://${hostHeader}`
216
- : null;
217
- if (band) {
218
- void sendAuthorityReportEmail({
219
- to: email,
220
- firstName,
221
- totalScore,
222
- bandLabel: band.label,
223
- stageBand,
224
- reportTitle:
225
- (typeof assessment.report_title === "string" && assessment.report_title) ||
226
- "Your report",
227
- categoryScores,
228
- ctaUrl,
229
- organisationId: orgId,
230
- prisma,
231
- publicBaseUrl,
232
- }).catch((e) =>
233
- log.error(
234
- `[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
235
- ),
236
- );
237
- }
238
- if (orgId) {
239
- recordEvent({
240
- kind: "assessment.authority.completed",
241
- payload: {
242
- assessment_id: input.assessmentId,
243
- response_id: response.id,
244
- email,
245
- organisation_id: orgId,
246
- total_score: totalScore,
247
- band: band?.slug ?? null,
248
- category_scores: categoryScores,
249
- },
250
- });
251
- }
252
-
253
- return {
254
- success: true as const,
255
- responseId: response.id,
256
- reportUrl: `/report/${response.id}`,
257
- totalScore,
258
- categoryScores,
259
- ...(band ? { band: band.label } : {}),
260
- };
261
- } catch (err) {
262
- log.error(
263
- `[publicAssessment.submit] failed: ${err instanceof Error ? err.message : String(err)}`,
264
- );
265
- throw err;
266
- }
267
- }),
268
- };