@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.
- package/package.json +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- 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);
|