@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.
@@ -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);
@@ -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