@teamscala/service-boot 0.23.398 → 0.23.400
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
|
@@ -25,6 +25,13 @@ interface CapturedEmail {
|
|
|
25
25
|
let capturedEmail: CapturedEmail | null = null;
|
|
26
26
|
let emailResolve: ((email: CapturedEmail) => void) | null = null;
|
|
27
27
|
let capturedEvent: { kind: string; payload: Record<string, unknown> } | null = null;
|
|
28
|
+
/** Mutable so one test can stub a brand row with a relative logo path. */
|
|
29
|
+
let brandRowStub: {
|
|
30
|
+
companyName: string | null;
|
|
31
|
+
primaryColor: string;
|
|
32
|
+
accentColor: string | null;
|
|
33
|
+
logo: { light: string | null; dark: string | null; brand: string | null };
|
|
34
|
+
} | null = null;
|
|
28
35
|
|
|
29
36
|
const noopLogger = {
|
|
30
37
|
info: () => {},
|
|
@@ -53,7 +60,7 @@ mock.module("@teamscala/email/templates/lookup", () => ({
|
|
|
53
60
|
lookupTemplate: async () => ({
|
|
54
61
|
subject: "Your Authority Index — {{totalScore}}/100 ({{bandLabel}})",
|
|
55
62
|
bodyHtml:
|
|
56
|
-
"<h1>{{totalScore}}/100 — {{bandLabel}}</h1><h2>{{reportTitle}}</h2><p>{{tagline}}</p>{{narrativeHtml}}{{pillarNotesHtml}}{{priorityActionsHtml}}{{roadmapHtml}}{{strategicFocusHtml}}{{opportunitiesHtml}}{{twelveMonthTargetHtml}}",
|
|
63
|
+
"{{brandLogoHtml}}<h1>{{totalScore}}/100 — {{bandLabel}}</h1><h2>{{reportTitle}}</h2><p>{{tagline}}</p>{{narrativeHtml}}{{pillarNotesHtml}}{{priorityActionsHtml}}{{roadmapHtml}}{{strategicFocusHtml}}{{opportunitiesHtml}}{{twelveMonthTargetHtml}}",
|
|
57
64
|
bodyText:
|
|
58
65
|
"{{totalScore}}/100 — {{bandLabel}}\n{{reportTitle}}\n{{tagline}}\n{{narrativeText}}",
|
|
59
66
|
}),
|
|
@@ -69,20 +76,24 @@ mock.module("@teamscala/encryption/crypto/secret-config", () => ({
|
|
|
69
76
|
}));
|
|
70
77
|
|
|
71
78
|
mock.module("@teamscala/resolver-engine/data-resolvers/fetch-org-brand", () => ({
|
|
72
|
-
fetchOrgBrand: async () =>
|
|
79
|
+
fetchOrgBrand: async () => brandRowStub,
|
|
73
80
|
}));
|
|
74
81
|
|
|
75
82
|
mock.module("@teamscala/email/identity/resolve-email-sender", () => ({
|
|
76
|
-
resolveEmailSender: async (
|
|
83
|
+
resolveEmailSender: async (
|
|
84
|
+
_organisationId: string | null | undefined,
|
|
85
|
+
opts?: { brand?: Record<string, unknown> },
|
|
86
|
+
) => ({
|
|
77
87
|
from: "Test <test@example.com>",
|
|
78
88
|
replyTo: undefined,
|
|
79
89
|
isOrgIdentity: false,
|
|
80
|
-
brand:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
brand:
|
|
91
|
+
opts?.brand ?? {
|
|
92
|
+
orgName: "TestCo",
|
|
93
|
+
primaryColor: "#123456",
|
|
94
|
+
accentColor: "#abcdef",
|
|
95
|
+
logoUrl: null,
|
|
96
|
+
},
|
|
86
97
|
}),
|
|
87
98
|
}));
|
|
88
99
|
|
|
@@ -204,7 +215,7 @@ type SubmitHandler = (opts: {
|
|
|
204
215
|
context: { prisma: unknown; headers: Headers };
|
|
205
216
|
}) => Promise<Record<string, unknown>>;
|
|
206
217
|
|
|
207
|
-
async function submitWith45() {
|
|
218
|
+
async function submitWith45(headers = new Headers()) {
|
|
208
219
|
// The raw handler under ~orpc only reads { input, context }; the other
|
|
209
220
|
// ProcedureHandlerOptions fields are middleware plumbing this procedure
|
|
210
221
|
// (no middlewares) never touches, so the narrowed call shape is cast.
|
|
@@ -216,13 +227,14 @@ async function submitWith45() {
|
|
|
216
227
|
email: "alex@example.com",
|
|
217
228
|
answers: answersForScore45(),
|
|
218
229
|
},
|
|
219
|
-
context: { prisma: makeFakePrisma(), headers
|
|
230
|
+
context: { prisma: makeFakePrisma(), headers },
|
|
220
231
|
});
|
|
221
232
|
}
|
|
222
233
|
|
|
223
234
|
beforeEach(() => {
|
|
224
235
|
capturedEmail = null;
|
|
225
236
|
capturedEvent = null;
|
|
237
|
+
brandRowStub = null;
|
|
226
238
|
});
|
|
227
239
|
|
|
228
240
|
describe("publicAssessment.submit — email band single-sourced from stage_bands", () => {
|
|
@@ -274,4 +286,48 @@ describe("publicAssessment.submit — email band single-sourced from stage_bands
|
|
|
274
286
|
expect(capturedEvent?.kind).toBe("assessment.authority.completed");
|
|
275
287
|
expect(capturedEvent?.payload.band).toBe("experimenting");
|
|
276
288
|
});
|
|
289
|
+
|
|
290
|
+
test("a relative brand logo renders as an absolute img src from the serving host", async () => {
|
|
291
|
+
brandRowStub = {
|
|
292
|
+
companyName: "GMG APAC",
|
|
293
|
+
primaryColor: "#FF4500",
|
|
294
|
+
accentColor: null,
|
|
295
|
+
logo: {
|
|
296
|
+
light: "/api/assets/5b742740/logos/launcha.png",
|
|
297
|
+
dark: null,
|
|
298
|
+
brand: null,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const emailPromise = new Promise<CapturedEmail>((resolve) => {
|
|
302
|
+
emailResolve = resolve;
|
|
303
|
+
});
|
|
304
|
+
await submitWith45(
|
|
305
|
+
new Headers({ "x-forwarded-host": "quiz.scala.business" }),
|
|
306
|
+
);
|
|
307
|
+
const email = await emailPromise;
|
|
308
|
+
|
|
309
|
+
expect(email.html).toContain(
|
|
310
|
+
'<img src="https://quiz.scala.business/api/assets/5b742740/logos/launcha.png"',
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a relative logo with no host header stays absent rather than emitting a broken src", async () => {
|
|
315
|
+
brandRowStub = {
|
|
316
|
+
companyName: "GMG APAC",
|
|
317
|
+
primaryColor: "#FF4500",
|
|
318
|
+
accentColor: null,
|
|
319
|
+
logo: {
|
|
320
|
+
light: "/api/assets/5b742740/logos/launcha.png",
|
|
321
|
+
dark: null,
|
|
322
|
+
brand: null,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
const emailPromise = new Promise<CapturedEmail>((resolve) => {
|
|
326
|
+
emailResolve = resolve;
|
|
327
|
+
});
|
|
328
|
+
await submitWith45();
|
|
329
|
+
const email = await emailPromise;
|
|
330
|
+
|
|
331
|
+
expect(email.html).not.toMatch(/<img/i);
|
|
332
|
+
});
|
|
277
333
|
});
|
|
@@ -107,6 +107,14 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
107
107
|
ctaUrl?: string;
|
|
108
108
|
organisationId?: string | null;
|
|
109
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;
|
|
110
118
|
}): Promise<void> {
|
|
111
119
|
const log = getAppLogger();
|
|
112
120
|
const rawSecret = await loadRegistryConfig(
|
|
@@ -130,13 +138,23 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
130
138
|
opts.organisationId,
|
|
131
139
|
).catch(() => null)
|
|
132
140
|
: null;
|
|
141
|
+
// Brand assets are stored as service-relative paths ("/api/assets/…") —
|
|
142
|
+
// the same origin that served the submit request serves them, so the email
|
|
143
|
+
// derives the absolute URL from that origin at resolve time instead of the
|
|
144
|
+
// profile row being mutated per consumer. Already-absolute logos pass through.
|
|
145
|
+
const absolutizeAssetUrl = (url: string | null): string | null => {
|
|
146
|
+
if (!url) return null;
|
|
147
|
+
if (/^https?:\/\//.test(url)) return url;
|
|
148
|
+
if (url.startsWith("/") && opts.publicBaseUrl) return `${opts.publicBaseUrl}${url}`;
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
133
151
|
const sender = await resolveEmailSender(opts.organisationId ?? null, {
|
|
134
152
|
brand: brandRow
|
|
135
153
|
? {
|
|
136
154
|
orgName: brandRow.companyName ?? "",
|
|
137
155
|
primaryColor: brandRow.primaryColor,
|
|
138
156
|
accentColor: brandRow.accentColor ?? null,
|
|
139
|
-
logoUrl: brandRow.logo.light
|
|
157
|
+
logoUrl: absolutizeAssetUrl(brandRow.logo.light),
|
|
140
158
|
}
|
|
141
159
|
: undefined,
|
|
142
160
|
});
|
|
@@ -172,7 +190,7 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
172
190
|
const b = opts.stageBand;
|
|
173
191
|
const sectionHtml = (heading: string, inner: string) =>
|
|
174
192
|
inner
|
|
175
|
-
? `<h3 style="margin:24px 0 8px;font-size:
|
|
193
|
+
? `<h3 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:${primary}">${heading}</h3>${inner}`
|
|
176
194
|
: "";
|
|
177
195
|
const paraHtml = (text?: string) =>
|
|
178
196
|
text ? `<p style="margin:0 0 12px;color:${muted}">${text}</p>` : "";
|
|
@@ -187,14 +205,14 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
187
205
|
? b.priorityActions
|
|
188
206
|
.map(
|
|
189
207
|
(a) =>
|
|
190
|
-
`<p style="margin:0 0 10px"><strong style="color:${primary}">${a.title}</strong><br><span style="color:${muted}">${a.description ?? ""}</span></p>`,
|
|
208
|
+
`<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>`,
|
|
191
209
|
)
|
|
192
210
|
.join("")
|
|
193
211
|
: "";
|
|
194
212
|
const roadmapHtml = orderRoadmap(b?.roadmap)
|
|
195
213
|
.map(
|
|
196
214
|
(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>`,
|
|
215
|
+
`<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>`,
|
|
198
216
|
)
|
|
199
217
|
.join("");
|
|
200
218
|
const notes = b?.pillarNotes ?? {};
|
|
@@ -202,7 +220,7 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
202
220
|
? Object.entries(notes)
|
|
203
221
|
.map(
|
|
204
222
|
([k, v]) =>
|
|
205
|
-
`<p style="margin:0 0 8px;color:${muted}"><strong style="color:${primary};text-transform:capitalize">${k}</strong> — ${v}</p>`,
|
|
223
|
+
`<p style="margin:0 0 8px;color:${muted}"><strong style="font-size:14px;color:${primary};text-transform:capitalize">${k}</strong> — ${v}</p>`,
|
|
206
224
|
)
|
|
207
225
|
.join("")
|
|
208
226
|
: "";
|
|
@@ -505,6 +523,15 @@ export const publicAssessmentRouter = {
|
|
|
505
523
|
const ctaUrl =
|
|
506
524
|
(typeof assessment.cta_url === "string" && assessment.cta_url) ||
|
|
507
525
|
undefined;
|
|
526
|
+
// The serving origin for brand asset paths. Behind Railway/Cloudflare the
|
|
527
|
+
// proxy host is authoritative, hence x-forwarded-host first. A
|
|
528
|
+
// non-DNS-safe value is dropped rather than passed into an email.
|
|
529
|
+
const hostHeader =
|
|
530
|
+
context.headers.get("x-forwarded-host") ?? context.headers.get("host");
|
|
531
|
+
const publicBaseUrl =
|
|
532
|
+
hostHeader && /^[A-Za-z0-9.-]+$/.test(hostHeader)
|
|
533
|
+
? `https://${hostHeader}`
|
|
534
|
+
: null;
|
|
508
535
|
if (band) {
|
|
509
536
|
void sendAuthorityReportEmail({
|
|
510
537
|
to: email,
|
|
@@ -519,6 +546,7 @@ export const publicAssessmentRouter = {
|
|
|
519
546
|
ctaUrl,
|
|
520
547
|
organisationId: orgId,
|
|
521
548
|
prisma,
|
|
549
|
+
publicBaseUrl,
|
|
522
550
|
}).catch((e) =>
|
|
523
551
|
log.error(
|
|
524
552
|
`[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
|