@teamscala/service-boot 0.23.415 → 0.23.417

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
- * @status handwritten @indivisible-unit
3
+ * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
6
  * Public ORPC procedure for submitting a public assessment (quiz) response.
@@ -11,32 +11,20 @@
11
11
  * recovering it here brings the quiz in line with the shared machinery (the GPT's
12
12
  * publicGptSession is the sibling pattern) instead of a per-page bespoke handler.
13
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).
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.
21
16
  */
22
17
 
23
18
  import { os } from "@orpc/server";
24
19
  import * as v from "valibot";
25
20
  import { getAppLogger } from "@teamscala/logger/app-loggers";
26
- import { sendResendEmail } from "./resend-client.ts";
27
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
28
- import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
29
- import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
30
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
31
- import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
32
- import type { StageBand } from "@teamscala/assessment-block/report-data";
21
+ import { recordEvent } from "@teamscala/event-log/record-event";
33
22
  import {
34
- generateNarrative,
35
- orderRoadmap,
36
23
  readStageBands,
37
24
  selectStageBand,
38
25
  } from "@teamscala/assessment-block/report-data";
39
- import { recordEvent } from "@teamscala/event-log/record-event";
26
+ import { sendAuthorityReportEmail } from "./assessment-report-email.ts";
27
+ import { scoreAnswers, toBandSlug, type StoredQuestion } from "./assessment-scoring.ts";
40
28
 
41
29
  interface Context {
42
30
  prisma: any;
@@ -67,325 +55,6 @@ const submitSchema = v.object({
67
55
  ),
68
56
  });
69
57
 
70
- interface StoredQuestion {
71
- id: string;
72
- category?: string | null;
73
- type?: string | null;
74
- weight?: number | null;
75
- options?: Array<{ value: string; points: number }> | null;
76
- }
77
-
78
- /** Scale answers are 1–5; earned = value-1, max = 4. */
79
- const SCALE_MAX = 4;
80
-
81
- /**
82
- * A stage band names itself; the event tag needs a stable slug. Derived from
83
- * the authored name rather than stored beside it, so the two cannot drift.
84
- */
85
- function toBandSlug(name: string): string {
86
- return name
87
- .toLowerCase()
88
- .replace(/[^a-z0-9]+/g, "-")
89
- .replace(/^-+|-+$/g, "");
90
- }
91
-
92
-
93
- /**
94
- * Best-effort branded Authority Index Report email. Self-contained: loads its
95
- * own Resend secret, swallows every error. Sender identity + brand tokens
96
- * come from resolveEmailSender (per-org email_sender row). On Resend refusal
97
- * the call falls back to the platform sender and logs the reason.
98
- */
99
- async function sendAuthorityReportEmail(opts: {
100
- to: string;
101
- firstName: string;
102
- totalScore: number;
103
- bandLabel: string;
104
- stageBand: StageBand | null;
105
- reportTitle: string;
106
- categoryScores: Record<string, number>;
107
- ctaUrl?: string;
108
- organisationId?: string | null;
109
- prisma: any;
110
- /**
111
- * Absolute origin (e.g. "https://quiz.scala.business") of the service that
112
- * received the submit — the origin that serves /api/assets. Mail clients
113
- * cannot resolve a relative src, so an org logo stored as a relative path
114
- * only renders when this is provided. Null → a relative logo stays absent
115
- * (previous behaviour) rather than emitting a broken src.
116
- */
117
- publicBaseUrl?: string | null;
118
- }): Promise<void> {
119
- const log = getAppLogger();
120
- const rawSecret = await loadRegistryConfig(
121
- "secret",
122
- "resend-api-key",
123
- v.looseObject({ token: v.optional(v.string()) }),
124
- ).catch(() => null);
125
- const decrypted = rawSecret
126
- ? (decryptSecretConfig(rawSecret as never) as { token?: string })
127
- : null;
128
- if (!decrypted?.token) {
129
- log.warn("[authority-report-email] Resend token unavailable — skipping");
130
- return;
131
- }
132
-
133
- const brandRow =
134
- opts.organisationId && opts.prisma
135
- ? await fetchOrgBrand(
136
- opts.prisma as Parameters<typeof fetchOrgBrand>[0],
137
- opts.organisationId,
138
- ).catch(() => null)
139
- : null;
140
- // Brand assets are stored as service-relative paths ("/api/assets/…") —
141
- // the same origin that served the submit request serves them, so the email
142
- // derives the absolute URL from that origin at resolve time instead of the
143
- // profile row being mutated per consumer. Already-absolute logos pass through.
144
- const absolutizeAssetUrl = (url: string | null): string | null => {
145
- if (!url) return null;
146
- if (/^https?:\/\//.test(url)) return url;
147
- if (url.startsWith("/") && opts.publicBaseUrl) return `${opts.publicBaseUrl}${url}`;
148
- return null;
149
- };
150
- const sender = await resolveEmailSender(opts.organisationId ?? null, {
151
- brand: brandRow
152
- ? {
153
- // The FRIENDLY name is the customer-facing one; company_name is the
154
- // legal entity. Recipients were getting "GMG APAC: your Authority
155
- // Index" and alt="GMG APAC" on the logo, for an org whose brand is
156
- // Launcha. Same precedence the platform already uses in
157
- // agreement-gate (friendly ?? legal), so an org that never set a
158
- // friendly name is unchanged.
159
- orgName: brandRow.companyFriendlyName || brandRow.companyName || "",
160
- primaryColor: brandRow.primaryColor,
161
- accentColor: brandRow.accentColor ?? null,
162
- logoUrl: absolutizeAssetUrl(brandRow.logo.light),
163
- }
164
- : undefined,
165
- });
166
- const primary = sender.brand.primaryColor;
167
- const muted = "#6b7280"; // structural neutral for body labels
168
- const logoUrl = sender.brand.logoUrl;
169
- const logoHtml =
170
- logoUrl && /^https?:\/\//.test(logoUrl)
171
- ? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
172
- : "";
173
-
174
- // Subject + body from the `authority-index-report` email_template row, not
175
- // literals here (email-template-registry-type). Dynamic markup is rendered
176
- // here and passed as merge vars — the row owns copy + layout; colours +
177
- // logo come from the org brand tokens, NOT inline literals.
178
- const pillarRows = (
179
- Object.entries(opts.categoryScores) as Array<[string, number]>
180
- )
181
- .map(
182
- ([name, score]) =>
183
- `<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>`,
184
- )
185
- .join("");
186
- const pillarsText = (
187
- Object.entries(opts.categoryScores) as Array<[string, number]>
188
- )
189
- .map(([name, score]) => `${name}: ${score} / 100`)
190
- .join("\n");
191
- // REPORT SECTIONS. Authored per stage on assessments.stage_bands and rendered
192
- // here as markup, so the template row stays copy + layout. Every section is
193
- // optional: a stage that has not authored one yields an empty string and the
194
- // row simply renders nothing for it.
195
- const b = opts.stageBand;
196
- const sectionHtml = (heading: string, inner: string) =>
197
- inner
198
- ? `<h3 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:${primary}">${heading}</h3>${inner}`
199
- : "";
200
- const paraHtml = (text?: string) =>
201
- text ? `<p style="margin:0 0 12px;color:${muted}">${text}</p>` : "";
202
- const bulletsHtml = (items?: string[]) =>
203
- Array.isArray(items) && items.length > 0
204
- ? `<ul style="margin:0 0 12px;padding-left:20px;color:${muted}">${items
205
- .map((i) => `<li style="margin:0 0 4px">${i}</li>`)
206
- .join("")}</ul>`
207
- : "";
208
- const actionsHtml =
209
- Array.isArray(b?.priorityActions) && b.priorityActions.length > 0
210
- ? b.priorityActions
211
- .map(
212
- (a) =>
213
- `<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>`,
214
- )
215
- .join("")
216
- : "";
217
- const roadmapHtml = orderRoadmap(b?.roadmap)
218
- .map(
219
- (phase) =>
220
- `<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>`,
221
- )
222
- .join("");
223
- const notes = b?.pillarNotes ?? {};
224
- const pillarNotesHtml = Object.keys(notes).length
225
- ? Object.entries(notes)
226
- .map(
227
- ([k, v]) =>
228
- `<p style="margin:0 0 8px;color:${muted}"><strong style="font-size:14px;color:${primary};text-transform:capitalize">${k}</strong> — ${v}</p>`,
229
- )
230
- .join("")
231
- : "";
232
- const narrative = generateNarrative(opts.totalScore, b ?? null);
233
-
234
- const tpl = applyTemplateVars(await lookupTemplate("authority-index-report"), {
235
- firstName: opts.firstName || "there",
236
- totalScore: String(opts.totalScore),
237
- bandLabel: opts.bandLabel,
238
- pillarRows,
239
- pillarsText,
240
- reportTitle: opts.reportTitle,
241
- tagline: b?.tagline ?? "",
242
- narrativeHtml: paraHtml(narrative),
243
- pillarNotesHtml: sectionHtml("Where you stand", pillarNotesHtml),
244
- priorityActionsHtml: sectionHtml("Your priority actions", actionsHtml),
245
- roadmapHtml: sectionHtml("Your next 90 days", roadmapHtml),
246
- strategicFocusHtml: sectionHtml("Strategic focus", paraHtml(b?.strategicFocus)),
247
- opportunitiesHtml: sectionHtml("Opportunities", bulletsHtml(b?.opportunities)),
248
- twelveMonthTargetHtml: sectionHtml("Twelve-month target", paraHtml(b?.twelveMonthTarget)),
249
- narrativeText: narrative,
250
- brandPrimaryColor: primary,
251
- brandAccentColor: sender.brand.accentColor ?? primary,
252
- brandLogoHtml: logoHtml,
253
- orgName: sender.brand.orgName,
254
- ctaHtml: opts.ctaUrl
255
- ? `<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>`
256
- : "",
257
- ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
258
- });
259
-
260
- const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
261
- sendResendEmail(decrypted.token, {
262
- from: fromHeader,
263
- to: opts.to,
264
- subject: tpl.subject,
265
- html: tpl.bodyHtml,
266
- text: tpl.bodyText,
267
- reply_to: replyToHeader,
268
- });
269
-
270
- let res = await sendOnce(sender.from, sender.replyTo);
271
- let usedFrom = sender.from;
272
- if (!res.success) {
273
- // Resend refused the org sender (likely unverified domain). Fall back
274
- // to the platform sender; never fail because a verification lookup
275
- // failed — the resolver returns the row, the failure is at Resend.
276
- const platformRow = await loadRegistryConfig(
277
- "config",
278
- "email-defaults",
279
- v.looseObject({ default_sender: v.optional(v.string()) }),
280
- ).catch(() => null);
281
- const fallbackFrom =
282
- (platformRow?.default_sender as string | undefined)?.trim() ||
283
- "scala <noreply@gateway.scala.business>";
284
- log.warn(
285
- `[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
286
- );
287
- res = await sendOnce(fallbackFrom, undefined);
288
- usedFrom = fallbackFrom;
289
- }
290
-
291
- if (res.success) {
292
- log.info(
293
- `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
294
- );
295
- } else {
296
- log.error(
297
- `[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
298
- );
299
- }
300
-
301
- // email-send-audited: best-effort audit row. Guarded so a stale prisma
302
- // client (scala-quiz schema predates email_send_log) skips cleanly.
303
- try {
304
- const auditPrisma = opts.prisma as {
305
- email_send_log?: { create: (args: unknown) => Promise<unknown> };
306
- };
307
- if (auditPrisma?.email_send_log) {
308
- await auditPrisma.email_send_log.create({
309
- data: {
310
- to_address: opts.to,
311
- template_slug: "authority-index-report",
312
- status: res.success ? "sent" : "failed",
313
- provider_message_id: res.id ?? null,
314
- error: res.success ? null : (res.error ?? "unknown"),
315
- from_address: usedFrom,
316
- },
317
- });
318
- }
319
- } catch (auditErr) {
320
- log.warn(
321
- `[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
322
- );
323
- }
324
- }
325
-
326
- function scoreAnswers(
327
- questions: StoredQuestion[],
328
- answers: Array<{
329
- questionId: string;
330
- scaleValue?: number;
331
- choiceValue?: string;
332
- choiceValues?: string[];
333
- }>,
334
- ): { totalScore: number; categoryScores: Record<string, number> } {
335
- const answerByQ = new Map(answers.map((a) => [a.questionId, a]));
336
- // Per-category and overall earned/max accumulators.
337
- const cat = new Map<string, { earned: number; max: number }>();
338
- let totalEarned = 0;
339
- let totalMax = 0;
340
-
341
- for (const q of questions) {
342
- if (q.type === "text") continue; // text questions are qualitative — never scored
343
- const category = q.category || "General";
344
- const a = answerByQ.get(q.id);
345
- let earned = 0;
346
- let max = 0;
347
-
348
- if (q.options && q.options.length > 0) {
349
- // Choice question: max is the highest single option's points; earned is
350
- // the selected option(s)' points (capped at max).
351
- max = Math.max(0, ...q.options.map((o) => o.points || 0));
352
- if (a?.choiceValue) {
353
- earned = q.options.find((o) => o.value === a.choiceValue)?.points ?? 0;
354
- } else if (a?.choiceValues?.length) {
355
- earned = Math.min(
356
- max,
357
- a.choiceValues.reduce(
358
- (s, val) =>
359
- s + (q.options?.find((o) => o.value === val)?.points ?? 0),
360
- 0,
361
- ),
362
- );
363
- }
364
- } else {
365
- // Scale question (the common case): 1–5 → earned 0–4.
366
- max = SCALE_MAX;
367
- if (typeof a?.scaleValue === "number") {
368
- earned = Math.max(0, Math.min(SCALE_MAX, a.scaleValue - 1));
369
- }
370
- }
371
-
372
- if (max <= 0) continue; // text/unscored questions don't affect the score
373
- const entry = cat.get(category) ?? { earned: 0, max: 0 };
374
- entry.earned += earned;
375
- entry.max += max;
376
- cat.set(category, entry);
377
- totalEarned += earned;
378
- totalMax += max;
379
- }
380
-
381
- const categoryScores: Record<string, number> = {};
382
- for (const [name, { earned, max }] of cat) {
383
- categoryScores[name] = max > 0 ? Math.round((100 * earned) / max) : 0;
384
- }
385
- const totalScore = totalMax > 0 ? Math.round((100 * totalEarned) / totalMax) : 0;
386
- return { totalScore, categoryScores };
387
- }
388
-
389
58
  export const publicAssessmentRouter = {
390
59
  submit: pub.input(submitSchema).handler(async ({ input, context }) => {
391
60
  const prisma = context.prisma;