@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `bun-stable-watch` pg-boss handler — the durable trigger for the
|
|
7
|
+
* bun-1.4 lock-resolution escape hatch's atomic flip-back (open-migration
|
|
8
|
+
* `bun-14-lock-resolution-pin`). Polls the public npm `bun` `latest`
|
|
9
|
+
* dist-tag once daily; when a STABLE 1.4.x (≥1.4.0, no prerelease) ships,
|
|
10
|
+
* records a detection event so the escape hatch can be retired via the atomic
|
|
11
|
+
* flip-back: `config/scala-tools-deps.lockResolutionBun` → "bun" + the ~18
|
|
12
|
+
* `railpack_config` rows → 1.4.0 stable + delete the pinned
|
|
13
|
+
* `/usr/local/bin/bun-1.3.14` binary + a green deploy (all one change). The
|
|
14
|
+
* flip-back itself is a deliberate action, not auto-fired — it mutates config
|
|
15
|
+
* + railpack + a binary + a deploy.
|
|
16
|
+
*
|
|
17
|
+
* `bun/latest` returns the stable release (canaries live under the `canary`
|
|
18
|
+
* dist-tag, not `latest`), so a non-prerelease version ≥1.4.0 means stable
|
|
19
|
+
* shipped. A daily `bun.watch.tick` liveness event lets a watchdog detect a
|
|
20
|
+
* silently-stopped watch (the same shape as `sweep.completed`).
|
|
21
|
+
*
|
|
22
|
+
* Registered under slug `@teamscala/service-boot:bun-stable-watch`.
|
|
23
|
+
*/
|
|
24
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
25
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
26
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
28
|
+
|
|
29
|
+
const BUN_NPM_LATEST = "https://registry.npmjs.org/bun/latest";
|
|
30
|
+
const TARGET_STABLE = "1.4.0"; // flip back when a stable at or above this ships
|
|
31
|
+
|
|
32
|
+
interface BunLatestManifest {
|
|
33
|
+
version: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseSemver(
|
|
37
|
+
v: string,
|
|
38
|
+
): { major: number; minor: number; patch: number; prerelease: string } | null {
|
|
39
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease: m[4] ?? "" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A stable (non-prerelease) release at or above the target version. */
|
|
45
|
+
function isStableAtOrAbove(version: string, target: string): boolean {
|
|
46
|
+
const v = parseSemver(version);
|
|
47
|
+
const t = parseSemver(target);
|
|
48
|
+
if (!v || !t) return false;
|
|
49
|
+
if (v.prerelease) return false; // canary/beta/rc are not stable
|
|
50
|
+
if (v.major !== t.major) return v.major > t.major;
|
|
51
|
+
if (v.minor !== t.minor) return v.minor > t.minor;
|
|
52
|
+
return v.patch >= t.patch;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
56
|
+
const log = getAppLogger();
|
|
57
|
+
let manifest: BunLatestManifest;
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(BUN_NPM_LATEST, { headers: { accept: "application/json" } });
|
|
60
|
+
if (!res.ok) throw new Error(`npm bun/latest HTTP ${res.status}`);
|
|
61
|
+
manifest = (await res.json()) as BunLatestManifest;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log.warn(`[bun-stable-watch] failed to fetch npm bun/latest: ${err}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (isStableAtOrAbove(manifest.version, TARGET_STABLE)) {
|
|
67
|
+
log.warn(
|
|
68
|
+
`[bun-stable-watch] Bun stable ${manifest.version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
|
|
69
|
+
);
|
|
70
|
+
recordEvent({
|
|
71
|
+
kind: "bun.stable.shipped",
|
|
72
|
+
payload: {
|
|
73
|
+
latest: manifest.version,
|
|
74
|
+
targetStable: TARGET_STABLE,
|
|
75
|
+
note: "escape hatch bun-14-lock-resolution-pin ready to retire; verify railpack 1.4.0 support then run the atomic flip-back",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
log.info(
|
|
81
|
+
`[bun-stable-watch] npm bun/latest = ${manifest.version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
|
|
82
|
+
);
|
|
83
|
+
recordEvent({
|
|
84
|
+
kind: "bun.watch.tick",
|
|
85
|
+
payload: { latest: manifest.version, targetStable: TARGET_STABLE },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pgBossHandlerRegistry.register("@teamscala/service-boot:bun-stable-watch", bunStableWatch);
|
|
@@ -0,0 +1,198 @@
|
|
|
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. Same fixed-query shape as prune-stale-data: 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);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `db-prune-stale-data` pg-boss handler. Reads the `retention` map from the
|
|
7
|
+
* `scheduled_job` row config and prunes stale rows per entry.
|
|
8
|
+
*
|
|
9
|
+
* Writes use Prisma ORM methods (deleteMany/updateMany) via the prisma-registry
|
|
10
|
+
* client — NEVER raw SQL. The `@teamscala/db` client carries the `blockRawSqlWrites`
|
|
11
|
+
* audit extension: `$executeRaw`/`$executeRawUnsafe` are blocked so every write
|
|
12
|
+
* is audited; ORM methods are the canonical audited path.
|
|
13
|
+
*
|
|
14
|
+
* SAFETY — identifier allowlist: each retention key selects a FIXED, hardcoded
|
|
15
|
+
* Prisma model + field (table/field names are literal in code, never derived
|
|
16
|
+
* from the row); the row's value (a duration string) is parsed to a cutoff Date.
|
|
17
|
+
* A row can therefore never inject arbitrary SQL or address an unvetted model.
|
|
18
|
+
* Keys not in the allowlist are skipped + logged.
|
|
19
|
+
*
|
|
20
|
+
* `audit_trail.*` is INTENTIONALLY EXCLUDED: `audit-trail-permanent` (critical)
|
|
21
|
+
* forbids any DELETE FROM audit_trail. The legacy row's
|
|
22
|
+
* `audit_trail.sync_entities: "delete all"` entry is stale drift from before
|
|
23
|
+
* the 2026-06-19 hardening — surfaced (skipped + warned), never executed.
|
|
24
|
+
*
|
|
25
|
+
* Registered under slug `@teamscala/service-boot:prune-stale-data`.
|
|
26
|
+
*/
|
|
27
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
28
|
+
import { Prisma } from "@teamscala/db/client";
|
|
29
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
30
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
31
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
32
|
+
|
|
33
|
+
const DURATION_RE = /^(\d+)\s+(second|minute|hour|day|week|month|year)s?$/;
|
|
34
|
+
const UNIT_TO_MS: Record<string, number> = {
|
|
35
|
+
second: 1_000,
|
|
36
|
+
minute: 60_000,
|
|
37
|
+
hour: 3_600_000,
|
|
38
|
+
day: 86_400_000,
|
|
39
|
+
week: 604_800_000,
|
|
40
|
+
month: 2_592_000_000,
|
|
41
|
+
year: 31_536_000_000,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type PrismaClient = ReturnType<typeof getPrisma>;
|
|
45
|
+
type PruneOp = (prisma: PrismaClient, cutoff: Date) => Promise<number>;
|
|
46
|
+
|
|
47
|
+
// Fixed ops — model + field names literal in code, cutoff Date is the only
|
|
48
|
+
// variable. Adding a prunable target = adding an entry here (and confirming the
|
|
49
|
+
// model/field + audit-trail-permanent scope).
|
|
50
|
+
const PRUNE_OPS: Record<string, PruneOp> = {
|
|
51
|
+
"fleet_run_events": async (prisma, cutoff) =>
|
|
52
|
+
(await prisma.fleet_run_events.deleteMany({ where: { created_at: { lt: cutoff } } })).count,
|
|
53
|
+
"fleet_run_transitions": async (prisma, cutoff) =>
|
|
54
|
+
(await prisma.fleet_run_transitions.deleteMany({ where: { created_at: { lt: cutoff } } })).count,
|
|
55
|
+
// Null out the bulky transcript JSON of old runs — keep the run row itself.
|
|
56
|
+
// A nullable-Json column requires Prisma's DbNull sentinel (bare `null` is
|
|
57
|
+
// ambiguous: JSON null vs SQL NULL); DbNull sets the column to SQL NULL.
|
|
58
|
+
// Prisma namespace is re-exported from @teamscala/db/client.
|
|
59
|
+
"fleet_agent_runs.transcript": async (prisma, cutoff) =>
|
|
60
|
+
(
|
|
61
|
+
await prisma.fleet_agent_runs.updateMany({
|
|
62
|
+
where: { created_at: { lt: cutoff } },
|
|
63
|
+
data: { transcript: Prisma.DbNull },
|
|
64
|
+
})
|
|
65
|
+
).count,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export async function pruneStaleData({
|
|
69
|
+
config,
|
|
70
|
+
}: {
|
|
71
|
+
config: ScheduledJobConfig;
|
|
72
|
+
}): Promise<void> {
|
|
73
|
+
const log = getAppLogger();
|
|
74
|
+
const retention = config.retention;
|
|
75
|
+
if (!retention || Object.keys(retention).length === 0) {
|
|
76
|
+
log.info("[db-prune-stale-data] no retention entries — nothing to prune");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const prisma = getPrisma();
|
|
80
|
+
let ran = 0;
|
|
81
|
+
let skipped = 0;
|
|
82
|
+
for (const [key, rawValue] of Object.entries(retention)) {
|
|
83
|
+
const op = PRUNE_OPS[key];
|
|
84
|
+
if (!op) {
|
|
85
|
+
log.warn(
|
|
86
|
+
`[db-prune-stale-data] skipping "${key}" — not in the prune allowlist ` +
|
|
87
|
+
`(audit_trail is excluded by audit-trail-permanent; add the op explicitly to enable)`,
|
|
88
|
+
);
|
|
89
|
+
skipped++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const match = DURATION_RE.exec(rawValue.trim());
|
|
93
|
+
if (!match) {
|
|
94
|
+
log.warn(
|
|
95
|
+
`[db-prune-stale-data] skipping "${key}" — value "${rawValue}" is not a duration (expected e.g. "1 day")`,
|
|
96
|
+
);
|
|
97
|
+
skipped++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const cutoff = new Date(Date.now() - Number(match[1]) * UNIT_TO_MS[match[2]!]);
|
|
101
|
+
const affected = await op(prisma, cutoff);
|
|
102
|
+
log.info(
|
|
103
|
+
`[db-prune-stale-data] pruned "${key}" older than ${rawValue} — ${affected} row(s) affected`,
|
|
104
|
+
);
|
|
105
|
+
ran++;
|
|
106
|
+
}
|
|
107
|
+
log.info(`[db-prune-stale-data] complete — ${ran} op(s) ran, ${skipped} skipped`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pgBossHandlerRegistry.register("@teamscala/service-boot:prune-stale-data", pruneStaleData);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `registry-stale-inactive-sweep` pg-boss handler. Surfaces registry
|
|
7
|
+
* rows that have been inactive beyond a threshold with no deliberate-pause
|
|
8
|
+
* metadata — the "deactivated and forgotten" class that caused the
|
|
9
|
+
* fleet_agent_slots miscount (operator directive 2026-07-20).
|
|
10
|
+
*
|
|
11
|
+
* This is a MONITOR (detection), not enforcement: it records an event_log
|
|
12
|
+
* entry so the operator sees stale rows accumulate, but never blocks or
|
|
13
|
+
* auto-reaps. The structural prevention is the `deactivated_at` stamp the
|
|
14
|
+
* registry_edit deactivate path writes; this sweep is the detection
|
|
15
|
+
* backstop that surfaces rows whose `updated_at` (the deactivation
|
|
16
|
+
* timestamp) has aged past the threshold.
|
|
17
|
+
*
|
|
18
|
+
* Excluded from the sweep (deliberate deactivation — NOT stale):
|
|
19
|
+
* - config.pause_owner / config.reactivate_when (progressive-activation paused guards)
|
|
20
|
+
* - config.operator_deactivated = true (operator emergency off)
|
|
21
|
+
*
|
|
22
|
+
* Registered under slug `@teamscala/service-boot:registry-stale-inactive-sweep`.
|
|
23
|
+
*/
|
|
24
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
25
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
26
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
27
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
28
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
29
|
+
|
|
30
|
+
const STALE_AFTER_DAYS = 30;
|
|
31
|
+
const SAMPLE_LIMIT = 20;
|
|
32
|
+
|
|
33
|
+
interface StaleRow {
|
|
34
|
+
type: string;
|
|
35
|
+
slug: string;
|
|
36
|
+
updated_at: Date;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function registryStaleInactiveSweep(): Promise<void> {
|
|
40
|
+
const log = getAppLogger();
|
|
41
|
+
const prisma = getPrisma();
|
|
42
|
+
|
|
43
|
+
const rows = await prisma.$queryRawUnsafe<StaleRow[]>(`
|
|
44
|
+
SELECT type, slug, updated_at
|
|
45
|
+
FROM registry_entries
|
|
46
|
+
WHERE is_active = false
|
|
47
|
+
AND updated_at < NOW() - INTERVAL '${STALE_AFTER_DAYS} days'
|
|
48
|
+
AND NOT (config ? 'pause_owner')
|
|
49
|
+
AND NOT (config ? 'reactivate_when')
|
|
50
|
+
AND NOT (config->>'operator_deactivated' = 'true')
|
|
51
|
+
ORDER BY updated_at ASC
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
const byType = new Map<string, number>();
|
|
55
|
+
for (const r of rows) {
|
|
56
|
+
byType.set(r.type, (byType.get(r.type) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const samples = rows
|
|
60
|
+
.slice(0, SAMPLE_LIMIT)
|
|
61
|
+
.map((r) => `${r.type}/${r.slug}(${r.updated_at.toISOString().slice(0, 10)})`);
|
|
62
|
+
|
|
63
|
+
recordEvent({
|
|
64
|
+
kind: "registry.stale-inactive.detected",
|
|
65
|
+
payload: {
|
|
66
|
+
totalStale: rows.length,
|
|
67
|
+
staleAfterDays: STALE_AFTER_DAYS,
|
|
68
|
+
byType: Object.fromEntries(byType),
|
|
69
|
+
samples,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
log.info(
|
|
74
|
+
`[registry-stale-inactive-sweep] ${rows.length} stale inactive row(s) (>${STALE_AFTER_DAYS}d, no pause metadata) — recorded to event_log`,
|
|
75
|
+
{ byType: Object.fromEntries(byType), samples },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
pgBossHandlerRegistry.register(
|
|
80
|
+
"@teamscala/service-boot:registry-stale-inactive-sweep",
|
|
81
|
+
registryStaleInactiveSweep as (ctx: { config: ScheduledJobConfig }) => Promise<void>,
|
|
82
|
+
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression lock: this watch must never restart scala-sentinel again.
|
|
7
|
+
*
|
|
8
|
+
* It used to reinstall @teamscala/scala-tools and then `pm2 restart scala-sentinel`
|
|
9
|
+
* "so the fresh scala-tools loads". That premise is false for this package —
|
|
10
|
+
* sentinel SPAWNS `node_modules/.bin/scala-tools` per invocation rather than
|
|
11
|
+
* importing it, so the reinstall alone is what takes effect. The restart's only
|
|
12
|
+
* real consequence was taking enforcement offline: every guard-daemon shutdown
|
|
13
|
+
* logs "152 guard(s) failed to run (fail-open)", so each publish of scala-tools
|
|
14
|
+
* opened a window where tool-check, turn-end and pre-push silently did not run.
|
|
15
|
+
*
|
|
16
|
+
* The assertion is on the SOURCE rather than on a mocked run, and that is
|
|
17
|
+
* deliberate rather than a shortcut: the property being locked is the ABSENCE of
|
|
18
|
+
* a side effect. A behavioural test can only prove the restart did not fire for
|
|
19
|
+
* the paths it happens to drive, whereas the absence of any pm2-restart call in
|
|
20
|
+
* the module proves it cannot fire on any path. It also stays honest if someone
|
|
21
|
+
* reintroduces the restart behind a new condition.
|
|
22
|
+
*/
|
|
23
|
+
const SOURCE = readFileSync(
|
|
24
|
+
join(import.meta.dir, "scala-tools-currency-watch.ts"),
|
|
25
|
+
"utf-8",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** Comment prose legitimately discusses the removed restart; code must not do it. */
|
|
29
|
+
const CODE = SOURCE.split("\n")
|
|
30
|
+
.filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*"))
|
|
31
|
+
.join("\n");
|
|
32
|
+
|
|
33
|
+
describe("scala-tools-currency-watch never restarts the guard daemon", () => {
|
|
34
|
+
test("issues no pm2 restart command", () => {
|
|
35
|
+
expect(CODE).not.toContain('"restart"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("never names scala-sentinel as a spawn argument", () => {
|
|
39
|
+
// The reinstall targets the sentinel DIRECTORY (a path constant); what must
|
|
40
|
+
// not exist is scala-sentinel as a process argument to a supervisor call.
|
|
41
|
+
expect(CODE).not.toContain('"scala-sentinel"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("uses no bare pm2 invocation", () => {
|
|
45
|
+
// forbid-bare-pm2-restart: a bare `pm2` call bypasses the currency-gated
|
|
46
|
+
// canonical pipeline, and the guard blocks agents from making this exact
|
|
47
|
+
// call — the handler was doing what agents are prevented from doing.
|
|
48
|
+
expect(CODE).not.toContain('"pm2"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("still performs the reinstall, which is the watch's actual job", () => {
|
|
52
|
+
// Removing the restart must not turn this into a no-op watch.
|
|
53
|
+
expect(CODE).toContain("deps");
|
|
54
|
+
expect(CODE).toContain("install");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("still records the reinstall event for observability", () => {
|
|
58
|
+
expect(CODE).toContain("scala-tools.currency.reinstalled");
|
|
59
|
+
});
|
|
60
|
+
});
|