@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.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. 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
+ }