@teamscala/service-boot 0.23.507 → 0.23.511
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 +626 -569
- package/src/content-gate/access.ts +18 -0
- package/src/content-gate/config-types.ts +20 -5
- package/src/content-gate/token.segmentation.test.ts +76 -0
- package/src/content-gate/token.ts +54 -6
- package/src/handlers/platform-fault-escalation/delivery.ts +9 -1
- package/src/handlers/quiz-research.ts +178 -0
- package/src/pg-boss-mount.test.ts +100 -0
- package/src/pg-boss-mount.ts +18 -1
- package/src/quiz-gate.ts +16 -5
|
@@ -38,6 +38,17 @@ export async function attachContentGateAccess(
|
|
|
38
38
|
let email = "";
|
|
39
39
|
let name = "";
|
|
40
40
|
let phone = "";
|
|
41
|
+
// Optional prospect segmentation a capture gate's composed form may post
|
|
42
|
+
// (the quiz identity screen: company, LinkedIn, website, team size). Every
|
|
43
|
+
// field is optional and every reader treats absence as "" — a gate that
|
|
44
|
+
// never collects them (the GPT) is unaffected, and a form that collects
|
|
45
|
+
// only some carries the rest as absent. Field names follow the response
|
|
46
|
+
// columns the segmentation lands in (linkedin_url / team_size, snake case)
|
|
47
|
+
// because the composed form posts exactly what its FormField rows name.
|
|
48
|
+
let company = "";
|
|
49
|
+
let linkedinUrl = "";
|
|
50
|
+
let website = "";
|
|
51
|
+
let teamSize = "";
|
|
41
52
|
let bodyFailed = false;
|
|
42
53
|
// The resource id may ride on the URL instead of the body. A gate whose
|
|
43
54
|
// form is COMPOSED FROM BLOCKS has no place to put a hidden field — the
|
|
@@ -58,6 +69,10 @@ export async function attachContentGateAccess(
|
|
|
58
69
|
// Captured alongside the email by an open-capture gate's composed
|
|
59
70
|
// form. It rides in the signed token rather than any client store.
|
|
60
71
|
phone = String(form.phone ?? "").trim();
|
|
72
|
+
company = String(form.company ?? "").trim();
|
|
73
|
+
linkedinUrl = String(form.linkedin_url ?? "").trim();
|
|
74
|
+
website = String(form.website ?? "").trim();
|
|
75
|
+
teamSize = String(form.team_size ?? "").trim();
|
|
61
76
|
} catch (error) {
|
|
62
77
|
log.warn(`[content-gate:${config.cookieName}] body parse failed — redirecting back: ${String(error)}`);
|
|
63
78
|
bodyFailed = true;
|
|
@@ -94,6 +109,9 @@ export async function attachContentGateAccess(
|
|
|
94
109
|
Date.now(),
|
|
95
110
|
name || undefined,
|
|
96
111
|
phone || undefined,
|
|
112
|
+
company || linkedinUrl || website || teamSize
|
|
113
|
+
? { company, linkedinUrl, website, teamSize }
|
|
114
|
+
: undefined,
|
|
97
115
|
);
|
|
98
116
|
if (token) {
|
|
99
117
|
const maxAge = Math.floor(config.ttlMs / 1000);
|
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
|
|
12
12
|
type Resolved = Record<string, unknown>;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Optional prospect-segmentation a lead-capture gate may capture alongside the
|
|
16
|
+
* identity. The quiz gate collects these on its composed identity screen; the
|
|
17
|
+
* token carries them so the next render and the final submit know the visitor
|
|
18
|
+
* fully, with NO client storage (same reasoning as the captured name/phone).
|
|
19
|
+
*/
|
|
20
|
+
export interface GateSegmentation {
|
|
21
|
+
company?: string;
|
|
22
|
+
linkedinUrl?: string;
|
|
23
|
+
website?: string;
|
|
24
|
+
teamSize?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
/** Drives the SSR-time gate (gate.ts / runContentGate). */
|
|
15
28
|
export interface ContentGateConfig {
|
|
16
29
|
/** Cookie that carries this gate's access token (e.g. "ag_token"). */
|
|
@@ -35,7 +48,7 @@ export interface ContentGateConfig {
|
|
|
35
48
|
token: string | undefined,
|
|
36
49
|
resourceId: string,
|
|
37
50
|
nowMs: number,
|
|
38
|
-
): { email: string; name?: string; phone?: string } | null;
|
|
51
|
+
): { email: string; name?: string; phone?: string; segmentation?: GateSegmentation } | null;
|
|
39
52
|
/**
|
|
40
53
|
* BAKED-IN unified auth. When a gate provides this resolver, the engine
|
|
41
54
|
* AUTOMATICALLY releases a logged-in viewer whose session email is in the
|
|
@@ -64,7 +77,7 @@ export interface ContentGateConfig {
|
|
|
64
77
|
onAuthorized(
|
|
65
78
|
resolved: Resolved,
|
|
66
79
|
resourceId: string,
|
|
67
|
-
identity: { email: string; name?: string; phone?: string },
|
|
80
|
+
identity: { email: string; name?: string; phone?: string; segmentation?: GateSegmentation },
|
|
68
81
|
): void | Promise<void>;
|
|
69
82
|
/** Withhold content (strip/inject the gate) — token absent/invalid. Async-
|
|
70
83
|
* capable so a gate can load its display/behaviour settings from a registry
|
|
@@ -111,9 +124,10 @@ export interface AccessConfig {
|
|
|
111
124
|
resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
|
|
112
125
|
/**
|
|
113
126
|
* Mint the access token for a matched email (token-agnostic per gate).
|
|
114
|
-
* `name` and `phone` are the optional captured identity
|
|
115
|
-
*
|
|
116
|
-
*
|
|
127
|
+
* `name` and `phone` are the optional captured identity, `segmentation` the
|
|
128
|
+
* optional captured prospect fields (open-capture gates carry them in the
|
|
129
|
+
* token; allow-list gates ignore them). They exist so a capture gate needs
|
|
130
|
+
* no browser storage to remember who this is.
|
|
117
131
|
*/
|
|
118
132
|
signToken(
|
|
119
133
|
resourceId: string,
|
|
@@ -121,5 +135,6 @@ export interface AccessConfig {
|
|
|
121
135
|
nowMs: number,
|
|
122
136
|
name?: string,
|
|
123
137
|
phone?: string,
|
|
138
|
+
segmentation?: GateSegmentation,
|
|
124
139
|
): string | null;
|
|
125
140
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Segmentation carriage in the content-gate token. The quiz's composed gate
|
|
7
|
+
* screen captures company/linkedin_url/website/team_size alongside the
|
|
8
|
+
* identity; the token carries them to the next render so the submit can put
|
|
9
|
+
* all seven fields on the ONE response row with no client storage. The
|
|
10
|
+
* generated `token.test.ts` owns the identity/binding suite; these are the
|
|
11
|
+
* segmentation-specific cases (including the backward-compatibility contract:
|
|
12
|
+
* a token minted before `s` existed verifies identically).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
process.env.AUTH_SECRET = "local-test-fixture-entropy-0001";
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { signAccessToken, verifyAccessTokenIdentity } from "./token.ts";
|
|
19
|
+
|
|
20
|
+
const NOW = 1_000_000_000_000;
|
|
21
|
+
const TTL = 60_000;
|
|
22
|
+
|
|
23
|
+
function decodePayload(token: string): Record<string, unknown> {
|
|
24
|
+
return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("content-gate token — segmentation carriage", () => {
|
|
28
|
+
test("round-trips the four captured fields", () => {
|
|
29
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614", {
|
|
30
|
+
company: "Acme",
|
|
31
|
+
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
32
|
+
website: "https://acme.test",
|
|
33
|
+
teamSize: "Me + team of 2–5",
|
|
34
|
+
});
|
|
35
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
36
|
+
expect(id?.email).toBe("a@b.com");
|
|
37
|
+
expect(id?.name).toBe("Ann");
|
|
38
|
+
expect(id?.phone).toBe("+614");
|
|
39
|
+
expect(id?.segmentation).toEqual({
|
|
40
|
+
company: "Acme",
|
|
41
|
+
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
42
|
+
website: "https://acme.test",
|
|
43
|
+
teamSize: "Me + team of 2–5",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("BACKWARD COMPATIBILITY: a pre-widening token (no s key) verifies with segmentation undefined — never an error", () => {
|
|
48
|
+
// Mint with the old shape by omitting segmentation entirely, then assert
|
|
49
|
+
// the exact contract a deploy-window survivor must satisfy.
|
|
50
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614");
|
|
51
|
+
expect(decodePayload(t as string).s).toBeUndefined();
|
|
52
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
53
|
+
expect(id).toEqual({ email: "a@b.com", name: "Ann", phone: "+614", segmentation: undefined });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("empty/whitespace segmentation values produce no s key at all", () => {
|
|
57
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
58
|
+
company: " ",
|
|
59
|
+
linkedinUrl: "",
|
|
60
|
+
website: undefined,
|
|
61
|
+
teamSize: undefined,
|
|
62
|
+
});
|
|
63
|
+
expect(decodePayload(t as string).s).toBeUndefined();
|
|
64
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
65
|
+
expect(id?.segmentation).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("values are trimmed on sign", () => {
|
|
69
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
70
|
+
company: " Acme ",
|
|
71
|
+
teamSize: "Just me",
|
|
72
|
+
});
|
|
73
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
74
|
+
expect(id?.segmentation).toEqual({ company: "Acme", teamSize: "Just me" });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* Fail-closed: no usable secret yields null (no token, no access).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import type { GateSegmentation } from "./config-types.ts";
|
|
28
|
+
|
|
27
29
|
function secret(): string | null {
|
|
28
30
|
for (const s of [
|
|
29
31
|
process.env.AUTH_SECRET,
|
|
@@ -68,10 +70,26 @@ export function signAccessToken(
|
|
|
68
70
|
* localStorage.
|
|
69
71
|
*/
|
|
70
72
|
phone?: string,
|
|
73
|
+
/**
|
|
74
|
+
* Optional prospect segmentation captured alongside the identity on the
|
|
75
|
+
* quiz's composed gate screen (company / LinkedIn / website / team size).
|
|
76
|
+
* Same reasoning as name/phone: it rides the signed request, never client
|
|
77
|
+
* storage. OPTIONAL and absent from older tokens — a token without it
|
|
78
|
+
* verifies exactly as before.
|
|
79
|
+
*/
|
|
80
|
+
segmentation?: GateSegmentation,
|
|
71
81
|
): string | null {
|
|
72
82
|
const key = secret();
|
|
73
83
|
if (!key) return null;
|
|
74
|
-
const payload: {
|
|
84
|
+
const payload: {
|
|
85
|
+
c: string;
|
|
86
|
+
r: string;
|
|
87
|
+
e: string;
|
|
88
|
+
x: number;
|
|
89
|
+
n?: string;
|
|
90
|
+
p?: string;
|
|
91
|
+
s?: GateSegmentation;
|
|
92
|
+
} = {
|
|
75
93
|
c: cookieName,
|
|
76
94
|
r: resourceId,
|
|
77
95
|
e: email.trim().toLowerCase(),
|
|
@@ -81,6 +99,16 @@ export function signAccessToken(
|
|
|
81
99
|
if (trimmedName) payload.n = trimmedName;
|
|
82
100
|
const trimmedPhone = phone?.trim();
|
|
83
101
|
if (trimmedPhone) payload.p = trimmedPhone;
|
|
102
|
+
const seg: GateSegmentation = {};
|
|
103
|
+
const segCompany = segmentation?.company?.trim();
|
|
104
|
+
if (segCompany) seg.company = segCompany;
|
|
105
|
+
const segLinkedin = segmentation?.linkedinUrl?.trim();
|
|
106
|
+
if (segLinkedin) seg.linkedinUrl = segLinkedin;
|
|
107
|
+
const segWebsite = segmentation?.website?.trim();
|
|
108
|
+
if (segWebsite) seg.website = segWebsite;
|
|
109
|
+
const segTeamSize = segmentation?.teamSize?.trim();
|
|
110
|
+
if (segTeamSize) seg.teamSize = segTeamSize;
|
|
111
|
+
if (Object.keys(seg).length > 0) payload.s = seg;
|
|
84
112
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
85
113
|
return `${payloadB64}.${hmac(payloadB64, key)}`;
|
|
86
114
|
}
|
|
@@ -121,17 +149,25 @@ export function verifyAccessToken(
|
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
/**
|
|
124
|
-
* Like `verifyAccessToken` but returns the full verified identity
|
|
125
|
-
* so a gate that carries a display name in
|
|
126
|
-
* gate) can seed the visitor's name — not
|
|
127
|
-
*
|
|
152
|
+
* Like `verifyAccessToken` but returns the full verified identity
|
|
153
|
+
* ({email, name, phone, segmentation}) so a gate that carries a display name in
|
|
154
|
+
* the token (the public GPT lead-capture gate) can seed the visitor's name — not
|
|
155
|
+
* just the email — at SSR, and the quiz gate can seed the captured prospect
|
|
156
|
+
* segmentation the same way. Same signature/expiry/gate-binding checks; null on
|
|
157
|
+
* any failure. A token minted BEFORE segmentation existed carries no `s` key and
|
|
158
|
+
* verifies identically (segmentation comes back undefined) — never an error.
|
|
128
159
|
*/
|
|
129
160
|
export function verifyAccessTokenIdentity(
|
|
130
161
|
token: string | undefined | null,
|
|
131
162
|
cookieName: string,
|
|
132
163
|
resourceId: string,
|
|
133
164
|
nowMs: number,
|
|
134
|
-
): {
|
|
165
|
+
): {
|
|
166
|
+
email: string;
|
|
167
|
+
name?: string;
|
|
168
|
+
phone?: string;
|
|
169
|
+
segmentation?: GateSegmentation;
|
|
170
|
+
} | null {
|
|
135
171
|
if (!token) return null;
|
|
136
172
|
const key = secret();
|
|
137
173
|
if (!key) return null;
|
|
@@ -148,15 +184,27 @@ export function verifyAccessTokenIdentity(
|
|
|
148
184
|
x?: number;
|
|
149
185
|
n?: string;
|
|
150
186
|
p?: string;
|
|
187
|
+
s?: GateSegmentation;
|
|
151
188
|
};
|
|
152
189
|
if (p.c !== cookieName) return null;
|
|
153
190
|
if (p.r !== resourceId) return null;
|
|
154
191
|
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
155
192
|
if (typeof p.e !== "string") return null;
|
|
193
|
+
const segmentation =
|
|
194
|
+
p.s && typeof p.s === "object"
|
|
195
|
+
? {
|
|
196
|
+
company: typeof p.s.company === "string" ? p.s.company : undefined,
|
|
197
|
+
linkedinUrl:
|
|
198
|
+
typeof p.s.linkedinUrl === "string" ? p.s.linkedinUrl : undefined,
|
|
199
|
+
website: typeof p.s.website === "string" ? p.s.website : undefined,
|
|
200
|
+
teamSize: typeof p.s.teamSize === "string" ? p.s.teamSize : undefined,
|
|
201
|
+
}
|
|
202
|
+
: undefined;
|
|
156
203
|
return {
|
|
157
204
|
email: p.e,
|
|
158
205
|
name: typeof p.n === "string" ? p.n : undefined,
|
|
159
206
|
phone: typeof p.p === "string" ? p.p : undefined,
|
|
207
|
+
segmentation,
|
|
160
208
|
};
|
|
161
209
|
} catch {
|
|
162
210
|
return null;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
|
|
16
|
+
import { idsWhereJsonKey } from "@teamscala/db/json-key-filter";
|
|
16
17
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
17
18
|
import {
|
|
18
19
|
composeEscalationMessage,
|
|
@@ -36,11 +37,18 @@ interface OperatorRelayBot {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
40
|
+
// flow_config.mode lives inside json; the v8 builder cannot address it.
|
|
41
|
+
const relayBotIds = await idsWhereJsonKey({
|
|
42
|
+
table: "messaging_bots",
|
|
43
|
+
column: "flow_config",
|
|
44
|
+
key: "mode",
|
|
45
|
+
equals: "operator-relay",
|
|
46
|
+
});
|
|
39
47
|
const bot = (await (await orpc()).messaging_bots.findFirst({
|
|
40
48
|
where: {
|
|
41
49
|
is_active: true,
|
|
42
50
|
platform: "whatsapp",
|
|
43
|
-
|
|
51
|
+
id: { in: relayBotIds },
|
|
44
52
|
},
|
|
45
53
|
select: { id: true, organisation_id: true, external_id: true, flow_config: true },
|
|
46
54
|
})) as unknown as OperatorRelayBot | null;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `quiz-research` pg-boss handler — the trigger + execution surface for the
|
|
7
|
+
* quiz research pass (assessment_research). Two job shapes on one queue:
|
|
8
|
+
*
|
|
9
|
+
* - cron tick (jobData null, from the scheduled_job row's cron): the SWEEP —
|
|
10
|
+
* bounded batch of eligible responses (identity anchor present, within the
|
|
11
|
+
* window, no live pass, fewer than the attempt cap of superseded
|
|
12
|
+
* failure-tombstones) → one singleton-deduped one-shot job per response on
|
|
13
|
+
* this same queue. The submit path is never touched; research starts within
|
|
14
|
+
* one tick of the submit.
|
|
15
|
+
* - `{ responseId }`: the RUN — one research pass via
|
|
16
|
+
* @teamscala/assessment's run unit; a throw lands in pg-boss native retry
|
|
17
|
+
* (retryLimit from the scheduled_job row).
|
|
18
|
+
*
|
|
19
|
+
* Tiering: this thin handler is server-tier (imports service-boot's own
|
|
20
|
+
* getPgBoss + the adapters-tier assessment package — downward only); all
|
|
21
|
+
* pipeline logic lives in the capability package.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
25
|
+
import { prisma as databasePrisma } from "@teamscala/db/database/client";
|
|
26
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
28
|
+
import {
|
|
29
|
+
QuizResearchConfigSchema,
|
|
30
|
+
makeProductionDeps,
|
|
31
|
+
recordResearchFailure,
|
|
32
|
+
runQuizResearch,
|
|
33
|
+
type QuizResearchConfig,
|
|
34
|
+
} from "@teamscala/assessment/assessment-research-run";
|
|
35
|
+
import { normaliseLinkedinIdentity } from "@teamscala/assessment/linkedin-identity";
|
|
36
|
+
import { getPgBoss } from "../pg-boss-mount.ts";
|
|
37
|
+
import { registerScheduledJobHandler } from "../pg-boss-handler-registry.ts";
|
|
38
|
+
|
|
39
|
+
const ATTEMPT_CAP = 2;
|
|
40
|
+
const ELIGIBLE_WINDOW_DAYS = 7;
|
|
41
|
+
/** Per-tick dedup window for the one-shot send (singletonKey = responseId). */
|
|
42
|
+
const SEND_DEDUP_SECONDS = 600;
|
|
43
|
+
|
|
44
|
+
interface QuizResearchJobData {
|
|
45
|
+
responseId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function sweepAndDispatch(
|
|
49
|
+
config: QuizResearchConfig,
|
|
50
|
+
queueName: string,
|
|
51
|
+
): Promise<number> {
|
|
52
|
+
const log = getAppLogger();
|
|
53
|
+
const boss = getPgBoss();
|
|
54
|
+
if (!boss) {
|
|
55
|
+
log.warn("[quiz-research] sweep fired but no pg-boss instance is mounted — skipping tick");
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const windowStart = new Date(Date.now() - ELIGIBLE_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
59
|
+
const candidates = (await databasePrisma.assessment_responses.findMany({
|
|
60
|
+
where: { linkedin_url: { not: null }, completed_at: { gt: windowStart } },
|
|
61
|
+
select: {
|
|
62
|
+
id: true,
|
|
63
|
+
assessment_id: true,
|
|
64
|
+
linkedin_url: true,
|
|
65
|
+
completed_at: true,
|
|
66
|
+
},
|
|
67
|
+
orderBy: { completed_at: "desc" },
|
|
68
|
+
take: 200,
|
|
69
|
+
})) as unknown as Array<{
|
|
70
|
+
id: string;
|
|
71
|
+
assessment_id: string;
|
|
72
|
+
linkedin_url: string;
|
|
73
|
+
}>;
|
|
74
|
+
|
|
75
|
+
let dispatched = 0;
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (dispatched >= (config.batch_size ?? 3)) break;
|
|
78
|
+
const identityKey = normaliseLinkedinIdentity(candidate.linkedin_url);
|
|
79
|
+
if (!identityKey) continue;
|
|
80
|
+
const researchRows = (await databasePrisma.assessment_research.findMany({
|
|
81
|
+
where: { assessment_id: candidate.assessment_id, identity_key: identityKey },
|
|
82
|
+
select: { superseded_at: true },
|
|
83
|
+
orderBy: { researched_at: "desc" },
|
|
84
|
+
})) as unknown as Array<{ superseded_at: Date | string | null }>;
|
|
85
|
+
if (researchRows.some((row) => row.superseded_at == null)) continue; // live pass exists
|
|
86
|
+
const tombstones = researchRows.filter((row) => row.superseded_at != null).length;
|
|
87
|
+
if (tombstones >= ATTEMPT_CAP) continue; // failed too many times — leave it
|
|
88
|
+
const sent = await boss.send(
|
|
89
|
+
queueName,
|
|
90
|
+
{ responseId: candidate.id } satisfies QuizResearchJobData,
|
|
91
|
+
{ singletonKey: candidate.id, singletonSeconds: SEND_DEDUP_SECONDS },
|
|
92
|
+
);
|
|
93
|
+
if (sent) dispatched++;
|
|
94
|
+
}
|
|
95
|
+
return dispatched;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function quizResearchHandler(ctx: {
|
|
99
|
+
config: ScheduledJobConfig;
|
|
100
|
+
jobData?: unknown;
|
|
101
|
+
}): Promise<unknown> {
|
|
102
|
+
const log = getAppLogger();
|
|
103
|
+
// "quiz-research" is not yet in the generated ConfigKey union: the registry-keys
|
|
104
|
+
// codegen output is refusing to regenerate (shrink-guard refusal, 2026-09-16) —
|
|
105
|
+
// surfaced to the orchestrator. The cast is the same boundary shape the sibling
|
|
106
|
+
// units use for dynamic slugs; drop it when the union regen lands.
|
|
107
|
+
const config = await loadRegistryConfig("config", "quiz-research" as never, QuizResearchConfigSchema) as unknown as QuizResearchConfig;
|
|
108
|
+
const queueName = ctx.config.queue_name;
|
|
109
|
+
const jobData = (ctx.jobData ?? {}) as QuizResearchJobData;
|
|
110
|
+
|
|
111
|
+
if (!jobData.responseId) {
|
|
112
|
+
const dispatched = await sweepAndDispatch(config, queueName);
|
|
113
|
+
return { mode: "sweep", dispatched };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const responseId = jobData.responseId;
|
|
117
|
+
try {
|
|
118
|
+
// The generated client does not structurally spell the simplified delegate shape
|
|
119
|
+
// ResearchPrisma declares (its method types are deeply generic); the boundary cast
|
|
120
|
+
// is the contained admission — the unit documents the exact calls it makes.
|
|
121
|
+
const result = await runQuizResearch(databasePrisma as unknown as never as Parameters<typeof runQuizResearch>[0], responseId, makeProductionDeps(config));
|
|
122
|
+
return { mode: "run", responseId, ...result };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
125
|
+
// Attempt tombstone: a superseded, findings-less research row that the
|
|
126
|
+
// report can never render (only superseded_at IS NULL rows load) but the
|
|
127
|
+
// sweep can count — ATTEMPT_CAP failures stop further dispatch. Written
|
|
128
|
+
// on every failed invocation so pg-boss retries consume the cap too.
|
|
129
|
+
try {
|
|
130
|
+
const response = (await databasePrisma.assessment_responses.findUnique({
|
|
131
|
+
where: { id: responseId },
|
|
132
|
+
select: {
|
|
133
|
+
assessment_id: true,
|
|
134
|
+
organisation_id: true,
|
|
135
|
+
linkedin_url: true,
|
|
136
|
+
first_name: true,
|
|
137
|
+
last_name: true,
|
|
138
|
+
},
|
|
139
|
+
})) as unknown as {
|
|
140
|
+
assessment_id: string;
|
|
141
|
+
organisation_id: string | null;
|
|
142
|
+
linkedin_url: string | null;
|
|
143
|
+
first_name: string | null;
|
|
144
|
+
last_name: string | null;
|
|
145
|
+
} | null;
|
|
146
|
+
const identityKey = response?.linkedin_url
|
|
147
|
+
? normaliseLinkedinIdentity(response.linkedin_url)
|
|
148
|
+
: null;
|
|
149
|
+
if (response && identityKey) {
|
|
150
|
+
await databasePrisma.assessment_research.create({
|
|
151
|
+
data: {
|
|
152
|
+
assessment_id: response.assessment_id,
|
|
153
|
+
organisation_id: response.organisation_id,
|
|
154
|
+
display_name: [response.first_name, response.last_name]
|
|
155
|
+
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
|
|
156
|
+
.join(" ")
|
|
157
|
+
.trim() || "unresolved",
|
|
158
|
+
identity_key: identityKey,
|
|
159
|
+
linkedin_url: response.linkedin_url as string,
|
|
160
|
+
validation_note: `failed pass: ${reason.slice(0, 400)}`,
|
|
161
|
+
superseded_at: new Date(),
|
|
162
|
+
findings: {},
|
|
163
|
+
},
|
|
164
|
+
select: { id: true },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch (tombstoneErr) {
|
|
168
|
+
log.error(
|
|
169
|
+
`[quiz-research] tombstone write failed for ${responseId}: ${tombstoneErr instanceof Error ? tombstoneErr.message : String(tombstoneErr)}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
recordResearchFailure(responseId, err);
|
|
173
|
+
log.error(`[quiz-research] pass failed for ${responseId}: ${reason}`);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
registerScheduledJobHandler("@teamscala/service-boot:quiz-research", quizResearchHandler);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The mount's poll-only contract: pg-boss v12 dispatches by polling, so the
|
|
7
|
+
* mount dials the INJECTED transaction-pooler URL unchanged (no endpoint
|
|
8
|
+
* derivation of its own — the session-port mapping died with the door,
|
|
9
|
+
* 2026-09-14) and constructs with useListenNotify false spelled explicitly
|
|
10
|
+
* rather than inherited from the supplier default, so a pg-boss bump cannot
|
|
11
|
+
* silently re-introduce a LISTEN dependency. The mock captures the opts
|
|
12
|
+
* PgBoss is constructed with — the claim is about what the mount DIALS and
|
|
13
|
+
* how it dispatches, so the assertions are on the captured opts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
17
|
+
|
|
18
|
+
interface CapturedOpts {
|
|
19
|
+
connectionString?: string;
|
|
20
|
+
max?: number;
|
|
21
|
+
useListenNotify?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const POOLED_URL =
|
|
25
|
+
"postgresql://scala_container_gbg.gbg:s3cret@10.205.50.1:6543/scala_container_gbg?sslmode=require";
|
|
26
|
+
|
|
27
|
+
function seedScheduledJob() {
|
|
28
|
+
return {
|
|
29
|
+
id: "test-job-1",
|
|
30
|
+
type: "scheduled_job",
|
|
31
|
+
slug: "scheduled_job:test-sync",
|
|
32
|
+
label: "Test Sync Job",
|
|
33
|
+
description: null,
|
|
34
|
+
config: {
|
|
35
|
+
handler_slug: "@teamscala/sync-worker:processSyncQueue",
|
|
36
|
+
cron: "*/5 * * * *",
|
|
37
|
+
queue_name: "sync-worker",
|
|
38
|
+
},
|
|
39
|
+
is_active: true,
|
|
40
|
+
sort_order: 0,
|
|
41
|
+
created_at: new Date(),
|
|
42
|
+
updated_at: new Date(),
|
|
43
|
+
noun: "scheduled_job",
|
|
44
|
+
kind: "scheduled_job",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("mountPgBoss poll-only contract", () => {
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
mock.restore();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("dials the injected transaction pooler unchanged and dispatches poll-only", async () => {
|
|
54
|
+
let captured: CapturedOpts | null = null;
|
|
55
|
+
|
|
56
|
+
mock.module("pg-boss", () => ({
|
|
57
|
+
PgBoss: mock((opts: CapturedOpts) => {
|
|
58
|
+
captured = opts;
|
|
59
|
+
return {
|
|
60
|
+
start: mock(() => Promise.resolve()),
|
|
61
|
+
stop: mock(() => Promise.resolve()),
|
|
62
|
+
on: mock(() => undefined),
|
|
63
|
+
createQueue: mock(() => Promise.resolve()),
|
|
64
|
+
work: mock(() => Promise.resolve("worker-id")),
|
|
65
|
+
schedule: mock(() => Promise.resolve()),
|
|
66
|
+
getSchedules: mock(() => Promise.resolve([])),
|
|
67
|
+
unschedule: mock(() => Promise.resolve()),
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
}));
|
|
71
|
+
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
72
|
+
getAllEntries: () => [seedScheduledJob()],
|
|
73
|
+
}));
|
|
74
|
+
mock.module("@teamscala/event-log/record-event", () => ({
|
|
75
|
+
recordEvent: () => undefined,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const { mountPgBoss } = await import("./pg-boss-mount.ts");
|
|
79
|
+
await mountPgBoss({
|
|
80
|
+
databaseUrl: POOLED_URL,
|
|
81
|
+
serviceSlug: "scala-sync-worker",
|
|
82
|
+
packages: ["@teamscala/sync-worker"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const connectionString = captured?.connectionString ?? "";
|
|
86
|
+
// The transaction pooler, exactly as injected — the mount adds no
|
|
87
|
+
// endpoint derivation.
|
|
88
|
+
expect(connectionString).toContain(":6543");
|
|
89
|
+
// NEVER the session port: the door is closing, not widening.
|
|
90
|
+
expect(connectionString).not.toContain(":5999");
|
|
91
|
+
// The tenant suffix the pooler routes by is kept.
|
|
92
|
+
expect(connectionString).toContain("scala_container_gbg.gbg:");
|
|
93
|
+
// The pg sslmode dialect rewrite still applies.
|
|
94
|
+
expect(connectionString).toContain("sslmode=no-verify");
|
|
95
|
+
// Poll-only, spelled: no LISTEN dependency, now and after a bump.
|
|
96
|
+
expect(captured?.useListenNotify).toBe(false);
|
|
97
|
+
// pg-boss stays a single sibling connection (pool sizing doctrine).
|
|
98
|
+
expect(captured?.max).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -36,6 +36,8 @@ import "./handlers/scala-tools-currency-watch.ts";
|
|
|
36
36
|
import "./handlers/singleton-tools-currency-watch.ts";
|
|
37
37
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
38
38
|
import "./handlers/platform-fault-escalation.ts";
|
|
39
|
+
import "./handlers/quiz-research.ts";
|
|
40
|
+
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
|
|
@@ -135,7 +137,22 @@ export async function mountPgBoss(
|
|
|
135
137
|
// shared URL's libpq spelling — route through the canonical rewrite.
|
|
136
138
|
// Config object (not a bare connectionString) so the internal pool is
|
|
137
139
|
// bounded + tagged for the ~220ms cross-internet dev→Railway RTT.
|
|
140
|
+
// POLL-ONLY, EXPLICITLY. pg-boss v12 dispatches by polling (SELECT .. FOR
|
|
141
|
+
// UPDATE SKIP LOCKED inside a transaction; its locks are
|
|
142
|
+
// pg_advisory_xact_lock — transaction-scoped, so a transaction pooler
|
|
143
|
+
// cannot leak them); LISTEN/NOTIFY is a supplier-documented latency hint
|
|
144
|
+
// whose registration dies on a transaction-pooler backend reassignment
|
|
145
|
+
// (measured 2026-09-14: the opted-in sync worker boss logged
|
|
146
|
+
// `LISTEN/NOTIFY channel registration was lost` every log window).
|
|
147
|
+
// attorney.js defaults useListenNotify to false in 12.29.0 (pinned),
|
|
148
|
+
// 12.30.0 (deployed) and 12.31.1 (latest) — all three read directly —
|
|
149
|
+
// and spelling it here keeps that contract explicit so a pg-boss bump
|
|
150
|
+
// cannot silently re-introduce a LISTEN dependency
|
|
151
|
+
// (close-the-session-mode-door, 2026-09-14). The connection dials the
|
|
152
|
+
// injected DATABASE_URL — the transaction pooler — reconciling the pg
|
|
153
|
+
// sslmode dialect; the mount adds no endpoint derivation of its own.
|
|
138
154
|
const boss = new PgBoss({
|
|
155
|
+
useListenNotify: false,
|
|
139
156
|
connectionString: pgSslCompatUrl(ctx.databaseUrl),
|
|
140
157
|
application_name: `pgboss:${ctx.serviceSlug}`,
|
|
141
158
|
// ONE connection, not three: pg-boss is a sibling pool that sits OUTSIDE
|
|
@@ -145,7 +162,7 @@ export async function mountPgBoss(
|
|
|
145
162
|
// background degradation (`a-per-consumer-pool-max-is-not-a-bound-on-the-server`:
|
|
146
163
|
// background work degrades to a queue, interactive work degrades to an
|
|
147
164
|
// outage). The sibling total is declared in
|
|
148
|
-
// config/db-pool-sizing.perServiceOverhead (pg-boss 1
|
|
165
|
+
// config/db-pool-sizing.perServiceOverhead (pg-boss 1; the LISTEN allowance is gone with poll-only).
|
|
149
166
|
max: 1,
|
|
150
167
|
// Generous connection-acquisition budget for the cross-internet link so
|
|
151
168
|
// a slow TLS handshake under sustained load doesn't terminate the conn.
|
package/src/quiz-gate.ts
CHANGED
|
@@ -56,12 +56,23 @@ export const quizGateConfig: ContentGateConfig = {
|
|
|
56
56
|
verifyToken(token, resourceId, nowMs) {
|
|
57
57
|
return verifyAccessTokenIdentity(token, QUIZ_COOKIE, resourceId, nowMs);
|
|
58
58
|
},
|
|
59
|
-
onAuthorized(resolved, _resourceId, { email, name, phone }) {
|
|
59
|
+
onAuthorized(resolved, _resourceId, { email, name, phone, segmentation }) {
|
|
60
60
|
// The one fact the page's block conditions read. Present → the identity
|
|
61
61
|
// container hides and the quiz container shows; absent → the reverse.
|
|
62
|
+
// The segmentation (captured on the same composed screen) rides along so
|
|
63
|
+
// the quiz flow starts already knowing the prospect — its submit then
|
|
64
|
+
// persists all seven identity fields on the ONE response row.
|
|
62
65
|
(resolved as {
|
|
63
|
-
initialVisitor?: {
|
|
64
|
-
|
|
66
|
+
initialVisitor?: {
|
|
67
|
+
name?: string;
|
|
68
|
+
email: string;
|
|
69
|
+
phone?: string;
|
|
70
|
+
company?: string;
|
|
71
|
+
linkedinUrl?: string;
|
|
72
|
+
website?: string;
|
|
73
|
+
teamSize?: string;
|
|
74
|
+
};
|
|
75
|
+
}).initialVisitor = { name, email, phone, ...segmentation };
|
|
65
76
|
},
|
|
66
77
|
onUnauthorized(resolved, resourceId) {
|
|
67
78
|
// The endpoint the identity form posts to, PUBLISHED INTO THE PAGE DATA
|
|
@@ -91,7 +102,7 @@ export const quizAccessConfig: AccessConfig = {
|
|
|
91
102
|
async resolveAuthorizedEmails() {
|
|
92
103
|
return new Set<string>();
|
|
93
104
|
},
|
|
94
|
-
signToken(resourceId, email, nowMs, name, phone) {
|
|
95
|
-
return signAccessToken(QUIZ_COOKIE, resourceId, email, nowMs, quizTtlMs(), name, phone);
|
|
105
|
+
signToken(resourceId, email, nowMs, name, phone, segmentation) {
|
|
106
|
+
return signAccessToken(QUIZ_COOKIE, resourceId, email, nowMs, quizTtlMs(), name, phone, segmentation);
|
|
96
107
|
},
|
|
97
108
|
};
|