@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,455 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for the AUTHENTICATED AI-chat conversation lifecycle —
7
+ * the org+user-keyed sibling of publicGptSessionRouter (which is contact-
8
+ * linked for anonymous public GPT visitors). One signed-in user chatting
9
+ * with one agent in one org owns a thread of `conversations` rows; this
10
+ * router createOrRestore-s the active one, saves turns, archives+news on
11
+ * clear, lists past threads, and loads a specific thread.
12
+ *
13
+ * Identity: the USER is resolved from the ORPC session context (`authed`
14
+ * middleware → `context.user.id`); the ORGANISATION is supplied by the
15
+ * client (it is the URL :orgId for org-scoped apps like scala-projects —
16
+ * the session does not carry it) and VERIFIED — the caller must be a
17
+ * member of that organisation, else FORBIDDEN. A caller cannot read or
18
+ * write another user's threads (ownership is user_id) or another org's
19
+ * (createOrRestore/list are membership-scoped to the supplied org).
20
+ *
21
+ * This restores the capability the portal AI chat lost when the June
22
+ * chat-engine migration severed `usePortalConversationId` + the
23
+ * `portalConversation` router (capability-named `chatSession` here per
24
+ * name-by-capability; the host-named orphan was deleted in the same change).
25
+ *
26
+ * Persistence model: the chat client owns writes via onMessagesPersist →
27
+ * saveMessages, and sends clientPersists:true so handle-chat-stream skips its
28
+ * own persistChatRun (single writer, no double-persist). Verbatim UIMessage
29
+ * parts are stored in messages.channel_data.parts so multi-step (tool-using)
30
+ * turns round-trip losslessly (apps/chat.md messages-stored-as-uimessage-verbatim).
31
+ */
32
+
33
+ import { os } from "@orpc/server";
34
+ import * as v from "valibot";
35
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
36
+ import { authed, type Context } from "./auth-router.ts";
37
+ import { persistChatMessages } from "./persist-chat-messages.ts";
38
+
39
+ /** channel_data discriminator carrying the agent this thread belongs to. */
40
+ const assistantType = (agentId: string) => `chat:${agentId}`;
41
+
42
+ /**
43
+ * How many recent messages createOrRestore returns. The full history was
44
+ * previously loaded (unbounded) — a heavy thread paid ~2.5s + ~460KB on every
45
+ * open/prefetch. Older messages paginate via `loadEarlier`. Adjustable; when a
46
+ * tunable, lift to a registry config row (config-constants-db-driven).
47
+ */
48
+ const RECENT_MESSAGE_LIMIT = 50;
49
+
50
+ const createOrRestoreSchema = v.object({
51
+ agentId: v.string(),
52
+ organisationId: v.string(),
53
+ /**
54
+ * SSR-only: the real user's id, passed by the AiChat serverLoader. The
55
+ * in-process server client is system-authed (context.user.id === "system"),
56
+ * so without this the conversation is created under user_id="system" and
57
+ * the real user's saveMessages hits assertOwns → FORBIDDEN. Only honoured
58
+ * when the caller is the system user (server-side SSR), never from a
59
+ * client-side ORPC call.
60
+ */
61
+ userId: v.optional(v.string()),
62
+ });
63
+ const loadSchema = v.object({
64
+ conversationId: v.string(),
65
+ });
66
+ const saveMessagesSchema = v.object({
67
+ conversationId: v.string(),
68
+ messages: v.array(
69
+ v.object({
70
+ // The SDK UIMessage id — persisted AS the row PK so a reload/retry/
71
+ // dual-write upserts the SAME row instead of inserting a duplicate
72
+ // (chat-message-ids-server-stable). The duplicate truncated-assistant
73
+ // rows came from create-without-id: each save inserted a fresh row the
74
+ // next save could never reconcile against.
75
+ id: v.optional(v.string()),
76
+ role: v.string(),
77
+ content: v.string(),
78
+ // Verbatim UIMessage parts (messages-stored-as-uimessage-verbatim).
79
+ parts: v.optional(v.array(v.any())),
80
+ // UIMessage metadata — per-message token usage (inputTokens/
81
+ // outputTokens/contextWindow) the stream attaches; persisted so the
82
+ // context-usage gauge rehydrates on reload (was dropped before).
83
+ metadata: v.optional(v.record(v.string(), v.any())),
84
+ }),
85
+ ),
86
+ });
87
+ const clearSchema = v.object({
88
+ conversationId: v.string(),
89
+ });
90
+ const listSchema = v.object({
91
+ agentId: v.string(),
92
+ organisationId: v.string(),
93
+ });
94
+ const loadEarlierSchema = v.object({
95
+ conversationId: v.string(),
96
+ /** Cursor: the occurred_at (ISO) of the oldest currently-loaded message. */
97
+ before: v.pipe(
98
+ v.string(),
99
+ v.isoTimestamp(),
100
+ ),
101
+ /** Cap; defaults to RECENT_MESSAGE_LIMIT, clamped to it. */
102
+ limit: v.optional(v.number()),
103
+ });
104
+
105
+ interface SessionUser {
106
+ id: string;
107
+ name?: string | null;
108
+ email?: string;
109
+ }
110
+
111
+ async function throwForbidden(message: string): Promise<never> {
112
+ const { ORPCError } = await import("@orpc/server");
113
+ throw new ORPCError("FORBIDDEN", { message });
114
+ }
115
+
116
+ /**
117
+ * Reject if the conversation does not belong to the calling user. Ownership
118
+ * by user_id is the complete proof (the row was created with this user's id);
119
+ * no org check needed.
120
+ */
121
+ async function assertOwns(
122
+ prisma: any,
123
+ conversationId: string,
124
+ user: SessionUser,
125
+ ): Promise<any> {
126
+ const conv = await prisma.conversations.findUnique({
127
+ where: { id: conversationId },
128
+ });
129
+ if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
130
+ return conv;
131
+ }
132
+
133
+ /** Map stored message rows to the wire shape the chat client restores. */
134
+ function mapMessages(msgs: any[]) {
135
+ return msgs
136
+ .filter((m: any) => m.role === "user" || m.role === "assistant")
137
+ .map((m: any) => ({
138
+ // Restore the stable row id so the client re-seeds useChat with the
139
+ // SAME ids it persisted — a later re-save then upserts instead of
140
+ // duplicating (chat-message-ids-server-stable).
141
+ id: m.id,
142
+ role: m.role,
143
+ content: m.content,
144
+ parts: (m.channel_data as { parts?: unknown[] } | null)?.parts,
145
+ // Restore the per-message token usage so the context-usage gauge can
146
+ // recompute on reload (the stream attaches it as UIMessage metadata).
147
+ metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata,
148
+ created_at: m.created_at,
149
+ // The ordering field — exposed so the client can pass the oldest
150
+ // loaded message's occurred_at as the loadEarlier cursor.
151
+ occurred_at: m.occurred_at,
152
+ }));
153
+ }
154
+
155
+ export const chatSessionRouter = {
156
+ // createOrRestore — resume the caller's most-recent ACTIVE thread for this
157
+ // agent in the supplied (membership-verified) org, else reactivate the
158
+ // most-recent archived one, else create a new empty thread. Mirrors
159
+ // publicGptSession.createOrRestore's restore logic but keyed on
160
+ // (org, user_id, assistant_type) instead of contact_email.
161
+ createOrRestore: authed
162
+ .input(createOrRestoreSchema)
163
+ .handler(async ({ input, context }) => {
164
+ const { agentId, organisationId: orgId, userId: overrideUserId } = input;
165
+ const prisma = (context as Context).prisma;
166
+ const ctxUser = context.user as SessionUser;
167
+ // SSR serverLoader override: when the caller is the system user AND a
168
+ // real userId is provided, use that instead — so the conversation is
169
+ // created under the real user's identity, not "system".
170
+ const effectiveUserId =
171
+ ctxUser.id === "system" && overrideUserId ? overrideUserId : ctxUser.id;
172
+ const user = { ...ctxUser, id: effectiveUserId };
173
+ const log = getAppLogger();
174
+ const aType = assistantType(agentId);
175
+
176
+ try {
177
+ // Resolve the agent's configured model so the chat footer can show
178
+ // it next to the session ID ("Session ID · <model>"). ai_model is
179
+ // the agent's model key (e.g. "FLASH"), mapped to an llm_model row.
180
+ const agent = await prisma.ai_agents.findUnique({
181
+ where: { id: agentId },
182
+ select: { ai_model: true },
183
+ });
184
+ const model = agent?.ai_model ?? null;
185
+ const candidates = await prisma.conversations.findMany({
186
+ where: {
187
+ channel: "ai",
188
+ organisation_id: orgId,
189
+ user_id: user.id,
190
+ channel_data: { path: ["assistant_type"], equals: aType },
191
+ },
192
+ orderBy: { created_at: "desc" },
193
+ });
194
+
195
+ let existing: any =
196
+ candidates.find((c: any) => c.status === "active") ?? null;
197
+ // A returning user whose only thread is archived → reactivate the
198
+ // most-recent one so their history continues (matches the GPT
199
+ // "session gone" fix).
200
+ if (!existing && candidates.length > 0) {
201
+ existing = candidates[0];
202
+ await prisma.conversations.update({
203
+ where: { id: existing.id },
204
+ data: { status: "active" },
205
+ });
206
+ }
207
+
208
+ if (existing) {
209
+ // Bound to the most-recent RECENT_MESSAGE_LIMIT messages so a heavy
210
+ // thread doesn't pay ~2.5s + ~460KB on every open/prefetch (the
211
+ // prior unbounded load). Older messages paginate via `loadEarlier`.
212
+ // desc+take N+1, then reverse → asc for display; take N+1 detects
213
+ // whether older rows exist (hasMore drives the load-more control).
214
+ const recentRows = await prisma.messages.findMany({
215
+ where: { conversation_id: existing.id },
216
+ orderBy: { occurred_at: "desc" },
217
+ take: RECENT_MESSAGE_LIMIT + 1,
218
+ });
219
+ const hasMore = recentRows.length > RECENT_MESSAGE_LIMIT;
220
+ const windowRows = hasMore
221
+ ? recentRows.slice(0, RECENT_MESSAGE_LIMIT)
222
+ : recentRows;
223
+ const msgs = windowRows.reverse();
224
+ const cd = (existing.channel_data ?? {}) as Record<
225
+ string,
226
+ unknown
227
+ >;
228
+ return {
229
+ conversationId: existing.id,
230
+ model,
231
+ messages: mapMessages(msgs),
232
+ tokenTotals: {
233
+ inputTokens: (cd.total_input_tokens as number) ?? 0,
234
+ outputTokens: (cd.total_output_tokens as number) ?? 0,
235
+ },
236
+ hasMore,
237
+ };
238
+ }
239
+
240
+ const conversation = await prisma.conversations.create({
241
+ data: {
242
+ channel: "ai",
243
+ organisation_id: orgId,
244
+ user_id: user.id,
245
+ title: "New chat",
246
+ status: "active",
247
+ channel_data: {
248
+ assistant_type: aType,
249
+ source: "authed-chat",
250
+ total_input_tokens: 0,
251
+ total_output_tokens: 0,
252
+ },
253
+ ...(user.name || user.email
254
+ ? {
255
+ members: {
256
+ name: user.name ?? null,
257
+ email: user.email ?? null,
258
+ },
259
+ }
260
+ : {}),
261
+ },
262
+ });
263
+
264
+ return {
265
+ conversationId: conversation.id,
266
+ model,
267
+ messages: [],
268
+ tokenTotals: { inputTokens: 0, outputTokens: 0 },
269
+ hasMore: false,
270
+ };
271
+ } catch (err) {
272
+ log.error(
273
+ `[chatSession.createOrRestore] failed: ${err instanceof Error ? err.message : String(err)}`,
274
+ );
275
+ throw err;
276
+ }
277
+ }),
278
+
279
+ // loadEarlier — paginate OLDER messages for a thread (the recent-N window
280
+ // createOrRestore returns is complemented by this). Cursor-based on
281
+ // occurred_at (the ordering field) so a new message appended at the tail
282
+ // never shifts the older window. Ownership is user_id (assertOwns).
283
+ loadEarlier: authed
284
+ .input(loadEarlierSchema)
285
+ .handler(async ({ input, context }) => {
286
+ const prisma = (context as Context).prisma;
287
+ const user = context.user as SessionUser;
288
+ const limit = Math.min(
289
+ input.limit ?? RECENT_MESSAGE_LIMIT,
290
+ RECENT_MESSAGE_LIMIT,
291
+ );
292
+ await assertOwns(prisma, input.conversationId, user);
293
+ const olderRows = await prisma.messages.findMany({
294
+ where: {
295
+ conversation_id: input.conversationId,
296
+ occurred_at: { lt: new Date(input.before) },
297
+ },
298
+ orderBy: { occurred_at: "desc" },
299
+ take: limit + 1,
300
+ });
301
+ const hasMore = olderRows.length > limit;
302
+ const windowRows = hasMore ? olderRows.slice(0, limit) : olderRows;
303
+ const msgs = windowRows.reverse();
304
+ return { messages: mapMessages(msgs), hasMore };
305
+ }),
306
+
307
+ // saveMessages — the onMessagesPersist writer. Stamps occurred_at with a
308
+ // 1ms step preserving array order (created_at is the DB backstop) and
309
+ // stores verbatim UIMessage parts in channel_data.parts. Reuses the GPT
310
+ // saveMessages shape. Ownership is user_id (assertOwns).
311
+ saveMessages: authed
312
+ .input(saveMessagesSchema)
313
+ .handler(async ({ input, context }) => {
314
+ const prisma = (context as Context).prisma;
315
+ const user = context.user as SessionUser;
316
+ const conv = await assertOwns(prisma, input.conversationId, user);
317
+ const orgId = conv.organisation_id;
318
+
319
+ // The write itself lives in ONE shared surface (persist-chat-messages);
320
+ // this router only resolves identity (assertOwns by user_id) above.
321
+ await persistChatMessages(prisma, {
322
+ conversationId: input.conversationId,
323
+ organisationId: orgId,
324
+ messages: input.messages,
325
+ });
326
+ return { success: true };
327
+ }),
328
+
329
+ // clear = START FRESH. Archive the old thread + drop its (now-cleared)
330
+ // messages so it never auto-restores, AND create a brand-new empty thread
331
+ // for the same user+org+agent with a NEW id (deterministic, no reload).
332
+ // Mirrors publicGptSession.clear. Ownership is user_id (assertOwns).
333
+ clear: authed.input(clearSchema).handler(async ({ input, context }) => {
334
+ const prisma = (context as Context).prisma;
335
+ const user = context.user as SessionUser;
336
+ const log = getAppLogger();
337
+
338
+ const old = await assertOwns(prisma, input.conversationId, user);
339
+
340
+ await prisma.messages.deleteMany({
341
+ where: { conversation_id: input.conversationId },
342
+ });
343
+ await prisma.conversations.update({
344
+ where: { id: input.conversationId },
345
+ data: { status: "archived" },
346
+ });
347
+
348
+ const cd = (old.channel_data ?? {}) as Record<string, unknown>;
349
+ const conversation = await prisma.conversations.create({
350
+ data: {
351
+ channel: "ai",
352
+ organisation_id: old.organisation_id,
353
+ user_id: old.user_id,
354
+ title: "New chat",
355
+ status: "active",
356
+ channel_data: {
357
+ assistant_type: cd.assistant_type,
358
+ source: "authed-chat",
359
+ total_input_tokens: 0,
360
+ total_output_tokens: 0,
361
+ },
362
+ ...(old.members ?? undefined ? { members: old.members } : {}),
363
+ },
364
+ });
365
+
366
+ log.info("[chatSession.clear] archived + created fresh", {
367
+ oldId: input.conversationId,
368
+ newId: conversation.id,
369
+ });
370
+ return {
371
+ conversationId: conversation.id,
372
+ messages: [],
373
+ tokenTotals: { inputTokens: 0, outputTokens: 0 },
374
+ };
375
+ }),
376
+
377
+ // list — the user's threads for this agent in the supplied (membership-
378
+ // verified) org, most-recent first. Powers the conversation history list.
379
+ // Includes a short preview from the latest user message in each thread.
380
+ list: authed.input(listSchema).handler(async ({ input, context }) => {
381
+ const prisma = (context as Context).prisma;
382
+ const user = context.user as SessionUser;
383
+ const { agentId, organisationId: orgId } = input;
384
+ const aType = assistantType(agentId);
385
+
386
+ const conversations = await prisma.conversations.findMany({
387
+ where: {
388
+ channel: "ai",
389
+ organisation_id: orgId,
390
+ user_id: user.id,
391
+ channel_data: { path: ["assistant_type"], equals: aType },
392
+ },
393
+ orderBy: { last_message_at: "desc" },
394
+ select: {
395
+ id: true,
396
+ title: true,
397
+ status: true,
398
+ last_message_at: true,
399
+ created_at: true,
400
+ },
401
+ });
402
+
403
+ if (conversations.length === 0) return { conversations: [] };
404
+
405
+ // One grouped fetch for the latest user message per thread (preview),
406
+ // mapped in JS — avoids an N+1.
407
+ const ids = conversations.map((c: any) => c.id);
408
+ const lastMsgs = await prisma.messages.findMany({
409
+ where: { conversation_id: { in: ids }, role: "user" },
410
+ orderBy: { occurred_at: "desc" },
411
+ select: { conversation_id: true, content: true },
412
+ });
413
+ const previewById = new Map<string, string>();
414
+ for (const m of lastMsgs) {
415
+ if (!previewById.has(m.conversation_id)) {
416
+ previewById.set(
417
+ m.conversation_id,
418
+ (m.content ?? "").slice(0, 120),
419
+ );
420
+ }
421
+ }
422
+
423
+ return {
424
+ conversations: conversations.map((c: any) => ({
425
+ id: c.id,
426
+ title: c.title,
427
+ status: c.status,
428
+ last_message_at: c.last_message_at ?? c.created_at,
429
+ preview: previewById.get(c.id) ?? "",
430
+ })),
431
+ };
432
+ }),
433
+
434
+ // load — switch to a past thread from the history list. Returns its full
435
+ // message history (verbatim parts). Ownership is user_id (assertOwns).
436
+ load: authed.input(loadSchema).handler(async ({ input, context }) => {
437
+ const prisma = (context as Context).prisma;
438
+ const user = context.user as SessionUser;
439
+ const conv = await assertOwns(prisma, input.conversationId, user);
440
+
441
+ const msgs = await prisma.messages.findMany({
442
+ where: { conversation_id: input.conversationId },
443
+ orderBy: { occurred_at: "asc" },
444
+ });
445
+ const cd = (conv.channel_data ?? {}) as Record<string, unknown>;
446
+ return {
447
+ conversationId: conv.id,
448
+ messages: mapMessages(msgs),
449
+ tokenTotals: {
450
+ inputTokens: (cd.total_input_tokens as number) ?? 0,
451
+ outputTokens: (cd.total_output_tokens as number) ?? 0,
452
+ },
453
+ };
454
+ }),
455
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Cached reader for the `email-defaults` config row's `default_sender` — the
7
+ * from-address for outbound transactional email (agreement/proposal/assessment
8
+ * senders). Replaces the former process.env.DEFAULT_EMAIL_SENDER read
9
+ * (configured-primitives: the registry row is the source of truth, not env).
10
+ */
11
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
12
+ import * as v from "valibot";
13
+
14
+ const FALLBACK_SENDER = "noreply@gateway.scala.business";
15
+ let cached: string | undefined;
16
+
17
+ export async function getDefaultEmailSender(): Promise<string> {
18
+ if (cached !== undefined) return cached;
19
+ const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
+ cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
+ return cached;
22
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Cached reader for the `email-links` config row's `email_link_ttl_ms` — the
7
+ * validity window for agreement/proposal email-access magic links (dedup'd
8
+ * across the 3 fn-routers that mint them). Replaces the former per-file
9
+ * hardcoded EMAIL_LINK_TTL_MS constant (config-constants: the registry row is
10
+ * the source of truth, not the literal).
11
+ */
12
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
13
+ import * as v from "valibot";
14
+
15
+ const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
16
+ let cached: number | undefined;
17
+
18
+ export async function getEmailLinkTtlMs(): Promise<number> {
19
+ if (cached !== undefined) return cached;
20
+ const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
21
+ cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
22
+ return cached;
23
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Composes auth, tasks, work-items, plans, chat-session, public-gpt-session,
7
+ * public-agreement, and user-preferences routers into the `fn` namespace and
8
+ * registers it via registerCustomRouter.
9
+ * Called at boot by registerFnRouters() — invoked before any ORPC dispatch.
10
+ *
11
+ * Also exposes the `regions` namespace on the client /api/rpc mount: a single
12
+ * `loadPage` procedure (reusing puckPagesRouter.loadFromWebsite) so a client
13
+ * RegionPageSwap can fetch a region's Puck page on demand and render it into a
14
+ * slot — the no-reload half of `region-views-are-pages` (every region is a Puck
15
+ * page; switching a view loads a different page's content). loadById is NOT
16
+ * exposed (website+path is the client-facing contract, not raw page ids).
17
+ */
18
+
19
+ import { registerCustomRouter } from "@teamscala/orpc/app-router";
20
+ import { puckPagesRouter } from "@teamscala/service-boot/page-server-orpc/puck-pages-router";
21
+ import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
22
+ import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
23
+ import { aiHealthHandler } from "./ai-health-router.ts";
24
+ import { resolvePanelDataHandler } from "./panel-data-router.ts";
25
+ import { agreementSendRouter } from "./agreement-send-router.ts";
26
+ import { cacheAdminRouter } from "./cache-admin-router.ts";
27
+ import { chatSessionRouter } from "./chat-session-router.ts";
28
+ import { proposalSendRouter } from "./proposal-send-router.ts";
29
+ import { authRouter } from "./auth-router.ts";
30
+ import { plansRouter } from "./plans-router.ts";
31
+ import { publicAgreementRouter } from "./public-agreement-router.ts";
32
+ import { publicAssessmentRouter } from "./public-assessment-router.ts";
33
+ import { publicGptSessionRouter } from "./public-gpt-session-router.ts";
34
+ import { signInFlowRouter } from "./sign-in-flow-router.ts";
35
+ import { taskSyncRouter } from "./task-sync-router.ts";
36
+ import { tasksRouter } from "./tasks-router.ts";
37
+ import { userPreferencesRouter } from "./user-preferences-router.ts";
38
+ import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
39
+ import { workItemsRouter } from "./work-items-router.ts";
40
+
41
+ export function registerFnRouters(): void {
42
+ registerCustomRouter("fn", {
43
+ aiHealth: aiHealthHandler,
44
+ agreementSend: agreementSendRouter,
45
+ proposalSend: proposalSendRouter,
46
+ auth: authRouter,
47
+ cacheAdmin: cacheAdminRouter,
48
+ chatSession: chatSessionRouter,
49
+ publicAgreement: publicAgreementRouter,
50
+ publicAssessment: publicAssessmentRouter,
51
+ publicGptSession: publicGptSessionRouter,
52
+ signInFlow: signInFlowRouter,
53
+ tasks: tasksRouter,
54
+ taskSync: taskSyncRouter,
55
+ workItems: workItemsRouter,
56
+ userPreferences: userPreferencesRouter,
57
+ portalPreferences: portalPreferencesRouter,
58
+ plans: plansRouter,
59
+ });
60
+ registerCustomRouter("regions", {
61
+ loadPage: puckPagesRouter.loadFromWebsite,
62
+ // Resolve a page-kind panel's OWN loaderData (panel_view.loader_slug) so a
63
+ // panel region renders against the panel page's data, not the host's.
64
+ resolvePanelData: resolvePanelDataHandler,
65
+ });
66
+ registerCustomRouter("realtime", {
67
+ watchDataChange,
68
+ watchPresence,
69
+ });
70
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * `regions.resolvePanelData` — resolves a page-kind panel's OWN loaderData so a
7
+ * panel region renders blocks against the panel page's data, not the host
8
+ * page's. RegionPageSwap calls this on panel-open + injects the result via
9
+ * LiveDataProvider("page-data"), making panel-embedded data blocks (e.g. the
10
+ * workload Chart) self-sufficient: each panel carries its own data, no host
11
+ * coupling + no preloading the host route.
12
+ *
13
+ * Flow (registry reads via the loadRegistryConfig helper — registry-via-helper):
14
+ * panel_view.content.loader_slug → resource_loader config → orgId derived from
15
+ * the serving website (builda_websites.organisation_id) → resolveFromLoaderConfig
16
+ * → the loader's resolved sources. Panels without a loader_slug (e.g. the AI
17
+ * panel, whose block self-loads) return null — no extra round-trip, no behavior
18
+ * change.
19
+ */
20
+ import { os } from "@orpc/server";
21
+ import * as v from "valibot";
22
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
23
+ import { resolveFromLoaderConfig } from "../resolve-from-loader-config.ts";
24
+
25
+ interface Context {
26
+ prisma: {
27
+ builda_websites: {
28
+ findFirst: (args: unknown) => Promise<{
29
+ organisation_id?: string | null;
30
+ } | null>;
31
+ };
32
+ };
33
+ }
34
+
35
+ const panelViewSchema = v.looseObject({
36
+ content: v.looseObject({ loader_slug: v.optional(v.string()) }),
37
+ });
38
+
39
+ export const resolvePanelDataHandler = os
40
+ .$context<Context>()
41
+ .input(
42
+ v.object({
43
+ panelSlug: v.string(),
44
+ websiteSlug: v.string(),
45
+ /** Tenant orgId (preferred over the website's organisation_id, which is
46
+ * the platform org on a multi-tenant portal — not the tenant). The
47
+ * caller (RegionPageSwap) threads this from __PAGE_DATA__.orgId. */
48
+ orgId: v.optional(v.string()),
49
+ }),
50
+ )
51
+ .handler(async ({ input, context }) => {
52
+ // panel_view → loader_slug (null when missing or the panel has no loader).
53
+ let loaderSlug: string | undefined;
54
+ try {
55
+ const pv = await loadRegistryConfig(
56
+ "panel_view",
57
+ input.panelSlug,
58
+ panelViewSchema,
59
+ );
60
+ loaderSlug = pv.content?.loader_slug;
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (!loaderSlug) return null;
65
+ // resource_loader → its config (loose schema; resolveFromLoaderConfig owns
66
+ // the typed LoaderConfig contract).
67
+ let loader: unknown;
68
+ try {
69
+ loader = await loadRegistryConfig(
70
+ "resource_loader",
71
+ loaderSlug,
72
+ v.unknown(),
73
+ );
74
+ } catch {
75
+ return null;
76
+ }
77
+ // Tenant orgId: prefer the caller-provided value (the route's tenant org,
78
+ // threaded from __PAGE_DATA__.orgId); fall back to the website's
79
+ // organisation_id (the platform org — wrong for multi-tenant portals, but
80
+ // the only derivable value when the caller omits it).
81
+ let orgId = input.orgId;
82
+ if (!orgId) {
83
+ const site = await context.prisma.builda_websites.findFirst({
84
+ where: { OR: [{ slug: input.websiteSlug }, { id: input.websiteSlug }] },
85
+ select: { organisation_id: true },
86
+ });
87
+ orgId = site?.organisation_id ?? undefined;
88
+ }
89
+ return await resolveFromLoaderConfig(
90
+ loader as Parameters<typeof resolveFromLoaderConfig>[0],
91
+ { orgId },
92
+ );
93
+ });