@teamscala/service-boot 0.23.438 → 0.23.440

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.438",
9
+ "version": "0.23.440",
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) => ({
@@ -12,6 +12,7 @@
12
12
  import { join } from "node:path";
13
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
14
  import { registerFnRouters } from "./fn-routers/index.ts";
15
+ import type { InjectedDb8 } from "@teamscala/orpc/configure";
15
16
  import type { Hono } from "hono";
16
17
  import { bodyLimit } from "hono/body-limit";
17
18
 
@@ -70,6 +71,29 @@ export async function mountOrpcRouter(
70
71
  "@teamscala/orpc/configure"
71
72
  );
72
73
 
74
+ // Coexistence (Prisma 8 migration): create the v8 chained client when
75
+ // the shared platform contract ships inside @teamscala/db. Absence is
76
+ // the EXPECTED state for services that have not migrated — db8 stays
77
+ // undefined and v7 procedures are untouched. One boot-time creation,
78
+ // closed over by the request handler (connects lazily on first query).
79
+ let db8: InjectedDb8 | undefined;
80
+ try {
81
+ if (process.env.DATABASE_URL) {
82
+ const { createDbClientV8 } = await import(
83
+ "@teamscala/db/create-db-client-v8"
84
+ );
85
+ db8 = (await createDbClientV8({
86
+ connectionString: process.env.DATABASE_URL,
87
+ serviceName: serviceSlug,
88
+ })) as never;
89
+ }
90
+ } catch (err) {
91
+ log.info(
92
+ `[scala-install:${serviceSlug}] db8 (Prisma 8 chained client) unavailable: ${err instanceof Error ? err.message : err} — serving v7 procedures only`,
93
+ );
94
+ db8 = undefined;
95
+ }
96
+
73
97
  // Bridge the composed router into tool-executor so in-process tool
74
98
  // dispatch (chat agents' work_items/meetings/agreements/proposals
75
99
  // tools, etc.) resolves against this service's ORPC router + Prisma
@@ -160,7 +184,7 @@ export async function mountOrpcRouter(
160
184
  const prisma = getConfiguredPrisma();
161
185
  const result = await handler.handle(c.req.raw, {
162
186
  prefix: "/api/rpc",
163
- context: { prisma, headers: c.req.raw.headers, resolveUser },
187
+ context: { prisma, db8, headers: c.req.raw.headers, resolveUser },
164
188
  });
165
189
  if (!result?.matched) return c.notFound();
166
190
  return result.response;