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