@teamscala/service-boot 0.23.439 → 0.23.441

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.439",
9
+ "version": "0.23.441",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -22,6 +22,7 @@ interface CapturedEmail {
22
22
  text: string;
23
23
  }
24
24
 
25
+ let capturedResponseRow: Record<string, unknown> | null = null;
25
26
  let capturedEmail: CapturedEmail | null = null;
26
27
  let emailResolve: ((email: CapturedEmail) => void) | null = null;
27
28
  let capturedEvent: { kind: string; payload: Record<string, unknown> } | null = null;
@@ -197,7 +198,10 @@ function makeFakePrisma() {
197
198
  // client_database deliberately absent — the quiz prisma client is
198
199
  // scoped without it, so the CRM branch must skip without error.
199
200
  assessment_responses: {
200
- create: async () => ({ id: "resp-1" }),
201
+ create: async (args: { data: Record<string, unknown> }) => {
202
+ capturedResponseRow = args?.data ?? null;
203
+ return { id: "resp-1" };
204
+ },
201
205
  },
202
206
  email_send_log: {
203
207
  create: async () => ({}),
@@ -218,7 +222,10 @@ type SubmitHandler = (opts: {
218
222
  context: { prisma: unknown; headers: Headers };
219
223
  }) => Promise<Record<string, unknown>>;
220
224
 
221
- async function submitWith45(headers = new Headers()) {
225
+ async function submitWith45(
226
+ headers = new Headers(),
227
+ extraInput: Record<string, unknown> = {},
228
+ ) {
222
229
  // The raw handler under ~orpc only reads { input, context }; the other
223
230
  // ProcedureHandlerOptions fields are middleware plumbing this procedure
224
231
  // (no middlewares) never touches, so the narrowed call shape is cast.
@@ -229,6 +236,7 @@ async function submitWith45(headers = new Headers()) {
229
236
  firstName: "Alex Reader",
230
237
  email: "alex@example.com",
231
238
  answers: answersForScore45(),
239
+ ...extraInput,
232
240
  },
233
241
  context: { prisma: makeFakePrisma(), headers },
234
242
  });
@@ -237,6 +245,7 @@ async function submitWith45(headers = new Headers()) {
237
245
  beforeEach(() => {
238
246
  capturedEmail = null;
239
247
  capturedEvent = null;
248
+ capturedResponseRow = null;
240
249
  brandRowStub = null;
241
250
  });
242
251
 
@@ -374,3 +383,55 @@ describe("publicAssessment.submit — email band single-sourced from stage_bands
374
383
  expect(email.html).not.toMatch(/<img/i);
375
384
  });
376
385
  });
386
+
387
+ describe("prospect segmentation fields are captured without touching the score", () => {
388
+ const SEGMENTATION = {
389
+ linkedinUrl: "https://www.linkedin.com/in/alex-reader",
390
+ website: "https://alexreader.example",
391
+ teamSize: "Me + team of 2–5",
392
+ };
393
+
394
+ test("all three persist to their own columns", async () => {
395
+ await submitWith45(new Headers(), SEGMENTATION);
396
+ expect(capturedResponseRow?.linkedin_url).toBe(SEGMENTATION.linkedinUrl);
397
+ expect(capturedResponseRow?.website).toBe(SEGMENTATION.website);
398
+ expect(capturedResponseRow?.team_size).toBe(SEGMENTATION.teamSize);
399
+ });
400
+
401
+ test("the Authority Index is IDENTICAL with and without them", async () => {
402
+ // The operator's binding constraint: these are segmentation only and must
403
+ // not move the score. Asserting the same answers produce the same score
404
+ // either way is the only check that actually proves it — asserting the
405
+ // columns were written says nothing about the scorer.
406
+ const without = (await submitWith45()) as {
407
+ totalScore: number;
408
+ categoryScores: Record<string, number>;
409
+ };
410
+ const withFields = (await submitWith45(
411
+ new Headers(),
412
+ SEGMENTATION,
413
+ )) as { totalScore: number; categoryScores: Record<string, number> };
414
+
415
+ expect(withFields.totalScore).toBe(without.totalScore);
416
+ expect(withFields.categoryScores).toEqual(without.categoryScores);
417
+ });
418
+
419
+ test("omitting them writes null, not empty string", async () => {
420
+ // A skipped optional field must be absent in the data, so segmentation
421
+ // queries can tell "not asked" from "answered blank".
422
+ await submitWith45();
423
+ expect(capturedResponseRow?.linkedin_url).toBeNull();
424
+ expect(capturedResponseRow?.website).toBeNull();
425
+ expect(capturedResponseRow?.team_size).toBeNull();
426
+ });
427
+
428
+ test("the existing answers payload is unchanged by their presence", async () => {
429
+ // Guards the other direction: segmentation must not leak into `answers`,
430
+ // which is the set the scorer reads.
431
+ await submitWith45(new Headers(), SEGMENTATION);
432
+ const answers = capturedResponseRow?.answers as Array<{ question_id: string }>;
433
+ expect(answers).toHaveLength(20);
434
+ expect(JSON.stringify(answers)).not.toContain("linkedin");
435
+ expect(JSON.stringify(answers)).not.toContain("team of 2");
436
+ });
437
+ });
@@ -41,6 +41,10 @@ const submitSchema = v.object({
41
41
  email: v.string(),
42
42
  company: v.optional(v.string()),
43
43
  phone: v.optional(v.string()),
44
+ // Prospect segmentation, captured at the company step. All optional and all
45
+ // written to their own columns — none is an input to scoring, which reads
46
+ // `answers` alone.
47
+ linkedinUrl: v.optional(v.string()),
44
48
  website: v.optional(v.string()),
45
49
  industry: v.optional(v.string()),
46
50
  teamSize: v.optional(v.string()),
@@ -178,6 +182,9 @@ export const publicAssessmentRouter = {
178
182
  first_name: firstName,
179
183
  last_name: lastName,
180
184
  company: input.company ?? null,
185
+ linkedin_url: input.linkedinUrl ?? null,
186
+ website: input.website ?? null,
187
+ team_size: input.teamSize ?? null,
181
188
  total_score: totalScore,
182
189
  category_scores: categoryScores,
183
190
  answers: input.answers.map((a) => ({
@@ -73,4 +73,24 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
73
73
  expect(html).toContain("/assets/hydrate.js");
74
74
  expect(html).toContain("/assets/styles.css");
75
75
  });
76
+
77
+ // The fallback is correct as a LAST RESORT and dangerous as a SILENT one:
78
+ // the unhashed names it emits were never built, so the page 200s while its
79
+ // stylesheet 404s. Measured 2026-09-01 — a client-facing proposal served
80
+ // unstyled while the deploy reported SUCCESS, because nothing anywhere in
81
+ // the stack distinguished "client build never ran" from "client build fine".
82
+ // The stamp is what makes the two distinguishable to an operator, a probe,
83
+ // or a deploy gate, so it is asserted rather than left to inspection.
84
+ test("stamps the page when no manifest was built, so a dead deploy is visible", async () => {
85
+ const html = await readHtmlTemplate();
86
+ expect(html).toContain('name="x-client-build-status"');
87
+ expect(html).toContain('content="missing-manifest"');
88
+ });
89
+
90
+ test("does not stamp missing-manifest once a real manifest exists", async () => {
91
+ await writeManifest("/assets/hydrate-abc.js", "/assets/styles-abc.css");
92
+ const html = await readHtmlTemplate();
93
+ expect(html).toContain("/assets/hydrate-abc.js");
94
+ expect(html).not.toContain('content="missing-manifest"');
95
+ });
76
96
  });
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { createCache } from "@teamscala/cache/create-cache";
43
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
43
44
  import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
44
45
  import { join } from "node:path";
45
46
 
@@ -93,7 +94,7 @@ async function manifestMtimeMs(): Promise<number> {
93
94
  }
94
95
  }
95
96
 
96
- async function loadManifest(): Promise<AssetManifest> {
97
+ async function loadManifest(): Promise<AssetManifest | null> {
97
98
  const path = manifestPath();
98
99
  if (await Bun.file(path).exists()) {
99
100
  try {
@@ -101,10 +102,21 @@ async function loadManifest(): Promise<AssetManifest> {
101
102
  if (parsed.stylesPath && parsed.hydratePath) {
102
103
  return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
103
104
  }
104
- } catch (error) {}
105
- getAppLogger().warn("asset manifest unreadable — falling back to default template wiring", error);
105
+ getAppLogger().warn(
106
+ `[read-html-template] asset manifest at ${path} is missing stylesPath/hydratePath — serving unbuilt fallback URLs`,
107
+ );
108
+ } catch (error) {
109
+ getAppLogger().warn(
110
+ `[read-html-template] asset manifest at ${path} is unreadable — serving unbuilt fallback URLs`,
111
+ error,
112
+ );
113
+ }
114
+ return null;
106
115
  }
107
- return FALLBACK;
116
+ getAppLogger().warn(
117
+ `[read-html-template] no asset manifest at ${path} — the client build did not run for this deployment; serving unbuilt fallback URLs that will 404`,
118
+ );
119
+ return null;
108
120
  }
109
121
 
110
122
  export async function readHtmlTemplate(): Promise<string> {
@@ -113,8 +125,24 @@ export async function readHtmlTemplate(): Promise<string> {
113
125
  if (cached && cached.manifestMtime === manifestMtime) return cached.html;
114
126
  const manifest = await loadManifest();
115
127
  let html = PAGE_DOCUMENT
116
- .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
117
- .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
128
+ .replace(/\{\{stylesPath\}\}/g, (manifest ?? FALLBACK).stylesPath)
129
+ .replace(/\{\{hydratePath\}\}/g, (manifest ?? FALLBACK).hydratePath);
130
+ // An ABSENT manifest is a failed client build wearing a working service's
131
+ // clothes: boot succeeds, SSR renders, every response is 200, and the only
132
+ // symptom is a stylesheet URL that was never built. Measured 2026-09-01 —
133
+ // proposals.scala.business served a client-facing proposal unstyled while
134
+ // Railway reported the deploy SUCCESS, because the fallback silently
135
+ // substitutes unhashed names for assets no build produced. The tombstone
136
+ // below already exists for a build that FAILED loudly; a build that never
137
+ // ran must be equally visible, or a broken deployment is indistinguishable
138
+ // from a working one at every layer that reports on it
139
+ // (`a-component-may-not-report-a-state-it-has-not-verified`).
140
+ if (!manifest) {
141
+ const meta = `<meta name="x-client-build-status" content="missing-manifest" data-reason="no dist/client/asset-manifest.json; asset URLs below are unbuilt and will 404">`;
142
+ html = html.includes("</head>")
143
+ ? html.replace("</head>", `${meta}</head>`)
144
+ : `${meta}${html}`;
145
+ }
118
146
  // Tombstone stamp — surfaces a failed client build at the served layer.
119
147
  // `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
120
148
  // JS dependency, visible via view-source / devtools / a future status