@teamscala/service-boot 0.23.416 → 0.23.418
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 +49 -1
- package/src/fn-routers/assessment-report-email.ts +254 -0
- package/src/fn-routers/assessment-scoring.ts +105 -0
- package/src/fn-routers/public-assessment-router.ts +6 -337
- package/src/register-registry-page-route/page-data-shared.ts +124 -0
- package/src/register-registry-page-route/public-shell-page-data.ts +169 -0
- package/src/register-registry-page-route/region-slots-page-data.ts +175 -0
- package/src/register-registry-page-route/resolve-page-data.ts +16 -515
- package/src/register-registry-page-route/single-page-data.ts +171 -0
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.418",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -1205,6 +1205,54 @@
|
|
|
1205
1205
|
"./fn-routers/agreement-sign-side-effects.ts": {
|
|
1206
1206
|
"types": "./src/fn-routers/agreement-sign-side-effects.d.ts",
|
|
1207
1207
|
"default": "./src/fn-routers/agreement-sign-side-effects.ts"
|
|
1208
|
+
},
|
|
1209
|
+
"./fn-routers/assessment-report-email": {
|
|
1210
|
+
"types": "./src/fn-routers/assessment-report-email.d.ts",
|
|
1211
|
+
"default": "./src/fn-routers/assessment-report-email.ts"
|
|
1212
|
+
},
|
|
1213
|
+
"./fn-routers/assessment-report-email.ts": {
|
|
1214
|
+
"types": "./src/fn-routers/assessment-report-email.d.ts",
|
|
1215
|
+
"default": "./src/fn-routers/assessment-report-email.ts"
|
|
1216
|
+
},
|
|
1217
|
+
"./fn-routers/assessment-scoring": {
|
|
1218
|
+
"types": "./src/fn-routers/assessment-scoring.d.ts",
|
|
1219
|
+
"default": "./src/fn-routers/assessment-scoring.ts"
|
|
1220
|
+
},
|
|
1221
|
+
"./fn-routers/assessment-scoring.ts": {
|
|
1222
|
+
"types": "./src/fn-routers/assessment-scoring.d.ts",
|
|
1223
|
+
"default": "./src/fn-routers/assessment-scoring.ts"
|
|
1224
|
+
},
|
|
1225
|
+
"./register-registry-page-route/page-data-shared": {
|
|
1226
|
+
"types": "./src/register-registry-page-route/page-data-shared.d.ts",
|
|
1227
|
+
"default": "./src/register-registry-page-route/page-data-shared.ts"
|
|
1228
|
+
},
|
|
1229
|
+
"./register-registry-page-route/page-data-shared.ts": {
|
|
1230
|
+
"types": "./src/register-registry-page-route/page-data-shared.d.ts",
|
|
1231
|
+
"default": "./src/register-registry-page-route/page-data-shared.ts"
|
|
1232
|
+
},
|
|
1233
|
+
"./register-registry-page-route/region-slots-page-data": {
|
|
1234
|
+
"types": "./src/register-registry-page-route/region-slots-page-data.d.ts",
|
|
1235
|
+
"default": "./src/register-registry-page-route/region-slots-page-data.ts"
|
|
1236
|
+
},
|
|
1237
|
+
"./register-registry-page-route/region-slots-page-data.ts": {
|
|
1238
|
+
"types": "./src/register-registry-page-route/region-slots-page-data.d.ts",
|
|
1239
|
+
"default": "./src/register-registry-page-route/region-slots-page-data.ts"
|
|
1240
|
+
},
|
|
1241
|
+
"./register-registry-page-route/public-shell-page-data": {
|
|
1242
|
+
"types": "./src/register-registry-page-route/public-shell-page-data.d.ts",
|
|
1243
|
+
"default": "./src/register-registry-page-route/public-shell-page-data.ts"
|
|
1244
|
+
},
|
|
1245
|
+
"./register-registry-page-route/public-shell-page-data.ts": {
|
|
1246
|
+
"types": "./src/register-registry-page-route/public-shell-page-data.d.ts",
|
|
1247
|
+
"default": "./src/register-registry-page-route/public-shell-page-data.ts"
|
|
1248
|
+
},
|
|
1249
|
+
"./register-registry-page-route/single-page-data": {
|
|
1250
|
+
"types": "./src/register-registry-page-route/single-page-data.d.ts",
|
|
1251
|
+
"default": "./src/register-registry-page-route/single-page-data.ts"
|
|
1252
|
+
},
|
|
1253
|
+
"./register-registry-page-route/single-page-data.ts": {
|
|
1254
|
+
"types": "./src/register-registry-page-route/single-page-data.d.ts",
|
|
1255
|
+
"default": "./src/register-registry-page-route/single-page-data.ts"
|
|
1208
1256
|
}
|
|
1209
1257
|
},
|
|
1210
1258
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Best-effort branded Authority Index Report email for a public assessment
|
|
7
|
+
* submit: org brand tokens, the email_template row's copy/layout, per-stage
|
|
8
|
+
* report sections, Resend send with platform-sender fallback, and the
|
|
9
|
+
* email_send_log audit row.
|
|
10
|
+
*
|
|
11
|
+
* Split out of fn-routers/public-assessment-router.ts (591 lines) —
|
|
12
|
+
* byte-for-byte move; import paths adjusted only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as v from "valibot";
|
|
16
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
17
|
+
import { sendResendEmail } from "./resend-client.ts";
|
|
18
|
+
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
|
|
19
|
+
import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
|
|
20
|
+
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
21
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
22
|
+
import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
|
|
23
|
+
import type { StageBand } from "@teamscala/assessment-block/report-data";
|
|
24
|
+
import {
|
|
25
|
+
generateNarrative,
|
|
26
|
+
orderRoadmap,
|
|
27
|
+
} from "@teamscala/assessment-block/report-data";
|
|
28
|
+
|
|
29
|
+
export async function sendAuthorityReportEmail(opts: {
|
|
30
|
+
to: string;
|
|
31
|
+
firstName: string;
|
|
32
|
+
totalScore: number;
|
|
33
|
+
bandLabel: string;
|
|
34
|
+
stageBand: StageBand | null;
|
|
35
|
+
reportTitle: string;
|
|
36
|
+
categoryScores: Record<string, number>;
|
|
37
|
+
ctaUrl?: string;
|
|
38
|
+
organisationId?: string | null;
|
|
39
|
+
prisma: any;
|
|
40
|
+
/**
|
|
41
|
+
* Absolute origin (e.g. "https://quiz.scala.business") of the service that
|
|
42
|
+
* received the submit — the origin that serves /api/assets. Mail clients
|
|
43
|
+
* cannot resolve a relative src, so an org logo stored as a relative path
|
|
44
|
+
* only renders when this is provided. Null → a relative logo stays absent
|
|
45
|
+
* (previous behaviour) rather than emitting a broken src.
|
|
46
|
+
*/
|
|
47
|
+
publicBaseUrl?: string | null;
|
|
48
|
+
}): Promise<void> {
|
|
49
|
+
const log = getAppLogger();
|
|
50
|
+
const rawSecret = await loadRegistryConfig(
|
|
51
|
+
"secret",
|
|
52
|
+
"resend-api-key",
|
|
53
|
+
v.looseObject({ token: v.optional(v.string()) }),
|
|
54
|
+
).catch(() => null);
|
|
55
|
+
const decrypted = rawSecret
|
|
56
|
+
? (decryptSecretConfig(rawSecret as never) as { token?: string })
|
|
57
|
+
: null;
|
|
58
|
+
if (!decrypted?.token) {
|
|
59
|
+
log.warn("[authority-report-email] Resend token unavailable — skipping");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const brandRow =
|
|
64
|
+
opts.organisationId && opts.prisma
|
|
65
|
+
? await fetchOrgBrand(
|
|
66
|
+
opts.prisma as Parameters<typeof fetchOrgBrand>[0],
|
|
67
|
+
opts.organisationId,
|
|
68
|
+
).catch(() => null)
|
|
69
|
+
: null;
|
|
70
|
+
// Brand assets are stored as service-relative paths ("/api/assets/…") —
|
|
71
|
+
// the same origin that served the submit request serves them, so the email
|
|
72
|
+
// derives the absolute URL from that origin at resolve time instead of the
|
|
73
|
+
// profile row being mutated per consumer. Already-absolute logos pass through.
|
|
74
|
+
const absolutizeAssetUrl = (url: string | null): string | null => {
|
|
75
|
+
if (!url) return null;
|
|
76
|
+
if (/^https?:\/\//.test(url)) return url;
|
|
77
|
+
if (url.startsWith("/") && opts.publicBaseUrl) return `${opts.publicBaseUrl}${url}`;
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
const sender = await resolveEmailSender(opts.organisationId ?? null, {
|
|
81
|
+
brand: brandRow
|
|
82
|
+
? {
|
|
83
|
+
// The FRIENDLY name is the customer-facing one; company_name is the
|
|
84
|
+
// legal entity. Recipients were getting "GMG APAC: your Authority
|
|
85
|
+
// Index" and alt="GMG APAC" on the logo, for an org whose brand is
|
|
86
|
+
// Launcha. Same precedence the platform already uses in
|
|
87
|
+
// agreement-gate (friendly ?? legal), so an org that never set a
|
|
88
|
+
// friendly name is unchanged.
|
|
89
|
+
orgName: brandRow.companyFriendlyName || brandRow.companyName || "",
|
|
90
|
+
primaryColor: brandRow.primaryColor,
|
|
91
|
+
accentColor: brandRow.accentColor ?? null,
|
|
92
|
+
logoUrl: absolutizeAssetUrl(brandRow.logo.light),
|
|
93
|
+
}
|
|
94
|
+
: undefined,
|
|
95
|
+
});
|
|
96
|
+
const primary = sender.brand.primaryColor;
|
|
97
|
+
const muted = "#6b7280"; // structural neutral for body labels
|
|
98
|
+
const logoUrl = sender.brand.logoUrl;
|
|
99
|
+
const logoHtml =
|
|
100
|
+
logoUrl && /^https?:\/\//.test(logoUrl)
|
|
101
|
+
? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
|
|
102
|
+
: "";
|
|
103
|
+
|
|
104
|
+
// Subject + body from the `authority-index-report` email_template row, not
|
|
105
|
+
// literals here (email-template-registry-type). Dynamic markup is rendered
|
|
106
|
+
// here and passed as merge vars — the row owns copy + layout; colours +
|
|
107
|
+
// logo come from the org brand tokens, NOT inline literals.
|
|
108
|
+
const pillarRows = (
|
|
109
|
+
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
110
|
+
)
|
|
111
|
+
.map(
|
|
112
|
+
([name, score]) =>
|
|
113
|
+
`<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>`,
|
|
114
|
+
)
|
|
115
|
+
.join("");
|
|
116
|
+
const pillarsText = (
|
|
117
|
+
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
118
|
+
)
|
|
119
|
+
.map(([name, score]) => `${name}: ${score} / 100`)
|
|
120
|
+
.join("\n");
|
|
121
|
+
// REPORT SECTIONS. Authored per stage on assessments.stage_bands and rendered
|
|
122
|
+
// here as markup, so the template row stays copy + layout. Every section is
|
|
123
|
+
// optional: a stage that has not authored one yields an empty string and the
|
|
124
|
+
// row simply renders nothing for it.
|
|
125
|
+
const b = opts.stageBand;
|
|
126
|
+
const sectionHtml = (heading: string, inner: string) =>
|
|
127
|
+
inner
|
|
128
|
+
? `<h3 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:${primary}">${heading}</h3>${inner}`
|
|
129
|
+
: "";
|
|
130
|
+
const paraHtml = (text?: string) =>
|
|
131
|
+
text ? `<p style="margin:0 0 12px;color:${muted}">${text}</p>` : "";
|
|
132
|
+
const bulletsHtml = (items?: string[]) =>
|
|
133
|
+
Array.isArray(items) && items.length > 0
|
|
134
|
+
? `<ul style="margin:0 0 12px;padding-left:20px;color:${muted}">${items
|
|
135
|
+
.map((i) => `<li style="margin:0 0 4px">${i}</li>`)
|
|
136
|
+
.join("")}</ul>`
|
|
137
|
+
: "";
|
|
138
|
+
const actionsHtml =
|
|
139
|
+
Array.isArray(b?.priorityActions) && b.priorityActions.length > 0
|
|
140
|
+
? b.priorityActions
|
|
141
|
+
.map(
|
|
142
|
+
(a) =>
|
|
143
|
+
`<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>`,
|
|
144
|
+
)
|
|
145
|
+
.join("")
|
|
146
|
+
: "";
|
|
147
|
+
const roadmapHtml = orderRoadmap(b?.roadmap)
|
|
148
|
+
.map(
|
|
149
|
+
(phase) =>
|
|
150
|
+
`<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>`,
|
|
151
|
+
)
|
|
152
|
+
.join("");
|
|
153
|
+
const notes = b?.pillarNotes ?? {};
|
|
154
|
+
const pillarNotesHtml = Object.keys(notes).length
|
|
155
|
+
? Object.entries(notes)
|
|
156
|
+
.map(
|
|
157
|
+
([k, v]) =>
|
|
158
|
+
`<p style="margin:0 0 8px;color:${muted}"><strong style="font-size:14px;color:${primary};text-transform:capitalize">${k}</strong> — ${v}</p>`,
|
|
159
|
+
)
|
|
160
|
+
.join("")
|
|
161
|
+
: "";
|
|
162
|
+
const narrative = generateNarrative(opts.totalScore, b ?? null);
|
|
163
|
+
|
|
164
|
+
const tpl = applyTemplateVars(await lookupTemplate("authority-index-report"), {
|
|
165
|
+
firstName: opts.firstName || "there",
|
|
166
|
+
totalScore: String(opts.totalScore),
|
|
167
|
+
bandLabel: opts.bandLabel,
|
|
168
|
+
pillarRows,
|
|
169
|
+
pillarsText,
|
|
170
|
+
reportTitle: opts.reportTitle,
|
|
171
|
+
tagline: b?.tagline ?? "",
|
|
172
|
+
narrativeHtml: paraHtml(narrative),
|
|
173
|
+
pillarNotesHtml: sectionHtml("Where you stand", pillarNotesHtml),
|
|
174
|
+
priorityActionsHtml: sectionHtml("Your priority actions", actionsHtml),
|
|
175
|
+
roadmapHtml: sectionHtml("Your next 90 days", roadmapHtml),
|
|
176
|
+
strategicFocusHtml: sectionHtml("Strategic focus", paraHtml(b?.strategicFocus)),
|
|
177
|
+
opportunitiesHtml: sectionHtml("Opportunities", bulletsHtml(b?.opportunities)),
|
|
178
|
+
twelveMonthTargetHtml: sectionHtml("Twelve-month target", paraHtml(b?.twelveMonthTarget)),
|
|
179
|
+
narrativeText: narrative,
|
|
180
|
+
brandPrimaryColor: primary,
|
|
181
|
+
brandAccentColor: sender.brand.accentColor ?? primary,
|
|
182
|
+
brandLogoHtml: logoHtml,
|
|
183
|
+
orgName: sender.brand.orgName,
|
|
184
|
+
ctaHtml: opts.ctaUrl
|
|
185
|
+
? `<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>`
|
|
186
|
+
: "",
|
|
187
|
+
ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
|
|
191
|
+
sendResendEmail(decrypted.token, {
|
|
192
|
+
from: fromHeader,
|
|
193
|
+
to: opts.to,
|
|
194
|
+
subject: tpl.subject,
|
|
195
|
+
html: tpl.bodyHtml,
|
|
196
|
+
text: tpl.bodyText,
|
|
197
|
+
reply_to: replyToHeader,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let res = await sendOnce(sender.from, sender.replyTo);
|
|
201
|
+
let usedFrom = sender.from;
|
|
202
|
+
if (!res.success) {
|
|
203
|
+
// Resend refused the org sender (likely unverified domain). Fall back
|
|
204
|
+
// to the platform sender; never fail because a verification lookup
|
|
205
|
+
// failed — the resolver returns the row, the failure is at Resend.
|
|
206
|
+
const platformRow = await loadRegistryConfig(
|
|
207
|
+
"config",
|
|
208
|
+
"email-defaults",
|
|
209
|
+
v.looseObject({ default_sender: v.optional(v.string()) }),
|
|
210
|
+
).catch(() => null);
|
|
211
|
+
const fallbackFrom =
|
|
212
|
+
(platformRow?.default_sender as string | undefined)?.trim() ||
|
|
213
|
+
"scala <noreply@gateway.scala.business>";
|
|
214
|
+
log.warn(
|
|
215
|
+
`[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
|
|
216
|
+
);
|
|
217
|
+
res = await sendOnce(fallbackFrom, undefined);
|
|
218
|
+
usedFrom = fallbackFrom;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (res.success) {
|
|
222
|
+
log.info(
|
|
223
|
+
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
log.error(
|
|
227
|
+
`[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// email-send-audited: best-effort audit row. Guarded so a stale prisma
|
|
232
|
+
// client (scala-quiz schema predates email_send_log) skips cleanly.
|
|
233
|
+
try {
|
|
234
|
+
const auditPrisma = opts.prisma as {
|
|
235
|
+
email_send_log?: { create: (args: unknown) => Promise<unknown> };
|
|
236
|
+
};
|
|
237
|
+
if (auditPrisma?.email_send_log) {
|
|
238
|
+
await auditPrisma.email_send_log.create({
|
|
239
|
+
data: {
|
|
240
|
+
to_address: opts.to,
|
|
241
|
+
template_slug: "authority-index-report",
|
|
242
|
+
status: res.success ? "sent" : "failed",
|
|
243
|
+
provider_message_id: res.id ?? null,
|
|
244
|
+
error: res.success ? null : (res.error ?? "unknown"),
|
|
245
|
+
from_address: usedFrom,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} catch (auditErr) {
|
|
250
|
+
log.warn(
|
|
251
|
+
`[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Public assessment (quiz) scoring: the stored-question shape, the scale/choice
|
|
7
|
+
* earned-vs-max rules, per-category + total scores, and the stable stage-band
|
|
8
|
+
* slug derivation.
|
|
9
|
+
*
|
|
10
|
+
* Scoring (recovered from live data, 2026-06-13): each scale answer contributes
|
|
11
|
+
* (scaleValue - 1) earned out of 4 max (1–5 scale → 0–4); choice answers
|
|
12
|
+
* contribute the selected option's points out of the question's max option
|
|
13
|
+
* points. A category's score is round(100 * earned / max) over that category's
|
|
14
|
+
* questions; the overall total is the same over all questions. Unanswered
|
|
15
|
+
* questions count toward the denominator (contribute 0) — matches the historical
|
|
16
|
+
* rows (e.g. Σ(v-1)=78 over 36 questions → 78/144 = 54).
|
|
17
|
+
*
|
|
18
|
+
* Split out of fn-routers/public-assessment-router.ts (591 lines) —
|
|
19
|
+
* byte-for-byte move; import paths adjusted only.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface StoredQuestion {
|
|
23
|
+
id: string;
|
|
24
|
+
category?: string | null;
|
|
25
|
+
type?: string | null;
|
|
26
|
+
weight?: number | null;
|
|
27
|
+
options?: Array<{ value: string; points: number }> | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Scale answers are 1–5; earned = value-1, max = 4. */
|
|
31
|
+
const SCALE_MAX = 4;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A stage band names itself; the event tag needs a stable slug. Derived from
|
|
35
|
+
* the authored name rather than stored beside it, so the two cannot drift.
|
|
36
|
+
*/
|
|
37
|
+
export function toBandSlug(name: string): string {
|
|
38
|
+
return name
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
41
|
+
.replace(/^-+|-+$/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function scoreAnswers(
|
|
45
|
+
questions: StoredQuestion[],
|
|
46
|
+
answers: Array<{
|
|
47
|
+
questionId: string;
|
|
48
|
+
scaleValue?: number;
|
|
49
|
+
choiceValue?: string;
|
|
50
|
+
choiceValues?: string[];
|
|
51
|
+
}>,
|
|
52
|
+
): { totalScore: number; categoryScores: Record<string, number> } {
|
|
53
|
+
const answerByQ = new Map(answers.map((a) => [a.questionId, a]));
|
|
54
|
+
// Per-category and overall earned/max accumulators.
|
|
55
|
+
const cat = new Map<string, { earned: number; max: number }>();
|
|
56
|
+
let totalEarned = 0;
|
|
57
|
+
let totalMax = 0;
|
|
58
|
+
|
|
59
|
+
for (const q of questions) {
|
|
60
|
+
if (q.type === "text") continue; // text questions are qualitative — never scored
|
|
61
|
+
const category = q.category || "General";
|
|
62
|
+
const a = answerByQ.get(q.id);
|
|
63
|
+
let earned = 0;
|
|
64
|
+
let max = 0;
|
|
65
|
+
|
|
66
|
+
if (q.options && q.options.length > 0) {
|
|
67
|
+
// Choice question: max is the highest single option's points; earned is
|
|
68
|
+
// the selected option(s)' points (capped at max).
|
|
69
|
+
max = Math.max(0, ...q.options.map((o) => o.points || 0));
|
|
70
|
+
if (a?.choiceValue) {
|
|
71
|
+
earned = q.options.find((o) => o.value === a.choiceValue)?.points ?? 0;
|
|
72
|
+
} else if (a?.choiceValues?.length) {
|
|
73
|
+
earned = Math.min(
|
|
74
|
+
max,
|
|
75
|
+
a.choiceValues.reduce(
|
|
76
|
+
(s, val) =>
|
|
77
|
+
s + (q.options?.find((o) => o.value === val)?.points ?? 0),
|
|
78
|
+
0,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Scale question (the common case): 1–5 → earned 0–4.
|
|
84
|
+
max = SCALE_MAX;
|
|
85
|
+
if (typeof a?.scaleValue === "number") {
|
|
86
|
+
earned = Math.max(0, Math.min(SCALE_MAX, a.scaleValue - 1));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (max <= 0) continue; // text/unscored questions don't affect the score
|
|
91
|
+
const entry = cat.get(category) ?? { earned: 0, max: 0 };
|
|
92
|
+
entry.earned += earned;
|
|
93
|
+
entry.max += max;
|
|
94
|
+
cat.set(category, entry);
|
|
95
|
+
totalEarned += earned;
|
|
96
|
+
totalMax += max;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const categoryScores: Record<string, number> = {};
|
|
100
|
+
for (const [name, { earned, max }] of cat) {
|
|
101
|
+
categoryScores[name] = max > 0 ? Math.round((100 * earned) / max) : 0;
|
|
102
|
+
}
|
|
103
|
+
const totalScore = totalMax > 0 ? Math.round((100 * totalEarned) / totalMax) : 0;
|
|
104
|
+
return { totalScore, categoryScores };
|
|
105
|
+
}
|