@teamscala/service-boot 0.23.508 → 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/handlers/quiz-research.ts +178 -0
- package/src/pg-boss-mount.ts +2 -0
|
@@ -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);
|
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
|