@teamscala/service-boot 0.23.516 → 0.23.518

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.
Files changed (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. package/src/resolve-service-context.ts +0 -88
@@ -1,246 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `platform-fault-escalation` pg-boss handler. Full contract in curated
7
- * `apps/escalation.md` — episodes EMIT ON TRANSITIONS ONLY; evidence bounded
8
- * `occurred_at <= now()` and cited by resolvable event id; delivery verified
9
- * by WhatsApp message id before `escalated_at` is recorded.
10
- *
11
- * This module is the SWEEP unit (load config → aggregate → decide → deliver/
12
- * resolve per kind, isolated). The machinery lives as siblings under
13
- * ./platform-fault-escalation/ (config, event-log-queries, orpc-client,
14
- * delivery) — split when the parent held them all at 500 lines; the pure
15
- * decision logic was already platform-fault-escalation-logic.ts.
16
- */
17
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
18
- import { recordEvent } from "@teamscala/event-log/record-event";
19
- import { getAppLogger } from "@teamscala/logger/app-loggers";
20
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
21
- import {
22
- decideSweep,
23
- episodeNeedsRefresh,
24
- type EscalationEpisode,
25
- type KindThresholdOverride,
26
- type ZeroVolumeInputs,
27
- } from "./platform-fault-escalation-logic.ts";
28
- import { loadFaultEscalationConfig } from "./platform-fault-escalation/config.ts";
29
- import { probeHttpOk } from "./platform-fault-escalation/probe.ts";
30
- import { resolveHoldRaisedSince } from "./platform-fault-escalation/event-log-queries.ts";
31
- import {
32
- fetchAggregates,
33
- fetchVolumeCount,
34
- zeroVolumeFlagActive,
35
- } from "./platform-fault-escalation/event-log-queries.ts";
36
- import { orpc } from "./platform-fault-escalation/orpc-client.ts";
37
- import {
38
- clearZeroVolumeHold,
39
- deliverEscalation,
40
- deliverZeroVolumeHold,
41
- refreshOpenEpisode,
42
- rearmEpisode,
43
- resolveEpisode,
44
- } from "./platform-fault-escalation/delivery.ts";
45
-
46
- async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig }): Promise<void> {
47
- const log = getAppLogger();
48
- const config = await loadFaultEscalationConfig();
49
- const now = new Date();
50
-
51
- const aggregates = await fetchAggregates(config.watched_kind_patterns, config.window_minutes);
52
- // Only OPEN episodes matter: a resolved episode and no episode at all have
53
- // the same meaning to decideSweep (a recurring sustained fault opens a NEW
54
- // episode and escalates again).
55
- const openEpisodes = (await (await orpc()).platform_fault_escalations.findMany({
56
- where: { resolved_at: null },
57
- })) as Array<{
58
- kind: string;
59
- first_seen_at: string | Date;
60
- last_seen_at: string | Date;
61
- event_count: number;
62
- escalated_at: string | Date | null;
63
- work_item_id: string | null;
64
- resolved_at: string | Date | null;
65
- rearmed_at: string | Date | null;
66
- last_updated_at: string | Date | null;
67
- }>;
68
- const episodes: EscalationEpisode[] = openEpisodes.map((row) => ({
69
- kind: row.kind,
70
- firstSeenAt: new Date(row.first_seen_at),
71
- lastSeenAt: new Date(row.last_seen_at),
72
- eventCount: row.event_count,
73
- escalatedAt: row.escalated_at ? new Date(row.escalated_at) : null,
74
- workItemId: row.work_item_id,
75
- resolvedAt: row.resolved_at ? new Date(row.resolved_at) : null,
76
- rearmedAt: row.rearmed_at ? new Date(row.rearmed_at) : null,
77
- lastUpdatedAt: row.last_updated_at ? new Date(row.last_updated_at) : null,
78
- }));
79
-
80
- const probeSources = config.probe_sources ?? {};
81
- const kindThresholds: Record<string, KindThresholdOverride> = {};
82
- for (const [kind, override] of Object.entries(config.kind_thresholds ?? {})) {
83
- kindThresholds[kind] = {
84
- minCount: override?.min_count,
85
- minSpanMinutes: override?.min_span_minutes,
86
- };
87
- }
88
-
89
- // ZERO-VOLUME INPUTS: per configured kind, resolve its source's window
90
- // volume and standing-flag state so decideSweep can tell quiet apart
91
- // from dead-producer. Computed here, pure decision stays in logic.
92
- const volumeSources = config.volume_sources ?? {};
93
- const zeroVolume: ZeroVolumeInputs = { sources: volumeSources, countByKind: {}, flagActiveByKind: {} };
94
- for (const [kind, source] of Object.entries(volumeSources)) {
95
- try {
96
- zeroVolume.countByKind[kind] = await fetchVolumeCount(source.volume_kind_pattern, config.window_minutes);
97
- } catch (err) {
98
- log.error(
99
- `[platform-fault-escalation] volume count for '${kind}' (~${source.volume_kind_pattern}) failed (${err instanceof Error ? err.message : String(err)}) — treating as unmeasured for this sweep`,
100
- );
101
- }
102
- }
103
-
104
- const decision = decideSweep(
105
- aggregates,
106
- episodes,
107
- {
108
- minCount: config.min_count,
109
- minSpanMinutes: config.min_span_minutes,
110
- resolveAfterMinutes: config.resolve_after_minutes,
111
- },
112
- now,
113
- kindThresholds,
114
- Object.keys(volumeSources).length > 0 ? zeroVolume : undefined,
115
- probeSources,
116
- );
117
-
118
- // Per-kind isolation: one fault's delivery failure must never block the
119
- // others. Failures are logged + emitted so the escalator itself is
120
- // observable (the sweep that silently stops sweeping is the original bug).
121
- for (const { aggregate } of decision.escalate) {
122
- try {
123
- const delivered = await deliverEscalation(aggregate.kind, aggregate, config);
124
- log.warn(
125
- `[platform-fault-escalation] ESCALATED '${aggregate.kind}' — ${delivered.messageId ? `WhatsApp message ${delivered.messageId}` : "work item only (agent-handled kind, operator not paged)"}, work item ${delivered.workItemId}`,
126
- );
127
- } catch (err) {
128
- const reason = err instanceof Error ? err.message : String(err);
129
- log.error(`[platform-fault-escalation] delivery for '${aggregate.kind}' failed (claim retained, retried next sweep): ${reason}`);
130
- recordEvent({ kind: "platform.fault.escalation_failed", payload: { event_kind: aggregate.kind, reason } });
131
- }
132
- }
133
- // AN OPEN EPISODE KEEPS ITS FACTS CURRENT. Escalation is a TRANSITION and
134
- // fires once (`emit-transitions-not-conditions`) — but the ROW is the
135
- // durable record of the condition, and a record that stops updating starts
136
- // lying. Refreshing is not emitting: nothing is delivered here, no comment
137
- // is written, no message is sent. Only the stored window facts move.
138
- const aggregateByKind = new Map(aggregates.map((aggregate) => [aggregate.kind, aggregate]));
139
- for (const episode of episodes) {
140
- const aggregate = aggregateByKind.get(episode.kind);
141
- if (!episodeNeedsRefresh(episode, aggregate) || !aggregate) continue;
142
- try {
143
- await refreshOpenEpisode(episode.kind, aggregate);
144
- } catch (err) {
145
- log.error(
146
- `[platform-fault-escalation] refresh for '${episode.kind}' failed (facts stay stale for this sweep, retried next): ${err instanceof Error ? err.message : String(err)}`,
147
- );
148
- }
149
- }
150
-
151
- for (const { episode, quietMinutes } of decision.zeroVolumeHold) {
152
- const source = volumeSources[episode.kind];
153
- if (!source) continue;
154
- try {
155
- if (await zeroVolumeFlagActive(episode.kind)) continue; // hold already raised once
156
- await deliverZeroVolumeHold(
157
- episode,
158
- quietMinutes,
159
- zeroVolume.countByKind[episode.kind] ?? 0,
160
- source,
161
- );
162
- log.warn(
163
- `[platform-fault-escalation] ZERO-VOLUME HOLD raised for '${episode.kind}' — failures quiet AND producer quiet; episode held open`,
164
- );
165
- } catch (err) {
166
- log.error(`[platform-fault-escalation] zero-volume hold for '${episode.kind}' failed (retried next sweep): ${err instanceof Error ? err.message : String(err)}`);
167
- }
168
- }
169
- // PROBE-GATED RESOLUTION (operator ruling 2026-08-28): the recovery
170
- // transition fires only when the subject's capability probe answers OK.
171
- // An unreachable probe HOLDS the episode open — the close-refusal is
172
- // raised ONCE per episode state as a durable event, never an alarm: the
173
- // original verdict (failures went quiet) was correct; it lacked the
174
- // probe behind the verdict.
175
- let resolveProbeHeld = 0;
176
- for (const { episode, quietMinutes, clearZeroVolumeFlag, probeUrl, probeTimeoutSeconds } of decision.resolveProbeGate) {
177
- try {
178
- if (await probeHttpOk(probeUrl, probeTimeoutSeconds)) {
179
- await resolveEpisode(episode, quietMinutes);
180
- if (clearZeroVolumeFlag) await clearZeroVolumeHold(episode, zeroVolume.countByKind[episode.kind] ?? 0);
181
- log.info(`[platform-fault-escalation] RESOLVED '${episode.kind}' — capability probe ${probeUrl} answered OK after ${quietMinutes} quiet min`);
182
- } else {
183
- resolveProbeHeld += 1;
184
- if (await resolveHoldRaisedSince(episode.kind, episode.lastUpdatedAt ?? episode.firstSeenAt)) continue;
185
- recordEvent({
186
- kind: "platform.fault.resolve_held",
187
- payload: {
188
- event_kind: episode.kind,
189
- probe: probeUrl,
190
- quiet_minutes: quietMinutes,
191
- work_item_id: episode.workItemId,
192
- },
193
- });
194
- log.warn(
195
- `[platform-fault-escalation] resolve HELD for '${episode.kind}' — capability probe ${probeUrl} did not answer OK; episode stays open (raised once, not an alarm)`,
196
- );
197
- }
198
- } catch (err) {
199
- log.error(`[platform-fault-escalation] probe-gated resolve for '${episode.kind}' failed (episode stays open; retried next sweep): ${err instanceof Error ? err.message : String(err)}`);
200
- }
201
- }
202
- for (const { episode, quietMinutes, clearZeroVolumeFlag } of decision.resolve) {
203
- try {
204
- await resolveEpisode(episode, quietMinutes);
205
- if (clearZeroVolumeFlag) await clearZeroVolumeHold(episode, zeroVolume.countByKind[episode.kind] ?? 0);
206
- } catch (err) {
207
- log.error(`[platform-fault-escalation] resolve for '${episode.kind}' failed: ${err instanceof Error ? err.message : String(err)}`);
208
- }
209
- }
210
- // RE-ARM, NOT RESOLVE (2026-08-29). A kind with no declared capability
211
- // probe went quiet: the alarm is armed for the next occurrence and NOTHING
212
- // is claimed about the condition. No operator comment is written here on
213
- // purpose — see rearmEpisode.
214
- for (const { episode, quietMinutes, clearZeroVolumeFlag } of decision.rearm) {
215
- try {
216
- await rearmEpisode(episode, quietMinutes);
217
- if (clearZeroVolumeFlag) await clearZeroVolumeHold(episode, zeroVolume.countByKind[episode.kind] ?? 0);
218
- log.info(`[platform-fault-escalation] RE-ARMED '${episode.kind}' after ${Math.round(quietMinutes)} quiet min — no probe declared, so the condition is UNVERIFIED and no close was claimed`);
219
- } catch (err) {
220
- log.error(`[platform-fault-escalation] re-arm for '${episode.kind}' failed (retried next sweep): ${err instanceof Error ? err.message : String(err)}`);
221
- }
222
- }
223
-
224
- // Emitted every run, even when nothing fired — a sweep that only reports
225
- // when it escalated is indistinguishable from one that stopped running.
226
- recordEvent({
227
- kind: "platform.fault.swept",
228
- payload: {
229
- watched_kinds: aggregates.length,
230
- escalated: decision.escalate.length,
231
- resolved: decision.resolve.length + (decision.resolveProbeGate.length - resolveProbeHeld),
232
- resolve_probe_held: resolveProbeHeld,
233
- zero_volume_holds: decision.zeroVolumeHold.length,
234
- },
235
- });
236
- if (decision.escalate.length > 0 || decision.resolve.length > 0 || decision.zeroVolumeHold.length > 0) {
237
- log.info(
238
- `[platform-fault-escalation] sweep — ${aggregates.length} kind(s) watched, ${decision.escalate.length} escalated, ${decision.resolve.length} resolved`,
239
- );
240
- }
241
- }
242
-
243
- pgBossHandlerRegistry.register(
244
- "@teamscala/service-boot:platform-fault-escalation",
245
- platformFaultEscalationSweep as (ctx: { config: ScheduledJobConfig }) => Promise<void>,
246
- );
@@ -1,198 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `projects-daily-digest` / `projects-weekly-digest` pg-boss handlers for
7
- * scala-projects. Fixed-query shape: no row-driven
8
- * SQL, every query is a literal Prisma call against work_items; the
9
- * scheduled_job row only carries the cron.
10
- *
11
- * Daily digest: per assignee, tasks assigned to them created in the last 24h.
12
- * Weekly digest: per assignee, their overdue tasks + tasks due in the next 7
13
- * days. Both group work_items by assignee_id so one org-wide query covers
14
- * every member, then send one email per assignee via the
15
- * projects-daily-digest / projects-weekly-digest email_template rows.
16
- *
17
- * Registered under `@teamscala/service-boot:projects-daily-digest` and
18
- * `@teamscala/service-boot:projects-weekly-digest`.
19
- */
20
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
21
- import { escapeHtml } from "@teamscala/formatters/escapeHtml";
22
- import { getPrisma } from "@teamscala/db/prisma-registry";
23
- import { createEmailClient } from "@teamscala/email-client/client";
24
- import { getAppLogger } from "@teamscala/logger/app-loggers";
25
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
26
- import { resolveAppBaseUrl } from "../resolve-app-base-url.ts";
27
-
28
- const emailClient = createEmailClient();
29
-
30
- interface DigestTask {
31
- id: string;
32
- title: string;
33
- organisation_id: string;
34
- assignee_id: string | null;
35
- }
36
-
37
- type PrismaClient = ReturnType<typeof getPrisma>;
38
-
39
- function taskLink(appUrl: string, task: DigestTask): string {
40
- return `${appUrl}/${task.organisation_id}?panel=task-comments&comment=${task.id}`;
41
- }
42
-
43
- function renderListHtml(tasks: DigestTask[], appUrl: string): string {
44
- if (tasks.length === 0) return "<p><em>None</em></p>";
45
- const items = tasks.map((t) => `<li><a href="${taskLink(appUrl, t)}">${escapeHtml(t.title)}</a></li>`).join("");
46
- return `<ul>${items}</ul>`;
47
- }
48
-
49
- function renderListText(tasks: DigestTask[]): string {
50
- if (tasks.length === 0) return "None";
51
- return tasks.map((t) => `- ${t.title}`).join("\n");
52
- }
53
-
54
- function groupByAssignee(tasks: DigestTask[]): Map<string, DigestTask[]> {
55
- const map = new Map<string, DigestTask[]>();
56
- for (const t of tasks) {
57
- if (!t.assignee_id) continue;
58
- const list = map.get(t.assignee_id) ?? [];
59
- list.push(t);
60
- map.set(t.assignee_id, list);
61
- }
62
- return map;
63
- }
64
-
65
- async function loadOrgNames(prisma: PrismaClient, orgIds: string[]): Promise<Map<string, string>> {
66
- if (orgIds.length === 0) return new Map();
67
- const rows = await prisma.organisation_profile.findMany({
68
- where: { id: { in: orgIds } },
69
- select: { id: true, company_name: true },
70
- });
71
- return new Map(rows.map((r: { id: string; company_name: string | null }) => [r.id, r.company_name || "your organisation"]));
72
- }
73
-
74
- async function loadUsers(
75
- prisma: PrismaClient,
76
- userIds: string[],
77
- ): Promise<Map<string, { email: string; name: string }>> {
78
- if (userIds.length === 0) return new Map();
79
- const rows = await prisma.user.findMany({
80
- where: { id: { in: userIds } },
81
- select: { id: true, email: true, name: true },
82
- });
83
- return new Map(rows.map((r: { id: string; email: string; name: string }) => [r.id, { email: r.email, name: r.name }]));
84
- }
85
-
86
- export async function sendDailyDigest(_ctx: { config: ScheduledJobConfig }): Promise<void> {
87
- const log = getAppLogger();
88
- const prisma = getPrisma();
89
- const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
90
- const tasks = (await prisma.work_items.findMany({
91
- where: { kind: "task", created_at: { gte: since }, assignee_id: { not: null } },
92
- select: { id: true, title: true, organisation_id: true, assignee_id: true },
93
- })) as DigestTask[];
94
-
95
- const byAssignee = groupByAssignee(tasks);
96
- if (byAssignee.size === 0) {
97
- log.info("[projects-daily-digest] no new tasks in the last 24h — nothing to send");
98
- return;
99
- }
100
-
101
- const orgNames = await loadOrgNames(prisma, [...new Set(tasks.map((t) => t.organisation_id))]);
102
- const users = await loadUsers(prisma, [...byAssignee.keys()]);
103
- const appUrl = await resolveAppBaseUrl();
104
-
105
- let sent = 0;
106
- for (const [userId, userTasks] of byAssignee) {
107
- const user = users.get(userId);
108
- if (!user?.email) continue;
109
- const orgId = userTasks[0]!.organisation_id;
110
- try {
111
- const result = await emailClient.send({
112
- to: user.email,
113
- template: "projects-daily-digest",
114
- vars: {
115
- recipient_name: user.name || user.email,
116
- org_name: orgNames.get(orgId) ?? "your organisation",
117
- new_task_count: String(userTasks.length),
118
- task_list_html: renderListHtml(userTasks, appUrl),
119
- task_list_text: renderListText(userTasks),
120
- app_url: `${appUrl}/${orgId}`,
121
- },
122
- });
123
- if (result.status !== "failed") sent++;
124
- } catch (err) {
125
- log.error("[projects-daily-digest] send failed", { userId, error: err instanceof Error ? err.message : String(err) });
126
- }
127
- }
128
- log.info(`[projects-daily-digest] complete — ${sent}/${byAssignee.size} digest(s) sent`);
129
- }
130
-
131
- export async function sendWeeklyDigest(_ctx: { config: ScheduledJobConfig }): Promise<void> {
132
- const log = getAppLogger();
133
- const prisma = getPrisma();
134
- const now = new Date();
135
- const weekAhead = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
136
-
137
- const [overdue, upcoming] = await Promise.all([
138
- prisma.work_items.findMany({
139
- where: { kind: "task", completed: { not: true }, assignee_id: { not: null }, due_date: { lt: now } },
140
- select: { id: true, title: true, organisation_id: true, assignee_id: true },
141
- }) as Promise<DigestTask[]>,
142
- prisma.work_items.findMany({
143
- where: {
144
- kind: "task",
145
- completed: { not: true },
146
- assignee_id: { not: null },
147
- due_date: { gte: now, lte: weekAhead },
148
- },
149
- select: { id: true, title: true, organisation_id: true, assignee_id: true },
150
- }) as Promise<DigestTask[]>,
151
- ]);
152
-
153
- const overdueByAssignee = groupByAssignee(overdue);
154
- const upcomingByAssignee = groupByAssignee(upcoming);
155
- const assigneeIds = new Set([...overdueByAssignee.keys(), ...upcomingByAssignee.keys()]);
156
- if (assigneeIds.size === 0) {
157
- log.info("[projects-weekly-digest] nothing overdue or due this week — nothing to send");
158
- return;
159
- }
160
-
161
- const allTasks = [...overdue, ...upcoming];
162
- const orgNames = await loadOrgNames(prisma, [...new Set(allTasks.map((t) => t.organisation_id))]);
163
- const users = await loadUsers(prisma, [...assigneeIds]);
164
- const appUrl = await resolveAppBaseUrl();
165
-
166
- let sent = 0;
167
- for (const userId of assigneeIds) {
168
- const user = users.get(userId);
169
- if (!user?.email) continue;
170
- const userOverdue = overdueByAssignee.get(userId) ?? [];
171
- const userUpcoming = upcomingByAssignee.get(userId) ?? [];
172
- const orgId = (userOverdue[0] ?? userUpcoming[0])!.organisation_id;
173
- try {
174
- const result = await emailClient.send({
175
- to: user.email,
176
- template: "projects-weekly-digest",
177
- vars: {
178
- recipient_name: user.name || user.email,
179
- org_name: orgNames.get(orgId) ?? "your organisation",
180
- overdue_count: String(userOverdue.length),
181
- upcoming_count: String(userUpcoming.length),
182
- overdue_list_html: renderListHtml(userOverdue, appUrl),
183
- overdue_list_text: renderListText(userOverdue),
184
- upcoming_list_html: renderListHtml(userUpcoming, appUrl),
185
- upcoming_list_text: renderListText(userUpcoming),
186
- app_url: `${appUrl}/${orgId}`,
187
- },
188
- });
189
- if (result.status !== "failed") sent++;
190
- } catch (err) {
191
- log.error("[projects-weekly-digest] send failed", { userId, error: err instanceof Error ? err.message : String(err) });
192
- }
193
- }
194
- log.info(`[projects-weekly-digest] complete — ${sent}/${assigneeIds.size} digest(s) sent`);
195
- }
196
-
197
- pgBossHandlerRegistry.register("@teamscala/service-boot:projects-daily-digest", sendDailyDigest);
198
- pgBossHandlerRegistry.register("@teamscala/service-boot:projects-weekly-digest", sendWeeklyDigest);
@@ -1,178 +0,0 @@
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);