@teamscala/service-boot 0.23.394 → 0.23.396

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.394",
9
+ "version": "0.23.396",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -16,8 +16,10 @@
16
16
  "src"
17
17
  ],
18
18
  "dependencies": {
19
+ "@orpc/server": "*",
19
20
  "@teamscala/agreement-generation": "*",
20
21
  "@teamscala/api-resend": "*",
22
+ "@teamscala/assessment-block": "*",
21
23
  "@teamscala/auth": "*",
22
24
  "@teamscala/cache": "*",
23
25
  "@teamscala/channel": "*",
@@ -44,7 +46,6 @@
44
46
  "@teamscala/timing": "*",
45
47
  "@teamscala/tool-executor": "*",
46
48
  "@teamscala/watchdog": "*",
47
- "@orpc/server": "*",
48
49
  "hono": "*",
49
50
  "pg-boss": "*",
50
51
  "tailwindcss": "*",
@@ -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
- * Bands are DATA, not code: each assessment that wants score-bands declares them
76
- * in a `config` registry row named `<assessment-slug>-bands` (shape:
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
- const bandsRowSchema = v.looseObject({
82
- bands: v.array(
83
- v.object({ max: v.number(), slug: v.string(), label: v.string() }),
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
- const bands = await getBands(assessment.id);
353
- const band = bands ? bandForScore(totalScore, bands) : null;
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,
@@ -44,6 +44,8 @@ export async function registerPageRoutes(
44
44
  const authMode = (config as { auth?: string }).auth ?? "none";
45
45
  const providers = ((config as { providers?: string[] }).providers ??
46
46
  []) as string[];
47
+ const portalScoped =
48
+ (config as { portal_scoped?: boolean }).portal_scoped === true;
47
49
 
48
50
  const registryPatterns = await registerRegistryPageRoute({
49
51
  app,
@@ -53,6 +55,7 @@ export async function registerPageRoutes(
53
55
  authMode,
54
56
  renderMode,
55
57
  providers,
58
+ portalScoped,
56
59
  });
57
60
 
58
61
  return registryPatterns;
@@ -79,6 +79,12 @@ interface RegistryRouteArgs {
79
79
  authMode: string;
80
80
  renderMode: RenderModeLiteral;
81
81
  providers: string[];
82
+ /** Declared service_install fact: this service's page URL space is
83
+ * org-scoped portal-shaped (/<websiteId>/<orgId>/<path>). Gates the SSR
84
+ * portal home redirect — NOT derivable from catchall presence: a flat-path
85
+ * catchall service (scala-agents-ui) serves its real page at "/" and must
86
+ * not be redirected to an org-scoped path that has no page there. */
87
+ portalScoped: boolean;
82
88
  }
83
89
 
84
90
  function extractUrlParams(
@@ -111,6 +117,7 @@ export async function registerRegistryPageRoute(
111
117
  authMode,
112
118
  renderMode,
113
119
  providers: baseProviders,
120
+ portalScoped,
114
121
  } = args;
115
122
  const log = getAppLogger();
116
123
 
@@ -123,16 +130,6 @@ export async function registerRegistryPageRoute(
123
130
  );
124
131
  if (serviceRoutes.length === 0) return [];
125
132
 
126
- // Only a service serving arbitrary portal paths (a `/*` catchall route —
127
- // the portal) redirects signed-in users from `/` into
128
- // /<website>/<org>/dashboard. Single-site portal-mode services (the
129
- // fleet dashboard) serve their real page AT `/` — the unconditional
130
- // redirect 404-looped them (2026-06-06; service-runtime@1.15.82 made
131
- // the redirect opt-in via this flag).
132
- const servesPortalCatchall = serviceRoutes.some(
133
- (r) => r.config.pattern === "/*",
134
- );
135
-
136
133
  // Sort routes so more specific patterns register before wildcard/param routes.
137
134
  // Hono matches by registration order, so `/*` (the portal catchall) registered
138
135
  // before `/sign-in` would swallow the sign-in page and render it inside the
@@ -834,7 +831,11 @@ export async function registerRegistryPageRoute(
834
831
  providers: routeProviders,
835
832
  renderMode: routeRenderMode,
836
833
  loadData: loadData as never,
837
- portalHomeRedirect: servesPortalCatchall,
834
+ // The portal home redirect follows the DECLARED portal_scoped service
835
+ // fact (see RegistryRouteArgs.portalScoped) — a signed-in user at "/" of
836
+ // an org-scoped portal belongs at /<websiteId>/<orgId>/dashboard; a
837
+ // flat-path service (the fleet dashboard) serves its real page at "/".
838
+ portalHomeRedirect: portalScoped,
838
839
  serviceSlug,
839
840
  });
840
841