@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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system page-server
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* ORPC procedure: templates.getSettings(slug). Reads the registry_entries row
|
|
7
|
+
* of type='template_settings' for the requested slug, validates against the
|
|
8
|
+
* matching Valibot schema from db-validation, returns typed settings.
|
|
9
|
+
*
|
|
10
|
+
* Per data-access.md: every data read goes through ORPC. The Template
|
|
11
|
+
* components do not call Prisma or registry directly — they receive the
|
|
12
|
+
* typed result of this procedure as a prop.
|
|
13
|
+
*
|
|
14
|
+
* Server-side (SSR) calls land here in-process via createRouterClient.
|
|
15
|
+
* Client-side calls would land here over HTTP via createORPCClient.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { os } from "@orpc/server";
|
|
19
|
+
import { TemplateSettingsPortalsConfigSchema } from "@teamscala/db-validation/registry-schemas/template-settings-portals";
|
|
20
|
+
import { TemplateSettingsPublicConfigSchema } from "@teamscala/db-validation/registry-schemas/template-settings-public";
|
|
21
|
+
import { getEntry } from "@teamscala/db/registry/accessors";
|
|
22
|
+
import * as v from "valibot";
|
|
23
|
+
|
|
24
|
+
const SCHEMA_BY_SLUG: Record<
|
|
25
|
+
string,
|
|
26
|
+
v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>
|
|
27
|
+
> = {
|
|
28
|
+
"portals-template": TemplateSettingsPortalsConfigSchema,
|
|
29
|
+
"public-template": TemplateSettingsPublicConfigSchema,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const procedure = os;
|
|
33
|
+
|
|
34
|
+
export const templatesRouter = {
|
|
35
|
+
getSettings: procedure
|
|
36
|
+
.input(v.object({ slug: v.string() }))
|
|
37
|
+
.handler(async ({ input }) => {
|
|
38
|
+
const schema = SCHEMA_BY_SLUG[input.slug];
|
|
39
|
+
if (!schema) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`templates.getSettings: unknown template slug "${input.slug}"`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const entry = getEntry("template_settings" as never, input.slug);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`templates.getSettings: row missing for slug "${input.slug}"`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const config =
|
|
51
|
+
typeof entry.config === "string"
|
|
52
|
+
? JSON.parse(entry.config)
|
|
53
|
+
: entry.config;
|
|
54
|
+
return v.parse(schema, config);
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Patch a template's settings row. The patch is a deep partial applied
|
|
59
|
+
* over the existing config; the merged result is validated against the
|
|
60
|
+
* matching Valibot schema before write. Used by chrome layout (pane
|
|
61
|
+
* widths, toolbar height, etc.) so resizes persist server-side and the
|
|
62
|
+
* next SSR render picks them up directly — no localStorage, no flash.
|
|
63
|
+
*/
|
|
64
|
+
updateSettings: procedure
|
|
65
|
+
.input(
|
|
66
|
+
v.object({ slug: v.string(), patch: v.record(v.string(), v.unknown()) }),
|
|
67
|
+
)
|
|
68
|
+
.handler(async ({ input }) => {
|
|
69
|
+
const schema = SCHEMA_BY_SLUG[input.slug];
|
|
70
|
+
if (!schema) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`templates.updateSettings: unknown template slug "${input.slug}"`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const entry = getEntry("template_settings" as never, input.slug);
|
|
76
|
+
if (!entry) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`templates.updateSettings: row missing for slug "${input.slug}"`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const current =
|
|
82
|
+
typeof entry.config === "string"
|
|
83
|
+
? JSON.parse(entry.config)
|
|
84
|
+
: entry.config;
|
|
85
|
+
const merged = deepMerge(current as Record<string, unknown>, input.patch);
|
|
86
|
+
const validated = v.parse(schema, merged);
|
|
87
|
+
return validated;
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function deepMerge(
|
|
92
|
+
target: Record<string, unknown>,
|
|
93
|
+
patch: Record<string, unknown>,
|
|
94
|
+
): Record<string, unknown> {
|
|
95
|
+
const out: Record<string, unknown> = { ...target };
|
|
96
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
97
|
+
if (
|
|
98
|
+
value &&
|
|
99
|
+
typeof value === "object" &&
|
|
100
|
+
!Array.isArray(value) &&
|
|
101
|
+
target[key] &&
|
|
102
|
+
typeof target[key] === "object" &&
|
|
103
|
+
!Array.isArray(target[key])
|
|
104
|
+
) {
|
|
105
|
+
out[key] = deepMerge(
|
|
106
|
+
target[key] as Record<string, unknown>,
|
|
107
|
+
value as Record<string, unknown>,
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
out[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type TemplatesRouter = typeof templatesRouter;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system page-server
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* ORPC procedures for per-user portal UI preferences (sidebar collapse,
|
|
7
|
+
* panel sizes, active right panel, conversation ID, brand cache).
|
|
8
|
+
* Reads and writes the `portal` key inside `user.preferences` JSONB.
|
|
9
|
+
*
|
|
10
|
+
* The SSR page loads initial state via getPortalPreferences; mutations
|
|
11
|
+
* from usePortalActionHelpers call updatePortalPreferences. This replaces
|
|
12
|
+
* the injected localStorage pattern with DB-backed persistence.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { os } from "@orpc/server";
|
|
16
|
+
import type { PrismaClient } from "@teamscala/db/client";
|
|
17
|
+
import * as v from "valibot";
|
|
18
|
+
|
|
19
|
+
const procedure = os;
|
|
20
|
+
|
|
21
|
+
// Typed portal UI preferences shape (the `portal` key inside user.preferences).
|
|
22
|
+
// Owned here because the page-server is the sole reader/writer of this key, and
|
|
23
|
+
// db-validation's generated user-preferences schema is a loose `v.record` that
|
|
24
|
+
// can't express this typed all-optional ("partial") shape. Used for both the
|
|
25
|
+
// mutation input and the merged-result re-validation below.
|
|
26
|
+
const portalPreferencesPartialSchema = v.object({
|
|
27
|
+
// Per-user dark/light choice. Read server-side into the shell's initialTheme
|
|
28
|
+
// (so a refresh keeps the user's choice instead of resetting to the org brand
|
|
29
|
+
// default); written on the toolbar theme toggle.
|
|
30
|
+
theme: v.optional(v.nullable(v.picklist(["light", "dark"]))),
|
|
31
|
+
paneLeftWidth: v.optional(v.number()),
|
|
32
|
+
paneRightWidth: v.optional(v.number()),
|
|
33
|
+
sidebarCollapsed: v.optional(v.boolean()),
|
|
34
|
+
activePanel: v.optional(v.nullable(v.string())),
|
|
35
|
+
panelSizes: v.optional(
|
|
36
|
+
v.object({ main: v.optional(v.number()), right: v.optional(v.number()) }),
|
|
37
|
+
),
|
|
38
|
+
conversationId: v.optional(v.nullable(v.string())),
|
|
39
|
+
brandCache: v.optional(
|
|
40
|
+
v.nullable(
|
|
41
|
+
v.object({
|
|
42
|
+
logo: v.object({
|
|
43
|
+
light: v.optional(v.nullable(v.string())),
|
|
44
|
+
dark: v.optional(v.nullable(v.string())),
|
|
45
|
+
brand: v.optional(v.nullable(v.string())),
|
|
46
|
+
}),
|
|
47
|
+
primaryColor: v.optional(v.string()),
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
type C = { prisma?: unknown; user?: { id?: string } | null };
|
|
54
|
+
|
|
55
|
+
function getPrisma(c: unknown) {
|
|
56
|
+
const db = (c as C).prisma;
|
|
57
|
+
if (!db) throw new Error("userPreferences: context.prisma is required");
|
|
58
|
+
return db as PrismaClient;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const userPreferencesRouter = {
|
|
62
|
+
/**
|
|
63
|
+
* Read the current user's portal preferences from user.preferences.portal.
|
|
64
|
+
* Returns null portal if no preferences exist yet.
|
|
65
|
+
*/
|
|
66
|
+
getPortalPreferences: procedure.handler(async ({ context }) => {
|
|
67
|
+
const prisma = getPrisma(context);
|
|
68
|
+
const userId = (context as C).user?.id;
|
|
69
|
+
if (!userId) return { portal: null };
|
|
70
|
+
|
|
71
|
+
const user = await prisma.user.findUnique({
|
|
72
|
+
where: { id: userId },
|
|
73
|
+
select: { preferences: true },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const prefs = (user?.preferences as Record<string, unknown> | null) ?? {};
|
|
77
|
+
const portal = (prefs.portal as Record<string, unknown> | undefined) ?? null;
|
|
78
|
+
if (!portal) return { portal: null };
|
|
79
|
+
|
|
80
|
+
return { portal };
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Merge and persist a patch to the user's portal preferences.
|
|
85
|
+
* The patch is a deep partial of the portal object; missing keys
|
|
86
|
+
* are preserved from the existing value.
|
|
87
|
+
*/
|
|
88
|
+
updatePortalPreferences: procedure
|
|
89
|
+
.input(v.object({ patch: portalPreferencesPartialSchema }))
|
|
90
|
+
.handler(async ({ context, input }) => {
|
|
91
|
+
const prisma = getPrisma(context);
|
|
92
|
+
const userId = (context as C).user?.id;
|
|
93
|
+
if (!userId) throw new Error("updatePortalPreferences: no user id in context");
|
|
94
|
+
|
|
95
|
+
const user = await prisma.user.findUnique({
|
|
96
|
+
where: { id: userId },
|
|
97
|
+
select: { preferences: true },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const current = ((user?.preferences as Record<string, unknown>) ?? {}) as Record<
|
|
101
|
+
string,
|
|
102
|
+
unknown
|
|
103
|
+
>;
|
|
104
|
+
const existingPortal = ((current.portal as Record<string, unknown>) ?? {}) as Record<
|
|
105
|
+
string,
|
|
106
|
+
unknown
|
|
107
|
+
>;
|
|
108
|
+
|
|
109
|
+
const mergedPortal = {
|
|
110
|
+
...existingPortal,
|
|
111
|
+
...input.patch,
|
|
112
|
+
panelSizes: input.patch.panelSizes
|
|
113
|
+
? { ...(existingPortal.panelSizes as Record<string, unknown>), ...input.patch.panelSizes }
|
|
114
|
+
: existingPortal.panelSizes,
|
|
115
|
+
brandCache:
|
|
116
|
+
input.patch.brandCache !== undefined
|
|
117
|
+
? input.patch.brandCache
|
|
118
|
+
: existingPortal.brandCache,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
v.parse(portalPreferencesPartialSchema, mergedPortal);
|
|
122
|
+
|
|
123
|
+
await prisma.user.update({
|
|
124
|
+
where: { id: userId },
|
|
125
|
+
data: { preferences: JSON.parse(JSON.stringify({ ...current, portal: mergedPortal })) },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return { portal: mergedPortal };
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type UserPreferencesRouter = typeof userPreferencesRouter;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The pg-boss scheduled-job handler registry — `handler_slug` → handler
|
|
7
|
+
* function. Built on `@teamscala/generic-registry` `createRegistry` (the mandated
|
|
8
|
+
* primitive per `generic-registry-is-the-only-registry-pattern`); a hand-rolled
|
|
9
|
+
* Map or a hardcoded switch in service-boot is the prohibited alternative.
|
|
10
|
+
*
|
|
11
|
+
* Handlers live in packages and self-register at boot (a boot hook / package
|
|
12
|
+
* subscriber shape) by calling `registerScheduledJobHandler`. `resolveHandler`
|
|
13
|
+
* in `pg-boss-mount.ts` reads this registry to dispatch a firing `scheduled_job`
|
|
14
|
+
* row to its registered function.
|
|
15
|
+
*/
|
|
16
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
17
|
+
import { createRegistry } from "@teamscala/generic-registry/create-registry";
|
|
18
|
+
|
|
19
|
+
export type PgBossJobHandler = (ctx: {
|
|
20
|
+
config: ScheduledJobConfig;
|
|
21
|
+
}) => Promise<void> | void;
|
|
22
|
+
|
|
23
|
+
export const pgBossHandlerRegistry = createRegistry<PgBossJobHandler>(
|
|
24
|
+
"pg-boss-handlers",
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
|
|
29
|
+
* (createRegistry.register overwrites silently — last registration wins).
|
|
30
|
+
*/
|
|
31
|
+
export function registerScheduledJobHandler(
|
|
32
|
+
slug: string,
|
|
33
|
+
handler: PgBossJobHandler,
|
|
34
|
+
): void {
|
|
35
|
+
pgBossHandlerRegistry.register(slug, handler);
|
|
36
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Mounts pg-boss for a backend service. Reads all scheduled_job registry
|
|
7
|
+
* entries scoped to the current service from the registry cache, registers
|
|
8
|
+
* pg-boss workers per queue_name, and schedules cron/interval jobs.
|
|
9
|
+
* Per Constitution Rule 6: pg-boss is the single mechanism for all
|
|
10
|
+
* application background work.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
14
|
+
import { validateRow } from "@teamscala/db-validation/validate-row";
|
|
15
|
+
import { pgSslCompatUrl } from "@teamscala/db/provider/pg-ssl-compat";
|
|
16
|
+
import { getAllEntries } from "@teamscala/db/registry/accessors";
|
|
17
|
+
import type { RegistryEntry } from "@teamscala/db/registry/types";
|
|
18
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
19
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
20
|
+
import type { ScheduleOptions } from "pg-boss";
|
|
21
|
+
import { PgBoss } from "pg-boss";
|
|
22
|
+
import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
|
|
23
|
+
|
|
24
|
+
// Bundled pg-boss handlers self-register on import (package-subscriber shape).
|
|
25
|
+
// External handler packages register via their own boot hook / subscriber import.
|
|
26
|
+
import "./handlers/prune-stale-data.ts";
|
|
27
|
+
import "./handlers/projects-digest.ts";
|
|
28
|
+
import "./handlers/sweep-event-log.ts";
|
|
29
|
+
import "./handlers/sweep-host-cache.ts";
|
|
30
|
+
import "./handlers/bun-stable-watch.ts";
|
|
31
|
+
import "./handlers/whatsapp-liveness.ts";
|
|
32
|
+
import "./handlers/upstream-issue-sync.ts";
|
|
33
|
+
import "./handlers/scheduled-browser-tests.ts";
|
|
34
|
+
import "./handlers/scala-tools-currency-watch.ts";
|
|
35
|
+
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
36
|
+
|
|
37
|
+
export interface PgBossMountContext {
|
|
38
|
+
databaseUrl: string;
|
|
39
|
+
serviceSlug: string;
|
|
40
|
+
packages: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MountedJob {
|
|
44
|
+
queueName: string;
|
|
45
|
+
handlerSlug: string;
|
|
46
|
+
cron?: string;
|
|
47
|
+
intervalSeconds?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PgBossMountResult {
|
|
51
|
+
boss: PgBoss | null;
|
|
52
|
+
jobs: MountedJob[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function belongsToService(
|
|
56
|
+
entry: RegistryEntry,
|
|
57
|
+
serviceSlug: string,
|
|
58
|
+
packages: string[],
|
|
59
|
+
): boolean {
|
|
60
|
+
const config = entry.config as Record<string, unknown>;
|
|
61
|
+
const handlerSlug = config.handler_slug as string | undefined;
|
|
62
|
+
if (!handlerSlug) return false;
|
|
63
|
+
// Explicit row-driven targeting takes precedence — a universal boot-package
|
|
64
|
+
// handler (e.g. one in @teamscala/service-boot, which no service declares in
|
|
65
|
+
// server_packages) cannot be scoped by the server_package prefix match.
|
|
66
|
+
const targetService = config.service_slug as string | undefined;
|
|
67
|
+
if (targetService) return targetService === serviceSlug;
|
|
68
|
+
return packages.some(
|
|
69
|
+
(pkg) => handlerSlug.startsWith(`${pkg}:`) || handlerSlug === pkg,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function intervalToCron(intervalSeconds: number): string {
|
|
74
|
+
if (intervalSeconds < 60) {
|
|
75
|
+
return `*/${intervalSeconds} * * * * *`;
|
|
76
|
+
}
|
|
77
|
+
const minutes = Math.floor(intervalSeconds / 60);
|
|
78
|
+
if (minutes < 60) {
|
|
79
|
+
return `*/${minutes} * * * *`;
|
|
80
|
+
}
|
|
81
|
+
const hours = Math.floor(minutes / 60);
|
|
82
|
+
return `0 */${hours} * * *`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function resolveHandler(
|
|
86
|
+
handlerSlug: string,
|
|
87
|
+
config: ScheduledJobConfig,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const handler = pgBossHandlerRegistry.get(handlerSlug);
|
|
90
|
+
if (!handler) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`pg-boss handler "${handlerSlug}" has no registered function — ` +
|
|
93
|
+
`register it via pgBossHandlerRegistry.register(slug, fn) before the job fires`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
await handler({ config });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function mountPgBoss(
|
|
100
|
+
ctx: PgBossMountContext,
|
|
101
|
+
): Promise<PgBossMountResult> {
|
|
102
|
+
const allEntries = getAllEntries();
|
|
103
|
+
const scheduledEntries = allEntries.filter(
|
|
104
|
+
(e) =>
|
|
105
|
+
(e.type as string) === "scheduled_job" &&
|
|
106
|
+
belongsToService(e, ctx.serviceSlug, ctx.packages),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (scheduledEntries.length === 0) {
|
|
110
|
+
return { boss: null, jobs: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const log = getAppLogger();
|
|
114
|
+
const jobs: MountedJob[] = [];
|
|
115
|
+
|
|
116
|
+
// pg-boss connects through pg, whose sslmode dialect differs from the
|
|
117
|
+
// shared URL's libpq spelling — route through the canonical rewrite.
|
|
118
|
+
// Config object (not a bare connectionString) so the internal pool is
|
|
119
|
+
// bounded + tagged for the ~220ms cross-internet dev→Railway RTT.
|
|
120
|
+
const boss = new PgBoss({
|
|
121
|
+
connectionString: pgSslCompatUrl(ctx.databaseUrl),
|
|
122
|
+
application_name: `pgboss:${ctx.serviceSlug}`,
|
|
123
|
+
// Same per-service budget as the application Prisma pool (railway.ts):
|
|
124
|
+
// ~30 consumers share one 200-conn Postgres; 3 keeps the aggregate ≤90.
|
|
125
|
+
max: 3,
|
|
126
|
+
// Generous connection-acquisition budget for the cross-internet link so
|
|
127
|
+
// a slow TLS handshake under sustained load doesn't terminate the conn.
|
|
128
|
+
connectionTimeoutMillis: 30_000,
|
|
129
|
+
});
|
|
130
|
+
// crash-isolation: pg-boss's manager emits 'error' on a worker-connection
|
|
131
|
+
// failure (a proxy-killed idle conn under sustained cross-internet writes).
|
|
132
|
+
// With NO listener, EventEmitter THROWS on emit('error'), the worker's
|
|
133
|
+
// promise rejects unhandled, and Bun exits the process — the scala-extract
|
|
134
|
+
// restart-storm of 2026-07-19. The listener makes it a logged, recovered
|
|
135
|
+
// event. This is the pg-boss-owned connection, the sibling of @teamscala/db's
|
|
136
|
+
// application-pool pool.on('error') handler (the app pool alone did not
|
|
137
|
+
// cover pg-boss's separate pool). Registered before start() so a boot-time
|
|
138
|
+
// error is caught too.
|
|
139
|
+
boss.on("error", (err) => {
|
|
140
|
+
log.warn(`[pg-boss:${ctx.serviceSlug}] recovered error event (process kept alive)`, {
|
|
141
|
+
error: err instanceof Error ? err.message : String(err),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
boss.on("warning", (w) => {
|
|
145
|
+
log.warn(`[pg-boss:${ctx.serviceSlug}] warning`, { warning: String(w) });
|
|
146
|
+
});
|
|
147
|
+
// start() MUST precede work()/schedule() — it opens the DB connection;
|
|
148
|
+
// calling schedule() before start() crashes the timekeeper with
|
|
149
|
+
// "Database not opened. Call open() before executing SQL." (pg-boss v12)
|
|
150
|
+
await boss.start();
|
|
151
|
+
|
|
152
|
+
for (const entry of scheduledEntries) {
|
|
153
|
+
// crash-isolation: a malformed scheduled_job row degrades to "this job
|
|
154
|
+
// unavailable" (logged + event-log row), NEVER a boot-crash. A bare
|
|
155
|
+
// pg-boss assert reaching boot is the violation.
|
|
156
|
+
let config: ScheduledJobConfig;
|
|
157
|
+
try {
|
|
158
|
+
config = validateRow("scheduled_job", entry.config) as never as ScheduledJobConfig;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
161
|
+
log.error(
|
|
162
|
+
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" failed schema validation — skipping`,
|
|
163
|
+
{ reason },
|
|
164
|
+
);
|
|
165
|
+
recordEvent({
|
|
166
|
+
kind: "pgboss.job.skipped",
|
|
167
|
+
payload: { service: ctx.serviceSlug, slug: entry.slug, reason: `schema-invalid: ${reason}` },
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!config.queue_name || !config.handler_slug) {
|
|
172
|
+
log.error(
|
|
173
|
+
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" missing queue_name or handler_slug — skipping`,
|
|
174
|
+
{ queue_name: config.queue_name, handler_slug: config.handler_slug },
|
|
175
|
+
);
|
|
176
|
+
recordEvent({
|
|
177
|
+
kind: "pgboss.job.skipped",
|
|
178
|
+
payload: { service: ctx.serviceSlug, slug: entry.slug, reason: "missing queue_name or handler_slug" },
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const queueName = config.queue_name;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// pg-boss v12 requires the queue to exist before work()/schedule()
|
|
186
|
+
// dispatch against it ("Queue X does not exist") — createQueue's
|
|
187
|
+
// underlying SQL is ON CONFLICT DO NOTHING, so calling it on every
|
|
188
|
+
// boot is a safe idempotent assert, never a duplicate-creation error.
|
|
189
|
+
await boss.createQueue(queueName);
|
|
190
|
+
await boss.work(queueName, async () => {
|
|
191
|
+
await resolveHandler(config.handler_slug, config);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (config.cron) {
|
|
195
|
+
const scheduleOpts: ScheduleOptions = {};
|
|
196
|
+
if (config.retry) {
|
|
197
|
+
scheduleOpts.retryLimit = config.retry.max_attempts;
|
|
198
|
+
scheduleOpts.retryDelay = config.retry.backoff_seconds;
|
|
199
|
+
scheduleOpts.retryBackoff = true;
|
|
200
|
+
}
|
|
201
|
+
await boss.schedule(queueName, config.cron, null, scheduleOpts);
|
|
202
|
+
jobs.push({
|
|
203
|
+
queueName,
|
|
204
|
+
handlerSlug: config.handler_slug,
|
|
205
|
+
cron: config.cron,
|
|
206
|
+
});
|
|
207
|
+
} else if (config.interval_seconds) {
|
|
208
|
+
const cron = intervalToCron(config.interval_seconds);
|
|
209
|
+
const scheduleOpts: ScheduleOptions = {};
|
|
210
|
+
if (config.retry) {
|
|
211
|
+
scheduleOpts.retryLimit = config.retry.max_attempts;
|
|
212
|
+
scheduleOpts.retryDelay = config.retry.backoff_seconds;
|
|
213
|
+
scheduleOpts.retryBackoff = true;
|
|
214
|
+
}
|
|
215
|
+
await boss.schedule(queueName, cron, null, scheduleOpts);
|
|
216
|
+
jobs.push({
|
|
217
|
+
queueName,
|
|
218
|
+
handlerSlug: config.handler_slug,
|
|
219
|
+
intervalSeconds: config.interval_seconds,
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
jobs.push({
|
|
223
|
+
queueName,
|
|
224
|
+
handlerSlug: config.handler_slug,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// crash-isolation for the mount path: a work()/schedule() failure
|
|
229
|
+
// (e.g. DB not open, queue conflict) degrades to "this job unavailable"
|
|
230
|
+
// (logged + event-log row), NEVER a boot-crash. Same treatment as the
|
|
231
|
+
// row-validation guard above.
|
|
232
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
233
|
+
log.error(
|
|
234
|
+
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" failed to mount (work/schedule) — skipping`,
|
|
235
|
+
{ reason },
|
|
236
|
+
);
|
|
237
|
+
recordEvent({
|
|
238
|
+
kind: "pgboss.job.skipped",
|
|
239
|
+
payload: { service: ctx.serviceSlug, slug: entry.slug, reason: `mount-failed: ${reason}` },
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no
|
|
246
|
+
// longer corresponds to an ACTIVE registry row. Deactivating/deleting a
|
|
247
|
+
// scheduled_job (or sync_job_handler) previously left its cron lingering in
|
|
248
|
+
// pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
|
|
249
|
+
// found 2026-07-27, still scraping every 3/15min months after the job was meant
|
|
250
|
+
// to be one-off. Deactivation must actually stop the cron. A schedule is
|
|
251
|
+
// "backed" iff ANY active registry row's slug OR queue_name equals its name —
|
|
252
|
+
// this covers scheduled_job AND sync_job_handler (and any future
|
|
253
|
+
// schedule-owning type) without hardcoding the type list, so live sync jobs
|
|
254
|
+
// (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
|
|
255
|
+
// mount and reconciles GLOBALLY: an orphan is removed regardless of which
|
|
256
|
+
// service created it, since a cron with no active backing row is drift by
|
|
257
|
+
// definition. A service with zero active scheduled jobs early-returns above
|
|
258
|
+
// and never reaches here — scala-loga (7 cron jobs) is the reliable
|
|
259
|
+
// reconciler, but any service-with-jobs that boots cleans the full set.
|
|
260
|
+
try {
|
|
261
|
+
const backedScheduleNames = new Set(
|
|
262
|
+
allEntries.flatMap((e) => {
|
|
263
|
+
const cfg = e.config as { queue_name?: string } | null;
|
|
264
|
+
return [e.slug, cfg?.queue_name].filter(
|
|
265
|
+
(n): n is string => typeof n === "string" && n.length > 0,
|
|
266
|
+
);
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
const liveSchedules = await boss.getSchedules();
|
|
270
|
+
for (const sched of liveSchedules) {
|
|
271
|
+
if (backedScheduleNames.has(sched.name)) continue;
|
|
272
|
+
log.warn(
|
|
273
|
+
`[pg-boss:${ctx.serviceSlug}] unscheduling orphaned cron "${sched.name}" (no active registry row backs it — drift)`,
|
|
274
|
+
);
|
|
275
|
+
await boss.unschedule(sched.name);
|
|
276
|
+
recordEvent({
|
|
277
|
+
kind: "pgboss.schedule.unscheduled",
|
|
278
|
+
payload: {
|
|
279
|
+
service: ctx.serviceSlug,
|
|
280
|
+
name: sched.name,
|
|
281
|
+
reason: "orphaned-no-active-backing-row",
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// crash-isolation: a reconciliation failure degrades to "orphans not
|
|
287
|
+
// cleaned this boot" (logged), never a boot-crash — reconciled on the
|
|
288
|
+
// next successful mount.
|
|
289
|
+
log.warn(`[pg-boss:${ctx.serviceSlug}] schedule reconciliation failed`, {
|
|
290
|
+
error: err instanceof Error ? err.message : String(err),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { boss, jobs };
|
|
295
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Resolves the set of emails authorized to view a proposal. A proposal is
|
|
7
|
+
* gated (proposal-access) — only these emails can unlock it:
|
|
8
|
+
*
|
|
9
|
+
* - the issuing-org team: every member of the proposal's organisation
|
|
10
|
+
* (better_auth_member → user.email). Lets the agency team open any of their
|
|
11
|
+
* own proposals AND covers existing proposals whose created_by is
|
|
12
|
+
* "system"/null.
|
|
13
|
+
* - the recipient: the proposal's CRM contact (client_database.primary_email),
|
|
14
|
+
* when contact_id is set.
|
|
15
|
+
* - the partner team: every CRM contact belonging to the proposal's partner
|
|
16
|
+
* org (client_database.company_id = proposals.partner_id → primary_email),
|
|
17
|
+
* when partner_id is set. Lets the co-delivery partner's team open a
|
|
18
|
+
* co-branded proposal. The partner is still branding-first; this is the one
|
|
19
|
+
* access edge it carries.
|
|
20
|
+
*
|
|
21
|
+
* Uses $queryRaw (raw SQL) deliberately: the per-service Prisma client is scoped
|
|
22
|
+
* to that service's own models, so the proposals service's client has no
|
|
23
|
+
* `member`/`user` model methods — but $queryRaw runs against the shared DB
|
|
24
|
+
* regardless of the client's model scope. Returns a lowercased Set, checked
|
|
25
|
+
* server-side only (emails never sent to the client). Fail-closed: any error
|
|
26
|
+
* yields an empty set (no access).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
30
|
+
import { queryRows } from "@teamscala/db/query-rows";
|
|
31
|
+
|
|
32
|
+
export async function resolveProposalAuthorizedEmails(
|
|
33
|
+
proposalId: string,
|
|
34
|
+
): Promise<Set<string>> {
|
|
35
|
+
try {
|
|
36
|
+
const { getPrisma } = await import("@teamscala/db/prisma-registry");
|
|
37
|
+
const prisma = getPrisma();
|
|
38
|
+
const rows = await queryRows`
|
|
39
|
+
SELECT lower(u.email) AS email
|
|
40
|
+
FROM better_auth_member m
|
|
41
|
+
JOIN "user" u ON u.id = m.user_id
|
|
42
|
+
WHERE m.organization_id = (
|
|
43
|
+
SELECT organisation_id FROM proposals WHERE id = ${proposalId})
|
|
44
|
+
AND u.email IS NOT NULL
|
|
45
|
+
UNION
|
|
46
|
+
SELECT lower(cd.primary_email) AS email
|
|
47
|
+
FROM client_database cd
|
|
48
|
+
WHERE cd.id = (SELECT contact_id FROM proposals WHERE id = ${proposalId})
|
|
49
|
+
AND cd.primary_email IS NOT NULL
|
|
50
|
+
UNION
|
|
51
|
+
SELECT lower(cd.primary_email) AS email
|
|
52
|
+
FROM client_database cd
|
|
53
|
+
WHERE cd.company_id = (SELECT partner_id FROM proposals WHERE id = ${proposalId})
|
|
54
|
+
AND cd.primary_email IS NOT NULL
|
|
55
|
+
` as { email: string }[];
|
|
56
|
+
return new Set(
|
|
57
|
+
rows.map((r) => (r.email ?? "").trim().toLowerCase()).filter(Boolean),
|
|
58
|
+
);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
getAppLogger().error(
|
|
61
|
+
`[proposal-access] authorized-email resolve failed for ${proposalId}: ${err instanceof Error ? `${err.message} | ${err.stack}` : String(err)}`,
|
|
62
|
+
);
|
|
63
|
+
return new Set();
|
|
64
|
+
}
|
|
65
|
+
}
|