@teamscala/service-boot 0.23.395 → 0.23.397
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
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.397",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@teamscala/agreement-generation": "*",
|
|
20
20
|
"@teamscala/api-resend": "*",
|
|
21
|
+
"@teamscala/assessment-block": "*",
|
|
21
22
|
"@teamscala/auth": "*",
|
|
22
23
|
"@teamscala/cache": "*",
|
|
23
24
|
"@teamscala/channel": "*",
|
|
@@ -0,0 +1,277 @@
|
|
|
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/templates/lookup";
|
|
17
|
+
|
|
18
|
+
interface CapturedEmail {
|
|
19
|
+
to: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
html: string;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let capturedEmail: CapturedEmail | null = null;
|
|
26
|
+
let emailResolve: ((email: CapturedEmail) => void) | null = null;
|
|
27
|
+
let capturedEvent: { kind: string; payload: Record<string, unknown> } | null = null;
|
|
28
|
+
|
|
29
|
+
const noopLogger = {
|
|
30
|
+
info: () => {},
|
|
31
|
+
warn: () => {},
|
|
32
|
+
error: () => {},
|
|
33
|
+
debug: () => {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
37
|
+
getAppLogger: () => noopLogger,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
mock.module("@teamscala/api-resend/send-email", () => ({
|
|
41
|
+
initResendClient: () => {},
|
|
42
|
+
sendEmail: async (args: CapturedEmail) => {
|
|
43
|
+
capturedEmail = args;
|
|
44
|
+
emailResolve?.(args);
|
|
45
|
+
return { success: true as const, id: "re_test_123" };
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// The row owns copy + layout; the test mirrors the authority-index-report
|
|
50
|
+
// row's merge tokens so the substitution surface exercised is the real one.
|
|
51
|
+
// applyTemplateVars stays the REAL implementation — only lookup is stubbed.
|
|
52
|
+
mock.module("@teamscala/email/templates/lookup", () => ({
|
|
53
|
+
lookupTemplate: async () => ({
|
|
54
|
+
subject: "Your Authority Index — {{totalScore}}/100 ({{bandLabel}})",
|
|
55
|
+
bodyHtml:
|
|
56
|
+
"<h1>{{totalScore}}/100 — {{bandLabel}}</h1><h2>{{reportTitle}}</h2><p>{{tagline}}</p>{{narrativeHtml}}{{pillarNotesHtml}}{{priorityActionsHtml}}{{roadmapHtml}}{{strategicFocusHtml}}{{opportunitiesHtml}}{{twelveMonthTargetHtml}}",
|
|
57
|
+
bodyText:
|
|
58
|
+
"{{totalScore}}/100 — {{bandLabel}}\n{{reportTitle}}\n{{tagline}}\n{{narrativeText}}",
|
|
59
|
+
}),
|
|
60
|
+
applyTemplateVars,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
64
|
+
loadRegistryConfig: async () => ({ token: "test-token" }),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
mock.module("@teamscala/encryption/crypto/secret-config", () => ({
|
|
68
|
+
decryptSecretConfig: (raw: unknown) => raw,
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
mock.module("@teamscala/resolver-engine/data-resolvers/fetch-org-brand", () => ({
|
|
72
|
+
fetchOrgBrand: async () => null,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
mock.module("@teamscala/email/identity/resolve-email-sender", () => ({
|
|
76
|
+
resolveEmailSender: async () => ({
|
|
77
|
+
from: "Test <test@example.com>",
|
|
78
|
+
replyTo: undefined,
|
|
79
|
+
isOrgIdentity: false,
|
|
80
|
+
brand: {
|
|
81
|
+
orgName: "TestCo",
|
|
82
|
+
primaryColor: "#123456",
|
|
83
|
+
accentColor: "#abcdef",
|
|
84
|
+
logoUrl: null,
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
mock.module("@teamscala/event-log/record-event", () => ({
|
|
90
|
+
recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
|
|
91
|
+
capturedEvent = e;
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const { publicAssessmentRouter } = await import("./public-assessment-router.ts");
|
|
96
|
+
|
|
97
|
+
// Mirrors the authored assessments.stage_bands shape (ascending max, the last
|
|
98
|
+
// band the catch-all) with the real threshold edges 20/40/60/80 — the band
|
|
99
|
+
// set the report page renders.
|
|
100
|
+
const STAGE_BANDS = {
|
|
101
|
+
bands: [
|
|
102
|
+
{
|
|
103
|
+
name: "Unknown",
|
|
104
|
+
max: 20,
|
|
105
|
+
tagline: "tagline-unknown",
|
|
106
|
+
executiveSummary: "You scored {{score}} — Unknown stage.",
|
|
107
|
+
pillarNotes: { seen: "seen-unknown" },
|
|
108
|
+
priorityActions: [{ title: "Act U1", description: "desc-u1" }],
|
|
109
|
+
roadmap: { month1: { focus: "Focus U", actions: ["u-a"] } },
|
|
110
|
+
strategicFocus: "focus-unknown",
|
|
111
|
+
opportunities: ["opp-u"],
|
|
112
|
+
twelveMonthTarget: "target-unknown",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "Testing",
|
|
116
|
+
max: 40,
|
|
117
|
+
tagline: "tagline-testing",
|
|
118
|
+
executiveSummary: "You scored {{score}} — Testing stage.",
|
|
119
|
+
pillarNotes: { seen: "seen-testing" },
|
|
120
|
+
priorityActions: [{ title: "Act T1", description: "desc-t1" }],
|
|
121
|
+
roadmap: { month1: { focus: "Focus T", actions: ["t-a"] } },
|
|
122
|
+
strategicFocus: "focus-testing",
|
|
123
|
+
opportunities: ["opp-t"],
|
|
124
|
+
twelveMonthTarget: "target-testing",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "Experimenting",
|
|
128
|
+
max: 60,
|
|
129
|
+
tagline: "tagline-experimenting",
|
|
130
|
+
executiveSummary: "You scored {{score}} — Experimenting stage.",
|
|
131
|
+
pillarNotes: { seen: "seen-experimenting", heard: "heard-experimenting" },
|
|
132
|
+
priorityActions: [
|
|
133
|
+
{ title: "Act E1", description: "desc-e1" },
|
|
134
|
+
{ title: "Act E2", description: "desc-e2" },
|
|
135
|
+
],
|
|
136
|
+
roadmap: {
|
|
137
|
+
month1: { focus: "Focus E1", actions: ["e1-a"] },
|
|
138
|
+
month2: { focus: "Focus E2", actions: ["e2-a"] },
|
|
139
|
+
},
|
|
140
|
+
strategicFocus: "focus-experimenting",
|
|
141
|
+
opportunities: ["opp-e"],
|
|
142
|
+
twelveMonthTarget: "target-experimenting",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "Recognised",
|
|
146
|
+
max: 80,
|
|
147
|
+
tagline: "tagline-recognised",
|
|
148
|
+
executiveSummary: "You scored {{score}} — Recognised stage.",
|
|
149
|
+
pillarNotes: {},
|
|
150
|
+
priorityActions: [],
|
|
151
|
+
strategicFocus: "focus-recognised",
|
|
152
|
+
twelveMonthTarget: "target-recognised",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "Industry Influencer",
|
|
156
|
+
tagline: "tagline-influencer",
|
|
157
|
+
executiveSummary: "You scored {{score}} — Influencer stage.",
|
|
158
|
+
pillarNotes: {},
|
|
159
|
+
priorityActions: [],
|
|
160
|
+
strategicFocus: "focus-influencer",
|
|
161
|
+
twelveMonthTarget: "target-influencer",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function makeFakePrisma() {
|
|
167
|
+
return {
|
|
168
|
+
assessments: {
|
|
169
|
+
findUnique: async () => ({
|
|
170
|
+
id: "asmt-1",
|
|
171
|
+
organisation_id: "org-1",
|
|
172
|
+
questions: Array.from({ length: 20 }, (_, i) => ({
|
|
173
|
+
id: `q${i + 1}`,
|
|
174
|
+
type: "scale",
|
|
175
|
+
category: `Pillar ${(i % 4) + 1}`,
|
|
176
|
+
})),
|
|
177
|
+
cta_url: "https://book.example/session",
|
|
178
|
+
slug: "authority-index",
|
|
179
|
+
stage_bands: STAGE_BANDS,
|
|
180
|
+
report_title: "Personalised Authority Blueprint",
|
|
181
|
+
}),
|
|
182
|
+
},
|
|
183
|
+
// client_database deliberately absent — the quiz prisma client is
|
|
184
|
+
// scoped without it, so the CRM branch must skip without error.
|
|
185
|
+
assessment_responses: {
|
|
186
|
+
create: async () => ({ id: "resp-1" }),
|
|
187
|
+
},
|
|
188
|
+
email_send_log: {
|
|
189
|
+
create: async () => ({}),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 20 scale questions (max 80); 9 answered 5 (earned 36) + 11 answered 1 (earned 0) → 45%. */
|
|
195
|
+
function answersForScore45() {
|
|
196
|
+
return Array.from({ length: 20 }, (_, i) => ({
|
|
197
|
+
questionId: `q${i + 1}`,
|
|
198
|
+
scaleValue: i < 9 ? 5 : 1,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
type SubmitHandler = (opts: {
|
|
203
|
+
input: Record<string, unknown>;
|
|
204
|
+
context: { prisma: unknown; headers: Headers };
|
|
205
|
+
}) => Promise<Record<string, unknown>>;
|
|
206
|
+
|
|
207
|
+
async function submitWith45() {
|
|
208
|
+
// The raw handler under ~orpc only reads { input, context }; the other
|
|
209
|
+
// ProcedureHandlerOptions fields are middleware plumbing this procedure
|
|
210
|
+
// (no middlewares) never touches, so the narrowed call shape is cast.
|
|
211
|
+
const handler = publicAssessmentRouter.submit["~orpc"].handler as unknown as SubmitHandler;
|
|
212
|
+
return handler({
|
|
213
|
+
input: {
|
|
214
|
+
assessmentId: "asmt-1",
|
|
215
|
+
firstName: "Alex Reader",
|
|
216
|
+
email: "alex@example.com",
|
|
217
|
+
answers: answersForScore45(),
|
|
218
|
+
},
|
|
219
|
+
context: { prisma: makeFakePrisma(), headers: new Headers() },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
beforeEach(() => {
|
|
224
|
+
capturedEmail = null;
|
|
225
|
+
capturedEvent = null;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("publicAssessment.submit — email band single-sourced from stage_bands", () => {
|
|
229
|
+
test("score 45 names the stage_bands band (Experimenting), not the retired config band (Testing)", async () => {
|
|
230
|
+
const result = await submitWith45();
|
|
231
|
+
expect(result.totalScore).toBe(45);
|
|
232
|
+
expect(result.band).toBe("Experimenting");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("the email arrives naming the same band, with the narrative substituted and no literal {{score}}", async () => {
|
|
236
|
+
const emailPromise = new Promise<CapturedEmail>((resolve) => {
|
|
237
|
+
emailResolve = resolve;
|
|
238
|
+
});
|
|
239
|
+
await submitWith45();
|
|
240
|
+
const email = await emailPromise;
|
|
241
|
+
|
|
242
|
+
expect(email.subject).toContain("45/100");
|
|
243
|
+
expect(email.subject).toContain("Experimenting");
|
|
244
|
+
expect(email.html).toContain("45/100 — Experimenting");
|
|
245
|
+
expect(email.html).toContain("You scored 45 — Experimenting stage.");
|
|
246
|
+
expect(email.html).not.toContain("{{score}}");
|
|
247
|
+
expect(email.text).toContain("You scored 45 — Experimenting stage.");
|
|
248
|
+
expect(email.text).not.toContain("{{score}}");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("report sections render from the same band the header names", async () => {
|
|
252
|
+
const emailPromise = new Promise<CapturedEmail>((resolve) => {
|
|
253
|
+
emailResolve = resolve;
|
|
254
|
+
});
|
|
255
|
+
await submitWith45();
|
|
256
|
+
const email = await emailPromise;
|
|
257
|
+
|
|
258
|
+
expect(email.html).toContain("Personalised Authority Blueprint");
|
|
259
|
+
expect(email.html).toContain("tagline-experimenting");
|
|
260
|
+
expect(email.html).toContain("Act E1");
|
|
261
|
+
expect(email.html).toContain("desc-e1");
|
|
262
|
+
expect(email.html).toContain("Focus E1");
|
|
263
|
+
expect(email.html).toContain("focus-experimenting");
|
|
264
|
+
expect(email.html).toContain("opp-e");
|
|
265
|
+
expect(email.html).toContain("target-experimenting");
|
|
266
|
+
// other bands' copy must not leak into this band's email
|
|
267
|
+
expect(email.html).not.toContain("Act T1");
|
|
268
|
+
expect(email.html).not.toContain("Act U1");
|
|
269
|
+
expect(email.html).not.toContain("Recognised stage");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the event tag slug derives from the authored band name", async () => {
|
|
273
|
+
await submitWith45();
|
|
274
|
+
expect(capturedEvent?.kind).toBe("assessment.authority.completed");
|
|
275
|
+
expect(capturedEvent?.payload.band).toBe("experimenting");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -29,6 +29,13 @@ import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-send
|
|
|
29
29
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
30
30
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
31
31
|
import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
|
|
32
|
+
import type { StageBand } from "@teamscala/assessment-block/report-data";
|
|
33
|
+
import {
|
|
34
|
+
generateNarrative,
|
|
35
|
+
orderRoadmap,
|
|
36
|
+
readStageBands,
|
|
37
|
+
selectStageBand,
|
|
38
|
+
} from "@teamscala/assessment-block/report-data";
|
|
32
39
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
33
40
|
|
|
34
41
|
interface Context {
|
|
@@ -72,36 +79,14 @@ interface StoredQuestion {
|
|
|
72
79
|
const SCALE_MAX = 4;
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `{bands: [{max, slug, label}, …]}`). An assessment with no such row is scored
|
|
78
|
-
* but not banded — so the Authority Index bands without affecting other quizzes.
|
|
79
|
-
* The band NARRATIVE is generated by the Strategist agent from its system prompt.
|
|
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.
|
|
80
84
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
async function getBands(
|
|
88
|
-
assessmentId: string | null | undefined,
|
|
89
|
-
): Promise<Array<{ max: number; slug: string; label: string }> | null> {
|
|
90
|
-
if (!assessmentId) return null;
|
|
91
|
-
const row = await loadRegistryConfig(
|
|
92
|
-
"config",
|
|
93
|
-
`${assessmentId}-bands`,
|
|
94
|
-
bandsRowSchema,
|
|
95
|
-
).catch(() => null);
|
|
96
|
-
if (!row || !Array.isArray(row.bands) || row.bands.length === 0) return null;
|
|
97
|
-
return row.bands;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function bandForScore(
|
|
101
|
-
total: number,
|
|
102
|
-
bands: Array<{ max: number; slug: string; label: string }>,
|
|
103
|
-
): { slug: string; label: string } {
|
|
104
|
-
return bands.find((b) => total <= b.max) ?? bands[bands.length - 1];
|
|
85
|
+
function toBandSlug(name: string): string {
|
|
86
|
+
return name
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
89
|
+
.replace(/^-+|-+$/g, "");
|
|
105
90
|
}
|
|
106
91
|
|
|
107
92
|
|
|
@@ -116,6 +101,8 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
116
101
|
firstName: string;
|
|
117
102
|
totalScore: number;
|
|
118
103
|
bandLabel: string;
|
|
104
|
+
stageBand: StageBand | null;
|
|
105
|
+
reportTitle: string;
|
|
119
106
|
categoryScores: Record<string, number>;
|
|
120
107
|
ctaUrl?: string;
|
|
121
108
|
organisationId?: string | null;
|
|
@@ -178,12 +165,65 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
178
165
|
)
|
|
179
166
|
.map(([name, score]) => `${name}: ${score} / 100`)
|
|
180
167
|
.join("\n");
|
|
168
|
+
// REPORT SECTIONS. Authored per stage on assessments.stage_bands and rendered
|
|
169
|
+
// here as markup, so the template row stays copy + layout. Every section is
|
|
170
|
+
// optional: a stage that has not authored one yields an empty string and the
|
|
171
|
+
// row simply renders nothing for it.
|
|
172
|
+
const b = opts.stageBand;
|
|
173
|
+
const sectionHtml = (heading: string, inner: string) =>
|
|
174
|
+
inner
|
|
175
|
+
? `<h3 style="margin:24px 0 8px;font-size:15px;font-weight:700;color:${primary}">${heading}</h3>${inner}`
|
|
176
|
+
: "";
|
|
177
|
+
const paraHtml = (text?: string) =>
|
|
178
|
+
text ? `<p style="margin:0 0 12px;color:${muted}">${text}</p>` : "";
|
|
179
|
+
const bulletsHtml = (items?: string[]) =>
|
|
180
|
+
Array.isArray(items) && items.length > 0
|
|
181
|
+
? `<ul style="margin:0 0 12px;padding-left:20px;color:${muted}">${items
|
|
182
|
+
.map((i) => `<li style="margin:0 0 4px">${i}</li>`)
|
|
183
|
+
.join("")}</ul>`
|
|
184
|
+
: "";
|
|
185
|
+
const actionsHtml =
|
|
186
|
+
Array.isArray(b?.priorityActions) && b.priorityActions.length > 0
|
|
187
|
+
? b.priorityActions
|
|
188
|
+
.map(
|
|
189
|
+
(a) =>
|
|
190
|
+
`<p style="margin:0 0 10px"><strong style="color:${primary}">${a.title}</strong><br><span style="color:${muted}">${a.description ?? ""}</span></p>`,
|
|
191
|
+
)
|
|
192
|
+
.join("")
|
|
193
|
+
: "";
|
|
194
|
+
const roadmapHtml = orderRoadmap(b?.roadmap)
|
|
195
|
+
.map(
|
|
196
|
+
(phase) =>
|
|
197
|
+
`<p style="margin:0 0 10px"><strong style="color:${primary}">${phase.focus ?? ""}</strong><br><span style="color:${muted}">${(phase.actions ?? []).join(" · ")}</span></p>`,
|
|
198
|
+
)
|
|
199
|
+
.join("");
|
|
200
|
+
const notes = b?.pillarNotes ?? {};
|
|
201
|
+
const pillarNotesHtml = Object.keys(notes).length
|
|
202
|
+
? Object.entries(notes)
|
|
203
|
+
.map(
|
|
204
|
+
([k, v]) =>
|
|
205
|
+
`<p style="margin:0 0 8px;color:${muted}"><strong style="color:${primary};text-transform:capitalize">${k}</strong> — ${v}</p>`,
|
|
206
|
+
)
|
|
207
|
+
.join("")
|
|
208
|
+
: "";
|
|
209
|
+
const narrative = generateNarrative(opts.totalScore, b ?? null);
|
|
210
|
+
|
|
181
211
|
const tpl = applyTemplateVars(await lookupTemplate("authority-index-report"), {
|
|
182
212
|
firstName: opts.firstName || "there",
|
|
183
213
|
totalScore: String(opts.totalScore),
|
|
184
214
|
bandLabel: opts.bandLabel,
|
|
185
215
|
pillarRows,
|
|
186
216
|
pillarsText,
|
|
217
|
+
reportTitle: opts.reportTitle,
|
|
218
|
+
tagline: b?.tagline ?? "",
|
|
219
|
+
narrativeHtml: paraHtml(narrative),
|
|
220
|
+
pillarNotesHtml: sectionHtml("Where you stand", pillarNotesHtml),
|
|
221
|
+
priorityActionsHtml: sectionHtml("Your priority actions", actionsHtml),
|
|
222
|
+
roadmapHtml: sectionHtml("Your next 90 days", roadmapHtml),
|
|
223
|
+
strategicFocusHtml: sectionHtml("Strategic focus", paraHtml(b?.strategicFocus)),
|
|
224
|
+
opportunitiesHtml: sectionHtml("Opportunities", bulletsHtml(b?.opportunities)),
|
|
225
|
+
twelveMonthTargetHtml: sectionHtml("Twelve-month target", paraHtml(b?.twelveMonthTarget)),
|
|
226
|
+
narrativeText: narrative,
|
|
187
227
|
brandPrimaryColor: primary,
|
|
188
228
|
brandAccentColor: sender.brand.accentColor ?? primary,
|
|
189
229
|
brandLogoHtml: logoHtml,
|
|
@@ -338,6 +378,8 @@ export const publicAssessmentRouter = {
|
|
|
338
378
|
questions: true,
|
|
339
379
|
cta_url: true,
|
|
340
380
|
slug: true,
|
|
381
|
+
stage_bands: true,
|
|
382
|
+
report_title: true,
|
|
341
383
|
},
|
|
342
384
|
});
|
|
343
385
|
if (!assessment) return { error: "Assessment not found" } as const;
|
|
@@ -349,8 +391,16 @@ export const publicAssessmentRouter = {
|
|
|
349
391
|
questions,
|
|
350
392
|
input.answers,
|
|
351
393
|
);
|
|
352
|
-
|
|
353
|
-
|
|
394
|
+
// ONE BAND SOURCE. The report page selects from assessments.stage_bands;
|
|
395
|
+
// the email used to select from a parallel `<id>-bands` config row whose
|
|
396
|
+
// thresholds disagreed (45 was Testing to the email and Experimenting to
|
|
397
|
+
// the report). Both now read the authored bands, so the band named in the
|
|
398
|
+
// email is by construction the band the report shows.
|
|
399
|
+
const stageBands = readStageBands(assessment);
|
|
400
|
+
const stageBand = selectStageBand(stageBands, totalScore);
|
|
401
|
+
const band = stageBand
|
|
402
|
+
? { label: stageBand.name, slug: toBandSlug(stageBand.name) }
|
|
403
|
+
: null;
|
|
354
404
|
|
|
355
405
|
// Find-or-create the contact in client_database (the single contact
|
|
356
406
|
// store) — same shape as publicGptSession's gate. OPTIONAL: only
|
|
@@ -461,6 +511,10 @@ export const publicAssessmentRouter = {
|
|
|
461
511
|
firstName,
|
|
462
512
|
totalScore,
|
|
463
513
|
bandLabel: band.label,
|
|
514
|
+
stageBand,
|
|
515
|
+
reportTitle:
|
|
516
|
+
(typeof assessment.report_title === "string" && assessment.report_title) ||
|
|
517
|
+
"Your report",
|
|
464
518
|
categoryScores,
|
|
465
519
|
ctaUrl,
|
|
466
520
|
organisationId: orgId,
|