@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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.398",
9
+ "version": "0.23.400",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -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 () => null,
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
- orgName: "TestCo",
82
- primaryColor: "#123456",
83
- accentColor: "#abcdef",
84
- logoUrl: null,
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: new 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 ?? null,
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:15px;font-weight:700;color:${primary}">${heading}</h3>${inner}`
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)}`,