@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
|
@@ -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 (
|
|
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(
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|