@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
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
|
|
15
|
-
*
|
|
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 {
|
|
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 {
|
|
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;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Shared Puck-page-data helpers for the resolved-graph → pageData conversion:
|
|
7
|
+
* the local PuckData structural type, JSON extraction with an honest null on
|
|
8
|
+
* malformed input, layout-default application, region root-prop picking, the
|
|
9
|
+
* RegionEntry shape, and the unresolved-region-slot warn. Plus the brand-token
|
|
10
|
+
* prologue shared by all three render branches.
|
|
11
|
+
*
|
|
12
|
+
* Split out of resolve-page-data.ts (547 lines) — byte-for-byte moves; only
|
|
13
|
+
* closure variables became explicit parameters.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Local structural type for Puck page data. Avoids a type-only import of
|
|
20
|
+
* `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
|
|
21
|
+
* excludes whole-statement type-only imports, so the bare specifier has no
|
|
22
|
+
* installed package to resolve against at publish typecheck time. The shape used
|
|
23
|
+
* here (root/content/zones) is all this module reads or constructs.
|
|
24
|
+
*/
|
|
25
|
+
export type PuckData = {
|
|
26
|
+
root: { props?: Record<string, unknown> };
|
|
27
|
+
content: unknown[];
|
|
28
|
+
zones?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
|
|
32
|
+
|
|
33
|
+
// Returns null on malformed JSON (observed via warn log) instead of a silent
|
|
34
|
+
// masking empty fallback — protective-layers-fail-explicitly. Callers default
|
|
35
|
+
// to EMPTY_PUCK_DATA where an empty render is the intended degradation.
|
|
36
|
+
export function extractPuckData(source: Record<string, unknown>): PuckData | null {
|
|
37
|
+
const raw = source.puck_data ?? source.puckData;
|
|
38
|
+
if (!raw) return EMPTY_PUCK_DATA;
|
|
39
|
+
if (typeof raw === "string") {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw) as PuckData;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
getAppLogger().warn(
|
|
44
|
+
"[extractPuckData] malformed puck_data JSON — rendering empty",
|
|
45
|
+
{ err },
|
|
46
|
+
);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return raw as PuckData;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applyLayoutDefaults(
|
|
54
|
+
puckData: PuckData,
|
|
55
|
+
defaults: Record<string, string>,
|
|
56
|
+
): void {
|
|
57
|
+
if (!defaults || Object.keys(defaults).length === 0) return;
|
|
58
|
+
const data = puckData as { root?: { props?: Record<string, unknown> } };
|
|
59
|
+
if (!data.root) data.root = { props: {} };
|
|
60
|
+
if (!data.root.props) data.root.props = {};
|
|
61
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
62
|
+
if (data.root.props[key] == null) data.root.props[key] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
|
|
67
|
+
for (const region of regions) {
|
|
68
|
+
const props = (
|
|
69
|
+
region.puckData as { root?: { props?: Record<string, unknown> } } | null
|
|
70
|
+
)?.root?.props;
|
|
71
|
+
const value = props?.[key];
|
|
72
|
+
if (value != null && value !== "") return value as T;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RegionEntry {
|
|
78
|
+
slot: string;
|
|
79
|
+
puckData: PuckData;
|
|
80
|
+
pageId: string;
|
|
81
|
+
website?: { slug: string; name: string; organisation_id?: string | null };
|
|
82
|
+
metaTitle?: string | null;
|
|
83
|
+
metaDescription?: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A region slot the operator DECLARED (resource_loader.region_slots) whose
|
|
87
|
+
// source did not resolve used to vanish with no signal anywhere — the page
|
|
88
|
+
// rendered minus a region while the page, source, attributes, template and
|
|
89
|
+
// tree builder all looked innocent (measured 2026-08-18: a footer region
|
|
90
|
+
// added to a report produced no footer and no diagnostic; the cause was the
|
|
91
|
+
// boot-time config capture named in the message). One warn per slot per
|
|
92
|
+
// process, throttled like the block-coverage guard in the caller, so the hot
|
|
93
|
+
// path never spams it.
|
|
94
|
+
const unresolvedRegionSlotsLogged = new Set<string>();
|
|
95
|
+
|
|
96
|
+
export function warnUnresolvedRegionSlot(slot: string): void {
|
|
97
|
+
if (unresolvedRegionSlotsLogged.has(slot)) return;
|
|
98
|
+
unresolvedRegionSlotsLogged.add(slot);
|
|
99
|
+
getAppLogger().warn(
|
|
100
|
+
`[resolve-page-data] region slot "${slot}" is declared in the resource_loader config but no source resolved for it — the region is omitted from this render. A resource_loader edit reaches a booted service only after a restart.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The resolved brand tokens every branch reads (the old file's shared prologue). */
|
|
105
|
+
export function readBrandTokens(resolved: Record<string, unknown>): {
|
|
106
|
+
brand: Record<string, unknown>;
|
|
107
|
+
primaryColor: string;
|
|
108
|
+
headingFont: string | null;
|
|
109
|
+
bodyFont: string | null;
|
|
110
|
+
logo: { light: string | null; dark: string | null; brand: string | null };
|
|
111
|
+
brandTheme: string | undefined;
|
|
112
|
+
} {
|
|
113
|
+
const brand = (resolved.brand as Record<string, unknown>) ?? {};
|
|
114
|
+
const primaryColor = (brand.primaryColor as string) ?? "";
|
|
115
|
+
const headingFont = (brand.headingFont as string) ?? null;
|
|
116
|
+
const bodyFont = (brand.bodyFont as string) ?? null;
|
|
117
|
+
const logo = (brand.logo as {
|
|
118
|
+
light: string | null;
|
|
119
|
+
dark: string | null;
|
|
120
|
+
brand: string | null;
|
|
121
|
+
}) ?? { light: null, dark: null, brand: null };
|
|
122
|
+
const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
|
|
123
|
+
return { brand, primaryColor, headingFont, bodyFont, logo, brandTheme };
|
|
124
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Branch 2 of the resolved-graph → pageData conversion: the public shell
|
|
7
|
+
* (header/body/footer regions from header/footer sources).
|
|
8
|
+
*
|
|
9
|
+
* Split out of resolve-page-data.ts (547 lines) — byte-identical body; only
|
|
10
|
+
* the closure variables became explicit parameters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveThemeMode } from "../resolve-theme-mode.ts";
|
|
14
|
+
import { composePageTitle } from "../compose-page-title.ts";
|
|
15
|
+
import { resolveEntityTitle } from "./meta-title.ts";
|
|
16
|
+
import {
|
|
17
|
+
applyLayoutDefaults,
|
|
18
|
+
EMPTY_PUCK_DATA,
|
|
19
|
+
extractPuckData,
|
|
20
|
+
pickRootProp,
|
|
21
|
+
readBrandTokens,
|
|
22
|
+
type PuckData,
|
|
23
|
+
type RegionEntry,
|
|
24
|
+
} from "./page-data-shared.ts";
|
|
25
|
+
|
|
26
|
+
export function publicShellPageData(
|
|
27
|
+
resolved: Record<string, unknown>,
|
|
28
|
+
meta: Record<string, unknown>,
|
|
29
|
+
themeOverride: "light" | "dark" | undefined,
|
|
30
|
+
orgPrefix: boolean | undefined,
|
|
31
|
+
): Record<string, unknown> {
|
|
32
|
+
const { brand, primaryColor, headingFont, bodyFont, logo, brandTheme } =
|
|
33
|
+
readBrandTokens(resolved);
|
|
34
|
+
const headerSource = resolved.header as Record<string, unknown> | undefined;
|
|
35
|
+
const footerSource = resolved.footer as Record<string, unknown> | undefined;
|
|
36
|
+
|
|
37
|
+
const page = (resolved.page as Record<string, unknown>) ?? {};
|
|
38
|
+
const website =
|
|
39
|
+
(resolved.website_by_host as Record<string, unknown>) ??
|
|
40
|
+
(resolved.website as Record<string, unknown>) ??
|
|
41
|
+
{};
|
|
42
|
+
const layoutDefaults =
|
|
43
|
+
(website.layout_defaults as Record<string, string>) ??
|
|
44
|
+
(website.layout_settings as Record<string, string>) ??
|
|
45
|
+
{};
|
|
46
|
+
const layoutSettings = website.layout_settings as
|
|
47
|
+
| { theme?: unknown }
|
|
48
|
+
| null
|
|
49
|
+
| undefined;
|
|
50
|
+
const websiteTheme =
|
|
51
|
+
layoutSettings && typeof layoutSettings.theme === "string"
|
|
52
|
+
? layoutSettings.theme
|
|
53
|
+
: undefined;
|
|
54
|
+
|
|
55
|
+
const regions: RegionEntry[] = [];
|
|
56
|
+
|
|
57
|
+
if (headerSource) {
|
|
58
|
+
const headerPuck = extractPuckData(headerSource) ?? EMPTY_PUCK_DATA;
|
|
59
|
+
applyLayoutDefaults(headerPuck, layoutDefaults);
|
|
60
|
+
regions.push({
|
|
61
|
+
slot: "header",
|
|
62
|
+
puckData: headerPuck,
|
|
63
|
+
pageId: (headerSource.id as string) ?? "",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const bodyPuck = extractPuckData(page) ?? EMPTY_PUCK_DATA;
|
|
68
|
+
applyLayoutDefaults(bodyPuck, layoutDefaults);
|
|
69
|
+
regions.push({
|
|
70
|
+
slot: "body",
|
|
71
|
+
puckData: bodyPuck,
|
|
72
|
+
pageId: (page.id as string) ?? "",
|
|
73
|
+
metaTitle: (page.meta_title as string) ?? null,
|
|
74
|
+
metaDescription: (page.meta_description as string) ?? null,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (footerSource) {
|
|
78
|
+
const footerPuck = extractPuckData(footerSource) ?? EMPTY_PUCK_DATA;
|
|
79
|
+
applyLayoutDefaults(footerPuck, layoutDefaults);
|
|
80
|
+
regions.push({
|
|
81
|
+
slot: "footer",
|
|
82
|
+
puckData: footerPuck,
|
|
83
|
+
pageId: (footerSource.id as string) ?? "",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rootTheme = pickRootProp<string>(regions, "theme");
|
|
88
|
+
const { theme } = resolveThemeMode({
|
|
89
|
+
override: themeOverride,
|
|
90
|
+
candidates: [rootTheme, brandTheme, websiteTheme],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const overrideColor = pickRootProp<string>(regions, "primaryColor");
|
|
94
|
+
const overrideBg = pickRootProp<string>(regions, "backgroundColor");
|
|
95
|
+
const overrideHeading = pickRootProp<string>(regions, "headingFont");
|
|
96
|
+
const overrideBody = pickRootProp<string>(regions, "bodyFont");
|
|
97
|
+
|
|
98
|
+
const pageName = (page.name as string) || null;
|
|
99
|
+
const entityTitle = resolveEntityTitle(resolved);
|
|
100
|
+
const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
|
|
101
|
+
const pageRootTitle = pickRootProp<string>(regions, "title");
|
|
102
|
+
const pageDescription =
|
|
103
|
+
regions.find((r) => r.slot === "body")?.metaDescription ??
|
|
104
|
+
pickRootProp<string>(regions, "description") ??
|
|
105
|
+
null;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
puckData: { root: { props: {} }, content: [] } as PuckData,
|
|
109
|
+
regions,
|
|
110
|
+
// Region-shell blocks (e.g. the header Navigation deriving the body's
|
|
111
|
+
// document sections) read the resolved entity graph from
|
|
112
|
+
// pageData.loaderData — the same surface Branch 1 exposes. Without it a
|
|
113
|
+
// header/footer block serverLoader gets ctx.loaderData=undefined and
|
|
114
|
+
// cannot see the body page, so a server-derived nav produced no items
|
|
115
|
+
// and the menu was client-only (vanished on stale tabs / narrow views).
|
|
116
|
+
loaderData: resolved,
|
|
117
|
+
website: {
|
|
118
|
+
slug: website.slug as string,
|
|
119
|
+
name: website.name as string,
|
|
120
|
+
organisation_id: website.organisation_id as string | null,
|
|
121
|
+
// header_settings carries the DB-driven header config (e.g.
|
|
122
|
+
// backdropBlur) the public shell applies — PublicTemplate reads
|
|
123
|
+
// pageData.website.header_settings.backdropBlur. Dropping it here
|
|
124
|
+
// silently kills the header blur even though the DB switch is set
|
|
125
|
+
// (the 2026-06-17 regression). Pass it through verbatim.
|
|
126
|
+
header_settings:
|
|
127
|
+
(website.header_settings as Record<string, unknown> | null) ?? null,
|
|
128
|
+
},
|
|
129
|
+
orgId:
|
|
130
|
+
(page.organisation_id as string) ??
|
|
131
|
+
null,
|
|
132
|
+
brand: {
|
|
133
|
+
primaryColor: overrideColor ?? primaryColor,
|
|
134
|
+
// Thread the org brand's dark surface (brand_background_color /
|
|
135
|
+
// brand_dark_color) when the page sets no explicit override — so
|
|
136
|
+
// dark-mode pages use the brand's real dark colour, not a
|
|
137
|
+
// primary-derived tint. Uses the org brand's real dark colour.
|
|
138
|
+
backgroundColor:
|
|
139
|
+
overrideBg ?? (brand.backgroundColor as string | null) ?? null,
|
|
140
|
+
lightSurface: (brand.lightSurface as string | null) ?? null,
|
|
141
|
+
darkSurface: (brand.darkSurface as string | null) ?? null,
|
|
142
|
+
accentColor: (brand.accentColor as string | null) ?? null,
|
|
143
|
+
secondaryColor: (brand.secondaryColor as string | null) ?? null,
|
|
144
|
+
mutedColor: (brand.mutedColor as string | null) ?? null,
|
|
145
|
+
headingFont: overrideHeading ?? headingFont,
|
|
146
|
+
bodyFont: overrideBody ?? bodyFont,
|
|
147
|
+
logo,
|
|
148
|
+
companyName: (brand.companyName as string) ?? null,
|
|
149
|
+
companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
|
|
150
|
+
contactEmail: (brand.contactEmail as string) ?? null,
|
|
151
|
+
favicon: (brand.favicon as string) ?? null,
|
|
152
|
+
},
|
|
153
|
+
theme,
|
|
154
|
+
meta: {
|
|
155
|
+
title: composePageTitle(
|
|
156
|
+
{
|
|
157
|
+
pageMetaTitle: bodyMetaTitle,
|
|
158
|
+
templateTitle: (meta.title as string) || undefined,
|
|
159
|
+
websiteName: (website.name as string) || undefined,
|
|
160
|
+
organisationName: (brand.companyName as string) || undefined,
|
|
161
|
+
pageName: pageName ?? pageRootTitle,
|
|
162
|
+
entityTitle: entityTitle || undefined,
|
|
163
|
+
},
|
|
164
|
+
{ orgPrefix },
|
|
165
|
+
),
|
|
166
|
+
...(pageDescription ? { description: pageDescription } : {}),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|