@teamscala/service-boot 0.23.389 → 0.23.391

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.389",
9
+ "version": "0.23.391",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -3,20 +3,26 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Cached reader for the `email-defaults` config row's `default_sender` — the
7
- * from-address for outbound transactional email (agreement/proposal/assessment
8
- * senders). Replaces the former process.env.DEFAULT_EMAIL_SENDER read
9
- * (configured-primitives: the registry row is the source of truth, not env).
6
+ * Hot snapshot of `email-defaults.default_sender` (outbound transactional
7
+ * from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
8
+ * Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
9
+ * safety ceiling for an upstream regression that drops notifications.
10
10
  */
11
+ import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
11
12
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
12
13
  import * as v from "valibot";
13
14
 
14
15
  const FALLBACK_SENDER = "noreply@gateway.scala.business";
15
- let cached: string | undefined;
16
+ const defaultSenderCache = createNotifyCache<string>({
17
+ name: "service-boot:default-email-sender",
18
+ load: async () => {
19
+ const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
+ return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
+ },
22
+ invalidateOn: ["registry_config_changed"],
23
+ staleAfterMs: 5 * 60 * 1000,
24
+ });
16
25
 
17
26
  export async function getDefaultEmailSender(): Promise<string> {
18
- if (cached !== undefined) return cached;
19
- const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
- cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
- return cached;
27
+ return defaultSenderCache.get();
22
28
  }
@@ -3,21 +3,26 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Cached reader for the `email-links` config row's `email_link_ttl_ms` — the
7
- * validity window for agreement/proposal email-access magic links (dedup'd
8
- * across the 3 fn-routers that mint them). Replaces the former per-file
9
- * hardcoded EMAIL_LINK_TTL_MS constant (config-constants: the registry row is
10
- * the source of truth, not the literal).
6
+ * Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
7
+ * window, dedup'd across the 3 fn-routers that mint them). Replaces the
8
+ * former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
9
+ * `registry_config_changed`.
11
10
  */
11
+ import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
12
12
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
13
13
  import * as v from "valibot";
14
14
 
15
15
  const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
16
- let cached: number | undefined;
16
+ const emailLinkTtlCache = createNotifyCache<number>({
17
+ name: "service-boot:email-link-ttl",
18
+ load: async () => {
19
+ const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
20
+ return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
21
+ },
22
+ invalidateOn: ["registry_config_changed"],
23
+ staleAfterMs: 5 * 60 * 1000,
24
+ });
17
25
 
18
26
  export async function getEmailLinkTtlMs(): Promise<number> {
19
- if (cached !== undefined) return cached;
20
- const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
21
- cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
22
- return cached;
27
+ return emailLinkTtlCache.get();
23
28
  }
@@ -25,10 +25,11 @@ import * as v from "valibot";
25
25
  import { getAppLogger } from "@teamscala/logger/app-loggers";
26
26
  import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
27
27
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
28
+ import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
28
29
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
29
30
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
31
+ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
30
32
  import { recordEvent } from "@teamscala/event-log/record-event";
31
- import { getDefaultEmailSender } from "./email-defaults.ts";
32
33
 
33
34
  interface Context {
34
35
  prisma: any;
@@ -106,9 +107,9 @@ function bandForScore(
106
107
 
107
108
  /**
108
109
  * Best-effort branded Authority Index Report email. Self-contained: loads its
109
- * own Resend secret, swallows every error — a send failure is logged and NEVER
110
- * undoes the submit (the agreement-completion-email pattern). Fire-and-forget
111
- * from the submit handler.
110
+ * own Resend secret, swallows every error. Sender identity + brand tokens
111
+ * come from resolveEmailSender (per-org email_sender row). On Resend refusal
112
+ * the call falls back to the platform sender and logs the reason.
112
113
  */
113
114
  async function sendAuthorityReportEmail(opts: {
114
115
  to: string;
@@ -117,6 +118,9 @@ async function sendAuthorityReportEmail(opts: {
117
118
  bandLabel: string;
118
119
  categoryScores: Record<string, number>;
119
120
  ctaUrl?: string;
121
+ organisationId?: string | null;
122
+ brand?: import("@teamscala/email/identity/resolve-email-sender").OrgBrandTokens;
123
+ prisma: any;
120
124
  }): Promise<void> {
121
125
  const log = getAppLogger();
122
126
  const rawSecret = await loadRegistryConfig(
@@ -132,17 +136,26 @@ async function sendAuthorityReportEmail(opts: {
132
136
  return;
133
137
  }
134
138
  initResendClient(decrypted.token);
135
- const from = await getDefaultEmailSender();
139
+
140
+ const sender = await resolveEmailSender(opts.organisationId ?? null);
141
+ const primary = sender.brand.primaryColor;
142
+ const muted = "#6b7280"; // structural neutral for body labels
143
+ const logoUrl = sender.brand.logoUrl;
144
+ const logoHtml =
145
+ logoUrl && /^https?:\/\//.test(logoUrl)
146
+ ? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
147
+ : "";
148
+
136
149
  // Subject + body from the `authority-index-report` email_template row, not
137
- // literals here (email-template-registry-type). The per-pillar rows and the
138
- // optional CTA are the only DYNAMIC markup, so they are rendered here and
139
- // passed as merge vars — the row still owns all the copy and the layout.
150
+ // literals here (email-template-registry-type). Dynamic markup is rendered
151
+ // here and passed as merge vars — the row owns copy + layout; colours +
152
+ // logo come from the org brand tokens, NOT inline literals.
140
153
  const pillarRows = (
141
154
  Object.entries(opts.categoryScores) as Array<[string, number]>
142
155
  )
143
156
  .map(
144
157
  ([name, score]) =>
145
- `<tr><td style="padding:6px 0;color:#374151">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:#111827">${score} / 100</td></tr>`,
158
+ `<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>`,
146
159
  )
147
160
  .join("");
148
161
  const pillarsText = (
@@ -156,27 +169,80 @@ async function sendAuthorityReportEmail(opts: {
156
169
  bandLabel: opts.bandLabel,
157
170
  pillarRows,
158
171
  pillarsText,
172
+ brandPrimaryColor: primary,
173
+ brandAccentColor: sender.brand.accentColor ?? primary,
174
+ brandLogoHtml: logoHtml,
175
+ orgName: sender.brand.orgName,
159
176
  ctaHtml: opts.ctaUrl
160
- ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
177
+ ? `<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>`
161
178
  : "",
162
179
  ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
163
180
  });
164
- const res = await sendEmail({
165
- from,
166
- to: opts.to,
167
- subject: tpl.subject,
168
- html: tpl.bodyHtml,
169
- text: tpl.bodyText,
170
- });
181
+
182
+ const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
183
+ sendEmail({
184
+ from: fromHeader,
185
+ to: opts.to,
186
+ subject: tpl.subject,
187
+ html: tpl.bodyHtml,
188
+ text: tpl.bodyText,
189
+ reply_to: replyToHeader,
190
+ });
191
+
192
+ let res = await sendOnce(sender.from, sender.replyTo);
193
+ let usedFrom = sender.from;
194
+ if (!res.success) {
195
+ // Resend refused the org sender (likely unverified domain). Fall back
196
+ // to the platform sender; never fail because a verification lookup
197
+ // failed — the resolver returns the row, the failure is at Resend.
198
+ const platformRow = await loadRegistryConfig(
199
+ "config",
200
+ "email-defaults",
201
+ v.looseObject({ default_sender: v.optional(v.string()) }),
202
+ ).catch(() => null);
203
+ const fallbackFrom =
204
+ (platformRow?.default_sender as string | undefined)?.trim() ||
205
+ "scala <noreply@gateway.scala.business>";
206
+ log.warn(
207
+ `[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
208
+ );
209
+ res = await sendOnce(fallbackFrom, undefined);
210
+ usedFrom = fallbackFrom;
211
+ }
212
+
171
213
  if (res.success) {
172
214
  log.info(
173
- `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
215
+ `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
174
216
  );
175
217
  } else {
176
218
  log.error(
177
219
  `[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
178
220
  );
179
221
  }
222
+
223
+ // email-send-audited: best-effort audit row. Guarded so a stale prisma
224
+ // client (scala-quiz schema predates email_send_log) skips cleanly.
225
+ try {
226
+ const auditPrisma = opts.prisma as {
227
+ email_send_log?: { create: (args: unknown) => Promise<unknown> };
228
+ };
229
+ if (auditPrisma?.email_send_log) {
230
+ await auditPrisma.email_send_log.create({
231
+ data: {
232
+ to_address: opts.to,
233
+ template_slug: "authority-index-report",
234
+ status: res.success ? "sent" : "failed",
235
+ provider_message_id: res.id ?? null,
236
+ error: res.success ? null : (res.error ?? "unknown"),
237
+ from_address: usedFrom,
238
+ },
239
+ });
240
+ }
241
+ } catch (auditErr) {
242
+ log.warn(
243
+ `[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
244
+ );
245
+ }
180
246
  }
181
247
 
182
248
  function scoreAnswers(
@@ -375,6 +441,21 @@ export const publicAssessmentRouter = {
375
441
  (typeof assessment.cta_url === "string" && assessment.cta_url) ||
376
442
  undefined;
377
443
  if (band) {
444
+ const prisma = (context as { prisma: unknown }).prisma;
445
+ const brandRow = opts.organisationId
446
+ ? await fetchOrgBrand(
447
+ prisma as Parameters<typeof fetchOrgBrand>[0],
448
+ opts.organisationId,
449
+ ).catch(() => null)
450
+ : null;
451
+ const brand = brandRow
452
+ ? {
453
+ orgName: brandRow.companyName ?? "",
454
+ primaryColor: brandRow.primaryColor,
455
+ accentColor: brandRow.accentColor ?? null,
456
+ logoUrl: brandRow.logo.light ?? null,
457
+ }
458
+ : undefined;
378
459
  void sendAuthorityReportEmail({
379
460
  to: email,
380
461
  firstName,
@@ -382,6 +463,9 @@ export const publicAssessmentRouter = {
382
463
  bandLabel: band.label,
383
464
  categoryScores,
384
465
  ctaUrl,
466
+ organisationId: opts.organisationId,
467
+ brand,
468
+ prisma,
385
469
  }).catch((e) =>
386
470
  log.error(
387
471
  `[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,