@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,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
|
+
}
|