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