@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,68 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Mutable app reference for two-phase boot. Allows swapping the Hono app
7
+ * atomically after building the full app in the background. The initial app
8
+ * is a minimal tool-only surface (ORPC, data routes) that starts serving
9
+ * immediately; the full app (pages, hydration) is built off-to-the-side and
10
+ * swapped in once complete.
11
+ *
12
+ * Thread-safety: Bun is single-threaded, so a simple module-level variable
13
+ * is sufficient. Atomics are not needed.
14
+ */
15
+
16
+ import type { Hono } from "hono";
17
+
18
+ let currentApp: Hono | null = null;
19
+ let appLabel = "initial";
20
+
21
+ /**
22
+ * Create a new mutable app reference initialized with the provided app.
23
+ * This should be called once at boot time.
24
+ */
25
+ export function initMutableAppRef(initialApp: Hono, label: string): void {
26
+ currentApp = initialApp;
27
+ appLabel = label;
28
+ }
29
+
30
+ /**
31
+ * Get the current app. Throws if the ref hasn't been initialized.
32
+ */
33
+ export function getApp(): Hono {
34
+ if (!currentApp) {
35
+ throw new Error("Mutable app reference not initialized");
36
+ }
37
+ return currentApp;
38
+ }
39
+
40
+ /**
41
+ * Get the current app label for logging.
42
+ */
43
+ export function getAppLabel(): string {
44
+ return appLabel;
45
+ }
46
+
47
+ /**
48
+ * Atomically swap the app to a new instance. This is called after the
49
+ * full app is built in the background. Returns the previous label for logging.
50
+ */
51
+ export function swapApp(newApp: Hono, newLabel: string): string {
52
+ const oldLabel = appLabel;
53
+ currentApp = newApp;
54
+ appLabel = newLabel;
55
+ return oldLabel;
56
+ }
57
+
58
+ /**
59
+ * Fetch handler that routes through the mutable app reference.
60
+ * This replaces the direct app.fetch call in the serve layer.
61
+ */
62
+ export function fetchWithMutableApp(
63
+ req: Request,
64
+ server: ReturnType<typeof Bun.serve>,
65
+ ): Promise<Response> {
66
+ const app = getApp();
67
+ return Promise.resolve(app.fetch(req, { server }));
68
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Render mode literal shared across page route registration. The old
7
+ * shell-variant types (LegacyShell, PageSlugShell, RegionsShell,
8
+ * PublicShell, ConfigPageRoute) were removed when the Phase 2 deploy-time
9
+ * `service_install.page_routes[]` path was eliminated — all routing is
10
+ * now registry-driven `page_route` rows (Phase 1).
11
+ */
12
+
13
+ export type RenderModeLiteral =
14
+ | "public"
15
+ | "portals"
16
+ | "utility";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @system page-server
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Audit revert procedure — re-applies an audit_trail row's changes_before to
7
+ * the entity via the canonical generated Prisma procedure for the entity's
8
+ * table: INSERT→delete the created row, UPDATE→restore the old values,
9
+ * DELETE→recreate from the deleted snapshot. The revert write itself fires the
10
+ * audit extension + DB trigger, recording a NEW forward audit_trail row —
11
+ * history is never edited (audit-trail-permanent; the undo is itself audited).
12
+ *
13
+ * Gated to super-admin (reverting business data is privileged). The caller
14
+ * must pass the audit_trail row id; the procedure resolves table + action +
15
+ * before-snapshot from that row.
16
+ */
17
+
18
+ import { os } from "@orpc/server";
19
+ import type { PrismaClient } from "@teamscala/db/client";
20
+ import * as v from "valibot";
21
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
22
+
23
+ const procedure = os;
24
+
25
+ type C = {
26
+ prisma?: unknown;
27
+ user?: { id?: string; isSuperAdmin?: boolean } | null;
28
+ };
29
+
30
+ function getPrisma(context: unknown) {
31
+ const db = (context as C).prisma;
32
+ if (!db) throw new Error("auditRevert: context.prisma is required");
33
+ return db as PrismaClient;
34
+ }
35
+
36
+ const revertInput = v.object({
37
+ auditTrailId: v.string(),
38
+ });
39
+
40
+ export const auditRevertRouter = {
41
+ /**
42
+ * Revert a single audited change by re-applying changes_before through the
43
+ * generated Prisma model for the entity's table. Returns the action taken.
44
+ */
45
+ revert: procedure.input(revertInput).handler(async ({ context, input }) => {
46
+ const user = (context as C).user;
47
+ if (!user?.isSuperAdmin) {
48
+ throw new Error("auditRevert: requires super-admin");
49
+ }
50
+ const prisma = getPrisma(context) as unknown as Record<string, any>;
51
+ const log = getAppLogger();
52
+ const { auditTrailId } = input;
53
+
54
+ const row = await prisma.audit_trail.findUnique({
55
+ where: { id: auditTrailId },
56
+ select: {
57
+ entity_type: true,
58
+ entity_id: true,
59
+ action: true,
60
+ changes_before: true,
61
+ changes_after: true,
62
+ },
63
+ });
64
+ if (!row) {
65
+ throw new Error(`auditRevert: audit_trail ${auditTrailId} not found`);
66
+ }
67
+
68
+ const table = String(row.entity_type);
69
+ const model = prisma[table];
70
+ if (!model || typeof model.delete !== "function") {
71
+ throw new Error(`auditRevert: table ${table} has no generated Prisma model`);
72
+ }
73
+ const entityId = String(row.entity_id ?? "");
74
+ const act = String(row.action ?? "").toUpperCase();
75
+ // changes_before is Json? — Prisma returns it parsed.
76
+ const before = (row.changes_before ?? null) as Record<string, unknown> | null;
77
+
78
+ let result: unknown;
79
+ if (act === "INSERT" || act === "CREATE") {
80
+ // Entity was created → revert = delete it.
81
+ if (!entityId) throw new Error("auditRevert INSERT: no entity_id");
82
+ result = await model.delete({ where: { id: entityId } });
83
+ } else if (act === "UPDATE") {
84
+ // Entity was updated → revert = restore changes_before (old values).
85
+ if (!before) throw new Error("auditRevert UPDATE: no changes_before to restore");
86
+ result = await model.update({ where: { id: entityId }, data: before });
87
+ } else if (act === "DELETE") {
88
+ // Entity was deleted → revert = recreate from changes_before.
89
+ if (!before) throw new Error("auditRevert DELETE: no changes_before to recreate");
90
+ result = await model.create({ data: before });
91
+ } else {
92
+ throw new Error(`auditRevert: unsupported action ${act}`);
93
+ }
94
+
95
+ log.info(
96
+ `[auditRevert] ${act} ${table} ${entityId} reverted by ${user.id}`,
97
+ );
98
+ return { success: true as const, action: act, table, entityId };
99
+ }),
100
+ };
101
+
102
+ export type AuditRevertRouter = typeof auditRevertRouter;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @system page-server
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Module-level singleton for the hub MCP client. setHubMcpClient(client)
7
+ * is called once at boot by the host service after creating the multi-
8
+ * tenant MCP client; createPageHandler's resolve-page-state reads it via
9
+ * getHubMcpClient to pass through to data loaders.
10
+ */
11
+
12
+ let _hubMcpClient: unknown | null = null;
13
+
14
+ export function setHubMcpClient(client: unknown): void {
15
+ _hubMcpClient = client;
16
+ }
17
+
18
+ export function getHubMcpClient(): unknown | undefined {
19
+ return _hubMcpClient ?? undefined;
20
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @system page-server
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for portal diagnostics. These replace direct Prisma calls
7
+ * in the portal diagnostic section files with proper ORPC procedures that
8
+ * receive context.prisma from the router.
9
+ */
10
+
11
+ import { os } from "@orpc/server";
12
+ import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
13
+ import { loadRegistryConfigsByType } from "@teamscala/db/registry/load-config";
14
+ import * as v from "valibot";
15
+
16
+ const procedure = os;
17
+
18
+ const PortalWebsiteSchema = v.object({
19
+ id: v.string(),
20
+ name: v.string(),
21
+ organisation_id: v.nullable(v.string()),
22
+ });
23
+
24
+ const MemberSchema = v.object({
25
+ user_id: v.string(),
26
+ });
27
+
28
+ const PageRowSchema = v.object({
29
+ id: v.string(),
30
+ path: v.nullable(v.string()),
31
+ name: v.string(),
32
+ puck_data: v.unknown(),
33
+ updated_at: v.string(),
34
+ });
35
+
36
+ const BlockSchemaSlugSchema = v.object({
37
+ slug: v.string(),
38
+ });
39
+
40
+ export const portalDiagRouter = {
41
+ findPortalWebsite: procedure
42
+ .input(v.object({}))
43
+ .output(v.nullable(PortalWebsiteSchema))
44
+ .handler(async ({ context }) => {
45
+ const db = (context as { prisma?: unknown }).prisma;
46
+ if (!db)
47
+ throw new Error("portalDiag.findPortalWebsite requires context.prisma");
48
+
49
+ return (await (
50
+ db as {
51
+ builda_websites: { findFirst: (args: unknown) => Promise<unknown> };
52
+ }
53
+ ).builda_websites.findFirst({
54
+ where: { website_type: "portal" },
55
+ select: { id: true, name: true, organisation_id: true },
56
+ orderBy: { updated_at: "desc" },
57
+ })) as {
58
+ id: string;
59
+ name: string;
60
+ organisation_id: string | null;
61
+ } | null;
62
+ }),
63
+
64
+ findMember: procedure
65
+ .input(v.object({ organisationId: v.string() }))
66
+ .output(v.nullable(MemberSchema))
67
+ .handler(async ({ input, context }) => {
68
+ const db = (context as { prisma?: unknown }).prisma;
69
+ if (!db) throw new Error("portalDiag.findMember requires context.prisma");
70
+
71
+ return (await (
72
+ db as {
73
+ better_auth_member: {
74
+ findFirst: (args: unknown) => Promise<unknown>;
75
+ };
76
+ }
77
+ ).better_auth_member.findFirst({
78
+ where: { organization_id: input.organisationId },
79
+ select: { user_id: true },
80
+ })) as { user_id: string } | null;
81
+ }),
82
+
83
+ countPages: procedure
84
+ .input(v.object({ websiteId: v.string() }))
85
+ .output(v.number())
86
+ .handler(async ({ input, context }) => {
87
+ const db = (context as { prisma?: unknown }).prisma;
88
+ if (!db) throw new Error("portalDiag.countPages requires context.prisma");
89
+
90
+ return await (
91
+ db as { builda_pages: { count: (args: unknown) => Promise<number> } }
92
+ ).builda_pages.count({
93
+ where: { website_id: input.websiteId },
94
+ });
95
+ }),
96
+
97
+ findPages: procedure
98
+ .input(
99
+ v.object({
100
+ websiteId: v.string(),
101
+ path: v.optional(v.string()),
102
+ take: v.optional(v.number()),
103
+ orderBy: v.optional(v.string()),
104
+ }),
105
+ )
106
+ .output(v.array(PageRowSchema))
107
+ .handler(async ({ input, context }) => {
108
+ const db = (context as { prisma?: unknown }).prisma;
109
+ if (!db) throw new Error("portalDiag.findPages requires context.prisma");
110
+
111
+ const where: Record<string, unknown> = { website_id: input.websiteId };
112
+ if (input.path) where.path = input.path;
113
+
114
+ return (await (
115
+ db as {
116
+ builda_pages: { findMany: (args: unknown) => Promise<unknown[]> };
117
+ }
118
+ ).builda_pages.findMany({
119
+ where,
120
+ select: {
121
+ id: true,
122
+ path: true,
123
+ name: true,
124
+ puck_data: true,
125
+ updated_at: true,
126
+ },
127
+ take: input.take,
128
+ orderBy: input.orderBy ? { [input.orderBy]: "desc" } : undefined,
129
+ })) as Array<{
130
+ id: string;
131
+ path: string | null;
132
+ name: string;
133
+ puck_data: unknown;
134
+ updated_at: string;
135
+ }>;
136
+ }),
137
+
138
+ findBlockSchemas: procedure
139
+ .input(v.object({ slugs: v.array(v.string()) }))
140
+ .output(v.array(BlockSchemaSlugSchema))
141
+ .handler(async ({ input }) => {
142
+ const all = await loadRegistryConfigsByType(
143
+ "block_schema",
144
+ BlockSchemaConfigSchema,
145
+ );
146
+ return all
147
+ .filter((r) => input.slugs.includes(r.slug))
148
+ .map((r) => ({ slug: r.slug }));
149
+ }),
150
+
151
+ findWebsites: procedure
152
+ .input(v.object({ take: v.optional(v.number()) }))
153
+ .output(v.array(v.object({ id: v.string(), name: v.string() })))
154
+ .handler(async ({ input, context }) => {
155
+ const db = (context as { prisma?: unknown }).prisma;
156
+ if (!db)
157
+ throw new Error("portalDiag.findWebsites requires context.prisma");
158
+
159
+ return (await (
160
+ db as {
161
+ builda_websites: { findMany: (args: unknown) => Promise<unknown[]> };
162
+ }
163
+ ).builda_websites.findMany({
164
+ select: { id: true, name: true },
165
+ take: input.take ?? 10,
166
+ })) as Array<{ id: string; name: string }>;
167
+ }),
168
+ };
169
+
170
+ export type PortalDiagRouter = typeof portalDiagRouter;
@@ -0,0 +1,272 @@
1
+ /**
2
+ * @system page-server
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for loading Puck pages. These replace the direct Prisma calls
7
+ * in load-puck-page-by-slug.ts with proper ORPC procedures that receive
8
+ * context.prisma from the router.
9
+ */
10
+
11
+ import { os } from "@orpc/server";
12
+ import * as v from "valibot";
13
+ import { createCache } from "@teamscala/cache/create-cache";
14
+
15
+ const procedure = os;
16
+
17
+ const _WebsiteShellSettingsSchema = v.object({
18
+ website_type: v.nullable(v.string()),
19
+ layout_settings: v.nullable(v.record(v.string(), v.unknown())),
20
+ header_settings: v.nullable(v.record(v.string(), v.unknown())),
21
+ footer_settings: v.nullable(v.record(v.string(), v.unknown())),
22
+ });
23
+
24
+ const PuckPageRowSchema = v.object({
25
+ id: v.string(),
26
+ path: v.nullable(v.string()),
27
+ puckData: v.unknown(),
28
+ mode: v.nullable(v.string()),
29
+ render_mode: v.nullable(v.string()),
30
+ website_slug: v.string(),
31
+ organisation_id: v.nullable(v.string()),
32
+ meta_title: v.nullable(v.string()),
33
+ meta_description: v.nullable(v.string()),
34
+ website: v.object({
35
+ slug: v.string(),
36
+ name: v.string(),
37
+ organisation_id: v.nullable(v.string()),
38
+ website_type: v.nullable(v.string()),
39
+ layout_settings: v.nullable(v.record(v.string(), v.unknown())),
40
+ header_settings: v.nullable(v.record(v.string(), v.unknown())),
41
+ footer_settings: v.nullable(v.record(v.string(), v.unknown())),
42
+ }),
43
+ });
44
+
45
+ type PuckPageRow = v.InferOutput<typeof PuckPageRowSchema>;
46
+
47
+ /**
48
+ * Right-panel / region pages (AI chat, audit, comments, …) are STATIC website
49
+ * config — one page row + its website row, no per-user data — so they are safe
50
+ * to cache across users keyed by website + path. Each cold open otherwise pays
51
+ * TWO dependent cross-internet DB round-trips (~230ms each against the dev host's
52
+ * proxied Railway Postgres); caching makes a repeat panel open near-instant.
53
+ * SWR: fresh 60s, stale-served up to 5min while ONE background refresh runs
54
+ * (stampede-safe), so a page edit appears within the window without every open
55
+ * re-querying. No user identity in the key or value — no cross-user leak.
56
+ */
57
+ const pageCache = createCache<PuckPageRow | null>(
58
+ "page-server:load-from-website",
59
+ { ttlMs: 60_000, staleTtlMs: 5 * 60_000, maxSize: 200, stampedeProtection: true },
60
+ );
61
+
62
+ export const puckPagesRouter = {
63
+ loadFromWebsite: procedure
64
+ .input(v.object({ websiteSlug: v.string(), path: v.string() }))
65
+ .output(v.nullable(PuckPageRowSchema))
66
+ .handler(async ({ input, context }) => {
67
+ const db = (context as { prisma?: unknown }).prisma;
68
+ if (!db)
69
+ throw new Error("puckPages.loadFromWebsite requires context.prisma");
70
+
71
+ // Normalize the path: builda_pages stores paths WITH a leading slash
72
+ // ('/chat', '/audit', …), but a panel_view.page_slug + a region
73
+ // `?right=`/`?panel=` slug conventionally OMIT it ('chat', 'audit').
74
+ // The SSR panel resolver (resolve-page-puck-data.ts) already prepends
75
+ // '/' (`/${activePanel}`); match that contract here so a client-side
76
+ // panel/region load by slug resolves the SAME row a deep-link SSR does.
77
+ // Without this, regions.loadPage({ path: "chat" }) queried
78
+ // `WHERE path = 'chat'` → no match → null → the panel dock opened blank
79
+ // (portal Chat/Audit/Notifications/Utility + the fleet-dashboard region
80
+ // swap — any page-kind panel opened by click rather than deep link).
81
+ const path = input.path.startsWith("/") ? input.path : `/${input.path}`;
82
+
83
+ return pageCache.fetchOrCompute(
84
+ `${input.websiteSlug}::${path}`,
85
+ async () => {
86
+ const site = (await (
87
+ db as {
88
+ builda_websites: { findFirst: (args: unknown) => Promise<unknown> };
89
+ }
90
+ ).builda_websites.findFirst({
91
+ // websiteSlug may be a website SLUG or a website ID. The Utility shell
92
+ // carries the serving website's id (loaderData.*.website_id) but not its
93
+ // slug, so its region panels pass the id; UUIDs and kebab slugs cannot
94
+ // collide, so OR-matching both is unambiguous.
95
+ where: {
96
+ OR: [{ slug: input.websiteSlug }, { id: input.websiteSlug }],
97
+ status: { not: "archived" },
98
+ },
99
+ select: {
100
+ id: true,
101
+ name: true,
102
+ slug: true,
103
+ organisation_id: true,
104
+ website_type: true,
105
+ layout_settings: true,
106
+ header_settings: true,
107
+ footer_settings: true,
108
+ },
109
+ })) as {
110
+ id: string;
111
+ name: string;
112
+ slug: string;
113
+ organisation_id: string | null;
114
+ website_type: string | null;
115
+ layout_settings: Record<string, unknown> | null;
116
+ header_settings: Record<string, unknown> | null;
117
+ footer_settings: Record<string, unknown> | null;
118
+ } | null;
119
+
120
+ if (!site) return null;
121
+
122
+ const row = (await (
123
+ db as {
124
+ builda_pages: { findFirst: (args: unknown) => Promise<unknown> };
125
+ }
126
+ ).builda_pages.findFirst({
127
+ where: { path, is_active: true, website_id: site.id },
128
+ select: {
129
+ id: true,
130
+ path: true,
131
+ puck_data: true,
132
+ meta_title: true,
133
+ meta_description: true,
134
+ },
135
+ })) as {
136
+ id: string;
137
+ path: string | null;
138
+ puck_data: unknown;
139
+ meta_title: string | null;
140
+ meta_description: string | null;
141
+ } | null;
142
+
143
+ if (!row) return null;
144
+
145
+ return {
146
+ id: row.id,
147
+ path: row.path,
148
+ puckData: row.puck_data,
149
+ mode: null,
150
+ render_mode: null,
151
+ website_slug: site.slug,
152
+ organisation_id: site.organisation_id,
153
+ meta_title: row.meta_title,
154
+ meta_description: row.meta_description,
155
+ website: {
156
+ slug: site.slug,
157
+ name: site.name,
158
+ organisation_id: site.organisation_id,
159
+ website_type: site.website_type,
160
+ layout_settings: site.layout_settings,
161
+ header_settings: site.header_settings,
162
+ footer_settings: site.footer_settings,
163
+ },
164
+ };
165
+ });
166
+ }),
167
+
168
+ loadById: procedure
169
+ .input(v.object({ pageId: v.string() }))
170
+ .output(v.nullable(PuckPageRowSchema))
171
+ .handler(async ({ input, context }) => {
172
+ const db = (context as { prisma?: unknown }).prisma;
173
+ if (!db) throw new Error("puckPages.loadById requires context.prisma");
174
+
175
+ console.log("[DEBUG-loadById] pageId:", input.pageId);
176
+
177
+ const row = (await (
178
+ db as {
179
+ builda_pages: { findFirst: (args: unknown) => Promise<unknown> };
180
+ }
181
+ ).builda_pages.findFirst({
182
+ where: { id: input.pageId, is_active: true },
183
+ select: {
184
+ id: true,
185
+ path: true,
186
+ puck_data: true,
187
+ website_id: true,
188
+ meta_title: true,
189
+ meta_description: true,
190
+ },
191
+ })) as {
192
+ id: string;
193
+ path: string | null;
194
+ puck_data: unknown;
195
+ website_id: string;
196
+ meta_title: string | null;
197
+ meta_description: string | null;
198
+ } | null;
199
+
200
+ if (!row) {
201
+ console.log("[DEBUG-loadById] NO ROW for pageId:", input.pageId);
202
+ return null;
203
+ }
204
+
205
+ const pd = row.puck_data as { content?: Array<{ type?: string }> } | null;
206
+ const types = pd?.content?.map((c) => c.type) ?? [];
207
+ console.log(
208
+ "[DEBUG-loadById] row found, content types:",
209
+ JSON.stringify(types),
210
+ );
211
+
212
+ const site = (await (
213
+ db as {
214
+ builda_websites: { findFirst: (args: unknown) => Promise<unknown> };
215
+ }
216
+ ).builda_websites.findFirst({
217
+ where: { id: row.website_id, status: { not: "archived" } },
218
+ select: {
219
+ id: true,
220
+ name: true,
221
+ slug: true,
222
+ organisation_id: true,
223
+ website_type: true,
224
+ layout_settings: true,
225
+ header_settings: true,
226
+ footer_settings: true,
227
+ },
228
+ })) as {
229
+ id: string;
230
+ name: string;
231
+ slug: string;
232
+ organisation_id: string | null;
233
+ website_type: string | null;
234
+ layout_settings: Record<string, unknown> | null;
235
+ header_settings: Record<string, unknown> | null;
236
+ footer_settings: Record<string, unknown> | null;
237
+ } | null;
238
+
239
+ return {
240
+ id: row.id,
241
+ path: row.path,
242
+ puckData: row.puck_data,
243
+ mode: null,
244
+ render_mode: null,
245
+ website_slug: site?.slug ?? "",
246
+ organisation_id: site?.organisation_id ?? null,
247
+ meta_title: row.meta_title,
248
+ meta_description: row.meta_description,
249
+ website: site
250
+ ? {
251
+ slug: site.slug,
252
+ name: site.name,
253
+ organisation_id: site.organisation_id,
254
+ website_type: site.website_type,
255
+ layout_settings: site.layout_settings,
256
+ header_settings: site.header_settings,
257
+ footer_settings: site.footer_settings,
258
+ }
259
+ : {
260
+ slug: "",
261
+ name: "",
262
+ organisation_id: null,
263
+ website_type: null,
264
+ layout_settings: null,
265
+ header_settings: null,
266
+ footer_settings: null,
267
+ },
268
+ };
269
+ }),
270
+ };
271
+
272
+ export type PuckPagesRouter = typeof puckPagesRouter;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @system page-server
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Server-side ORPC client for in-process direct invocation, per the canonical
7
+ * Vite/Next/Nuxt SSR pattern documented at https://orpc.dev/docs/best-practices/optimize-ssr
8
+ *
9
+ * The host service (scala-install) must call `setServerClient(client)` once
10
+ * at boot, after createRouterClient(appRouter, { context: { prisma } }).
11
+ * Then page-server's createPageHandler reads `getServerClient()` per request
12
+ * — no HTTP loopback, just a typed function call.
13
+ */
14
+
15
+ import { createRouterClient, os } from "@orpc/server";
16
+
17
+ const emptyRouter = {};
18
+
19
+ let serverClient: ReturnType<typeof createRouterClient> | null = null;
20
+
21
+ export function getServerClient(): ReturnType<typeof createRouterClient> {
22
+ if (serverClient) return serverClient;
23
+ serverClient = createRouterClient(emptyRouter, { context: {} });
24
+ return serverClient;
25
+ }
26
+
27
+ export function setServerClient(
28
+ client: ReturnType<typeof createRouterClient>,
29
+ ): void {
30
+ serverClient = client;
31
+ }