@teamscala/service-boot 0.23.479 → 0.23.492

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 (46) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-registry-page-route/region-slots-page-data.ts +2 -2
  7. package/src/register-ws-routes.ts +1 -1
  8. package/src/fn-routers/agreement-certificate.ts +0 -224
  9. package/src/fn-routers/agreement-completion-email.ts +0 -114
  10. package/src/fn-routers/agreement-send-router.ts +0 -303
  11. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  12. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  13. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  14. package/src/fn-routers/assessment-report-data.ts +0 -485
  15. package/src/fn-routers/assessment-report-email.ts +0 -256
  16. package/src/fn-routers/assessment-report-pair.test.ts +0 -107
  17. package/src/fn-routers/assessment-report-pair.ts +0 -155
  18. package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
  19. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  20. package/src/fn-routers/assessment-scoring.ts +0 -105
  21. package/src/fn-routers/auth-router.ts +0 -65
  22. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  23. package/src/fn-routers/chat-session-router.ts +0 -356
  24. package/src/fn-routers/conversation-ownership.ts +0 -24
  25. package/src/fn-routers/email-defaults.ts +0 -28
  26. package/src/fn-routers/email-link-ttl.ts +0 -28
  27. package/src/fn-routers/index.ts +0 -76
  28. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  29. package/src/fn-routers/linkedin-identity.ts +0 -36
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  31. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  32. package/src/fn-routers/panel-data-router.ts +0 -104
  33. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  34. package/src/fn-routers/persist-chat-messages.ts +0 -98
  35. package/src/fn-routers/proposal-send-router.ts +0 -207
  36. package/src/fn-routers/public-agreement-router.ts +0 -407
  37. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  38. package/src/fn-routers/public-assessment-router.ts +0 -268
  39. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  40. package/src/fn-routers/resend-client.ts +0 -39
  41. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  42. package/src/fn-routers/user-preferences-router.ts +0 -130
  43. package/src/fn-routers/work-items-router.ts +0 -342
  44. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  45. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  46. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,378 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC procedures for public GPT session management.
7
- * Creates/restores conversations and messages for public-facing GPT pages.
8
- */
9
-
10
- import { os } from "@orpc/server";
11
- import * as v from "valibot";
12
- import type { PrismaClient } from "@teamscala/db/client";
13
- import { getAppLogger } from "@teamscala/logger/app-loggers";
14
- import { queryRows } from "@teamscala/db/query-rows";
15
- import { getClient } from "@teamscala/object-storage/create/client/get-client";
16
- import { persistChatMessages } from "./persist-chat-messages.ts";
17
-
18
- interface Context {
19
- prisma: PrismaClient;
20
- headers: Headers;
21
- resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
22
- }
23
-
24
- const pub = os.$context<Context>();
25
-
26
- const createOrRestoreSchema = v.object({
27
- contactName: v.string(),
28
- email: v.string(),
29
- phone: v.optional(v.string()),
30
- gptId: v.string(),
31
- orgId: v.string(),
32
- sourceUrl: v.optional(v.string()),
33
- });
34
-
35
- const loadSchema = v.object({
36
- conversationId: v.string(),
37
- });
38
-
39
- const saveMessagesSchema = v.object({
40
- conversationId: v.string(),
41
- messages: v.array(v.object({
42
- // The SDK UIMessage id — persisted AS the row PK so a re-save upserts the
43
- // SAME row instead of inserting a duplicate (chat-message-ids-server-stable).
44
- id: v.optional(v.string()),
45
- role: v.string(),
46
- content: v.string(),
47
- // Verbatim UIMessage parts (apps/chat.md messages-stored-as-uimessage-verbatim).
48
- // Optional so content-only callers still validate. When present we store them
49
- // in channel_data.parts so multi-step (tool-using) turns persist the full
50
- // answer, not just the first text part — matches the portal/engine save path.
51
- parts: v.optional(v.array(v.any())),
52
- // UIMessage metadata — per-message token usage; persisted so the context
53
- // gauge rehydrates on reload (matches the portal save path).
54
- metadata: v.optional(v.record(v.string(), v.any())),
55
- })),
56
- });
57
-
58
- const clearSchema = v.object({
59
- conversationId: v.string(),
60
- });
61
-
62
- const uploadSchema = v.object({
63
- conversationId: v.string(),
64
- fileData: v.string(),
65
- fileName: v.string(),
66
- fileType: v.string(),
67
- fileSize: v.number(),
68
- });
69
-
70
- export const publicGptSessionRouter = {
71
- createOrRestore: pub.input(createOrRestoreSchema).handler(async ({ input, context }) => {
72
- const data = input;
73
- const prisma = context.prisma;
74
- const log = getAppLogger();
75
-
76
- // Email is the session identity, and email is case-insensitive by spec.
77
- // Normalize (trim + lowercase) for BOTH store and match so the SAME
78
- // person re-logging with any casing/whitespace ("Info@x", "info@x ")
79
- // always lands on their existing thread instead of a fresh empty one.
80
- const email = (data.email ?? "").trim().toLowerCase();
81
-
82
- try {
83
- // Find-or-create the contact in client_database — the platform's single
84
- // contact store (per product-pricing-single-source's sibling rule: no
85
- // feature-private contact tables; the retired crm_contacts table was
86
- // exactly that drift). On UPDATE (e.g. a silent auto-restore that carries
87
- // only the server-resolved name and no phone) we must NOT overwrite the
88
- // stored name/phone with empty. Match is org + normalized email; there is
89
- // no unique constraint on that pair, so findFirst + create/update.
90
- const nameParts = (data.contactName ?? "").trim().split(/\s+/);
91
- const firstName = nameParts[0] || "Unknown";
92
- const lastName = nameParts.slice(1).join(" ") || "-";
93
- let contact = await prisma.client_database.findFirst({
94
- where: { organisation_id: data.orgId, primary_email: email },
95
- select: { id: true, full_name: true, mobile_phone: true },
96
- });
97
- if (contact) {
98
- contact = await prisma.client_database.update({
99
- where: { id: contact.id },
100
- data: {
101
- ...(data.contactName
102
- ? { first_name: firstName, last_name: lastName }
103
- : {}),
104
- ...(data.phone ? { mobile_phone: data.phone } : {}),
105
- last_updated_by: "gpt-gate",
106
- },
107
- select: { id: true, full_name: true, mobile_phone: true },
108
- });
109
- } else {
110
- contact = await prisma.client_database.create({
111
- data: {
112
- organisation_id: data.orgId,
113
- first_name: firstName,
114
- last_name: lastName,
115
- primary_email: email,
116
- mobile_phone: data.phone ?? null,
117
- lead_source: "gpt-gate",
118
- data_source: "public-form",
119
- referral_source: data.gptId,
120
- lifecycle_stage: "lead",
121
- created_by: "gpt-gate",
122
- last_updated_by: "gpt-gate",
123
- },
124
- select: { id: true, full_name: true, mobile_phone: true },
125
- });
126
- }
127
- // Canonical identity for prefill — the stored contact wins, so a restore
128
- // that arrived without a phone still surfaces the saved one.
129
- const contactName = contact.full_name ?? data.contactName;
130
- const contactPhone = contact.mobile_phone ?? undefined;
131
-
132
- // Restore the visitor's existing conversation on re-login by EMAIL — regardless of status. Previously this filtered status:'active', so any
133
- // thread that had been archived (e.g. by a prior Clear) was invisible and the visitor got a brand-new empty conversation every time ("session
134
- // gone"). Restore the thread whose MOST RECENT MESSAGE is newest, found
135
- // via the messages themselves (last_message_at is unreliable on older
136
- // threads), reactivating it if archived. Only fall back to an active
137
- // empty thread, then a new one, when the email has no real history.
138
- // Case-insensitive email match (raw SQL on the JSONB field) so old rows
139
- // stored with any casing still match. Prisma's JSON `equals` is
140
- // case-sensitive, which silently fragmented a returning visitor's
141
- // history across near-duplicate threads.
142
- const candidates = await queryRows<{
143
- id: string;
144
- status: string;
145
- channel_data: Record<string, unknown>;
146
- created_at: Date;
147
- }>`
148
- SELECT id, status, channel_data, created_at
149
- FROM conversations
150
- WHERE channel = 'ai'
151
- AND organisation_id = ${data.orgId}
152
- AND channel_data->>'assistant_type' = ${`custom-gpt:${data.gptId}`}
153
- AND lower(trim(channel_data->>'contact_email')) = ${email}
154
- `;
155
-
156
- // Pick the thread to continue. Prefer the most-recent ACTIVE thread by
157
- // creation: after a Clear that is the fresh empty conversation the user
158
- // just created, so a cleared session stays fresh on reload instead of
159
- // jumping back to an old thread with messages (the unreliable session-id
160
- // behaviour reported 2026-06-14 — it previously restored whichever thread
161
- // had the newest MESSAGE, which after a clear is an OLD thread). Only when
162
- // there is NO active thread do we reactivate the most-recent archived one (a
163
- // returning visitor whose thread was archived — preserving the earlier
164
- // "session gone" fix), and finally fall through to creating a new thread.
165
- const byCreatedDesc = (a: (typeof candidates)[number], b: (typeof candidates)[number]) =>
166
- new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
167
- let existing: (typeof candidates)[number] | null =
168
- candidates.filter((c) => c.status === "active").sort(byCreatedDesc)[0] ?? null;
169
- if (!existing && candidates.length > 0) {
170
- existing = [...candidates].sort(byCreatedDesc)[0] ?? null;
171
- if (existing) {
172
- await prisma.conversations.update({
173
- where: { id: existing.id },
174
- data: { status: "active" },
175
- });
176
- }
177
- }
178
-
179
- if (existing) {
180
- const msgs = await prisma.messages.findMany({
181
- where: { conversation_id: existing.id },
182
- orderBy: { created_at: "asc" },
183
- });
184
- const channelData = existing.channel_data as Record<string, unknown>;
185
- return {
186
- conversationId: existing.id,
187
- contactName,
188
- contactPhone,
189
- // Only user/assistant turns reach the client. A stored role:"tool"
190
- // row (written by the prior double-persist path) is not a valid
191
- // UIMessage role and 500s validateUIMessages on the next turn —
192
- // filter it out so already-poisoned conversations restore cleanly.
193
- // Tool output is preserved inside the assistant message's parts[].
194
- messages: msgs
195
- .filter((m: { role: string | null }) => m.role === "user" || m.role === "assistant")
196
- .map((m: { id: string; role: string | null; content: string | null; channel_data: unknown; created_at: Date }) => ({ id: m.id, role: m.role, content: m.content, parts: (m.channel_data as { parts?: unknown[] } | null)?.parts, metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata, created_at: m.created_at })),
197
- tokenTotals: {
198
- inputTokens: (channelData?.total_input_tokens as number) ?? 0,
199
- outputTokens: (channelData?.total_output_tokens as number) ?? 0,
200
- },
201
- };
202
- }
203
-
204
- const conversation = await prisma.conversations.create({
205
- data: {
206
- channel: "ai",
207
- organisation_id: data.orgId,
208
- title: `GPT Chat - ${contactName}`,
209
- status: "active",
210
- channel_data: {
211
- assistant_type: `custom-gpt:${data.gptId}`,
212
- source: "public-gpt",
213
- contact_name: contactName,
214
- contact_email: email,
215
- contact_phone: contactPhone ?? null,
216
- source_url: data.sourceUrl ?? null,
217
- total_input_tokens: 0,
218
- total_output_tokens: 0,
219
- },
220
- members: { name: contactName, email },
221
- },
222
- });
223
-
224
- return {
225
- conversationId: conversation.id,
226
- contactName,
227
- contactPhone,
228
- messages: [],
229
- tokenTotals: { inputTokens: 0, outputTokens: 0 },
230
- };
231
- } catch (err) {
232
- log.error(`[publicGptSession.createOrRestore] failed: ${err instanceof Error ? err.message : String(err)}`);
233
- throw err;
234
- }
235
- }),
236
-
237
- load: pub.input(loadSchema).handler(async ({ input, context }) => {
238
- const data = input;
239
- const prisma = context.prisma;
240
-
241
- const conversation = await prisma.conversations.findUnique({
242
- where: { id: data.conversationId },
243
- });
244
- if (!conversation) return null;
245
-
246
- const msgs = await prisma.messages.findMany({
247
- where: { conversation_id: data.conversationId },
248
- orderBy: { created_at: "asc" },
249
- });
250
-
251
- const channelData = conversation.channel_data as Record<string, unknown>;
252
- return {
253
- conversationId: conversation.id,
254
- contactName: (channelData?.contact_name as string) ?? "",
255
- contactEmail: (channelData?.contact_email as string) ?? "",
256
- messages: msgs.map((m: { id: string; role: string | null; content: string | null; channel_data: unknown; created_at: Date }) => ({ id: m.id, role: m.role, content: m.content, parts: (m.channel_data as { parts?: unknown[] } | null)?.parts, metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata, created_at: m.created_at })),
257
- tokenTotals: {
258
- inputTokens: (channelData?.total_input_tokens as number) ?? 0,
259
- outputTokens: (channelData?.total_output_tokens as number) ?? 0,
260
- },
261
- };
262
- }),
263
-
264
- saveMessages: pub.input(saveMessagesSchema).handler(async ({ input, context }) => {
265
- const data = input;
266
- const prisma = context.prisma;
267
-
268
- const conversation = await prisma.conversations.findUnique({
269
- where: { id: data.conversationId },
270
- });
271
- if (!conversation) return;
272
-
273
- // The write itself lives in ONE shared surface (persist-chat-messages),
274
- // identical to the authed chatSession router; this router only resolves
275
- // the public identity (contact-linked conversation) above.
276
- await persistChatMessages(prisma, {
277
- conversationId: data.conversationId,
278
- organisationId: conversation.organisation_id,
279
- messages: data.messages,
280
- });
281
- }),
282
-
283
- // Clear = START FRESH. Archive the old thread AND create a brand-new empty
284
- // conversation for the same contact, returning its NEW id. The old flow only
285
- // archived + emptied the SAME conversation (id unchanged), and the client then
286
- // called createOrRestore which reactivated an old thread — so the session id
287
- // never changed on clear, then jumped/vanished on reload (the unreliable
288
- // behaviour reported 2026-06-14). Creating+returning the new conversation here
289
- // makes clear atomic and the id deterministic.
290
- clear: pub.input(clearSchema).handler(async ({ input, context }) => {
291
- const data = input;
292
- const prisma = context.prisma;
293
- const log = getAppLogger();
294
-
295
- const old = await prisma.conversations.findUnique({
296
- where: { id: data.conversationId },
297
- });
298
-
299
- // Archive the old thread + drop its (now-cleared) messages so it never
300
- // restores. Audit history is preserved by the messages table's audit triggers.
301
- await prisma.messages.deleteMany({
302
- where: { conversation_id: data.conversationId },
303
- });
304
- await prisma.conversations.update({
305
- where: { id: data.conversationId },
306
- data: { status: "archived" },
307
- });
308
-
309
- if (!old) {
310
- log.error(`[publicGptSession.clear] conversation ${data.conversationId} not found; cannot create fresh`);
311
- throw new Error("conversation not found");
312
- }
313
-
314
- // Carry the old thread's identity into the fresh one.
315
- const cd = (old.channel_data ?? {}) as {
316
- assistant_type?: string;
317
- contact_name?: string | null;
318
- contact_email?: string | null;
319
- contact_phone?: string | null;
320
- source_url?: string | null;
321
- };
322
- const conversation = await prisma.conversations.create({
323
- data: {
324
- channel: "ai",
325
- organisation_id: old.organisation_id,
326
- title: old.title ?? "GPT Chat",
327
- status: "active",
328
- channel_data: {
329
- assistant_type: cd.assistant_type,
330
- source: "public-gpt",
331
- contact_name: cd.contact_name ?? null,
332
- contact_email: cd.contact_email ?? null,
333
- contact_phone: cd.contact_phone ?? null,
334
- source_url: cd.source_url ?? null,
335
- total_input_tokens: 0,
336
- total_output_tokens: 0,
337
- },
338
- members: old.members ?? undefined,
339
- },
340
- });
341
-
342
- return {
343
- conversationId: conversation.id,
344
- contactName: (cd.contact_name as string) ?? "",
345
- contactPhone: (cd.contact_phone as string | undefined) ?? undefined,
346
- messages: [] as Array<{ role: string; content: string; parts?: unknown[] }>,
347
- tokenTotals: { inputTokens: 0, outputTokens: 0 },
348
- };
349
- }),
350
-
351
- checkIdentity: pub.input(v.object({ email: v.string() })).handler(async ({ input, context }) => {
352
- const existing = await context.prisma.user.findFirst({
353
- where: { email: input.email },
354
- select: { id: true },
355
- });
356
- return { isPlatformUser: !!existing };
357
- }),
358
-
359
- upload: pub.input(uploadSchema).handler(async ({ input }) => {
360
- const log = getAppLogger();
361
- const s3 = await getClient();
362
- if (!s3) {
363
- log.error("[publicGptSession.upload] S3 client not available");
364
- throw new Error("File storage unavailable");
365
- }
366
-
367
- const key = `gpt/${input.conversationId}/${Date.now()}-${input.fileName}`;
368
- const buffer = Buffer.from(input.fileData, "base64");
369
-
370
- await s3.file(key).write(buffer, {
371
- type: input.fileType,
372
- });
373
-
374
- const url = `/api/assets/${key}`;
375
- log.info("[publicGptSession.upload] File uploaded", { key, size: input.fileSize, type: input.fileType });
376
- return { success: true as const, url, type: input.fileType };
377
- }),
378
- };
@@ -1,39 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The fn-router Resend send: a per-call adapter (org rest_client token)
7
- * carrying the {success, id?, error?} shape the routers consume. Replaces
8
- * the module-global @teamscala/api-resend/send-email wrapper a regen wiped;
9
- * failure routes through safe-fallback (fail-open-noisy).
10
- */
11
- import { createResendAdapter, emailsSend } from "@teamscala/api-resend";
12
- import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
13
-
14
- /** The Resend send body (from/to/subject + html/text/reply_to) — forwarded
15
- * to the adapter operation verbatim; nothing is assembled here. Index 2 is
16
- * the body arg; index 1 is the spec-derived params (headers) object. */
17
- export type ResendSendBody = Parameters<typeof emailsSend>[2];
18
-
19
- export interface ResendSendResult {
20
- success: boolean;
21
- id?: string;
22
- error?: string;
23
- }
24
-
25
- const sendLayer = createSafeFallback<ResendSendResult>({
26
- name: "service-boot:resend-send",
27
- onFailure: "fail-open-noisy",
28
- degradedResult: { success: false, error: "resend send layer degraded" },
29
- failureBudget: 5,
30
- });
31
-
32
- export async function sendResendEmail(token: string, body: ResendSendBody): Promise<ResendSendResult> {
33
- const result = await sendLayer.call(async () => {
34
- const client = createResendAdapter({ apiKey: token });
35
- const res = await emailsSend(client, {}, body);
36
- return { success: true, id: res.id } satisfies ResendSendResult;
37
- });
38
- return result;
39
- }
@@ -1,196 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * `fn.signInFlow` — typed ORPC procedures for the sign-in flow's client-facing
7
- * reads (flow config, session check, portal list). Replaces the former
8
- * `/api/data/{sign-in-flow-config,check-session,portals}` HANDLER_MAP entries
9
- * from the deleted register-data-routes.ts (orpc-is-the-only-api: every API surface is a
10
- * typed ORPC procedure, never a hand-written Hono route). Logic ported
11
- * verbatim from those handlers — these are anonymous-allowed reads (they
12
- * return an empty result for an unauthenticated caller, never throw), so they
13
- * use a public base procedure reading `context.headers` for the session, NOT
14
- * the `authed` middleware (which throws UNAUTHORIZED).
15
- *
16
- * EXPAND phase: this router is ADDITIVE — registered into the `fn` namespace
17
- * but not yet retargeted by @teamscala/auth's use-sign-in-flow client (which
18
- * still hits the /api/data/* routes). The HANDLER_MAP entries stay until the
19
- * client retarget + round-trip verify land (post-incident, when the auth-flow
20
- * test harness can run), then are removed (CONTRACT).
21
- */
22
-
23
- import { os } from "@orpc/server";
24
- import type { Context } from "./auth-router.ts";
25
-
26
- // Public base procedure (anonymous-allowed): carries the request headers so the
27
- // handler can resolve the session the same way the old Hono handlers did.
28
- const c = os.$context<Context>();
29
-
30
- // ServerClient (getServerClient) is the generic typed base client — it carries
31
- // no per-service model names, so the org+portal reads below narrow it to the
32
- // exact procedure shape they call. Precise inline cast, not `any` (mirrors the
33
- // sibling fn-routers' Prisma-cast pattern in panel-data/portal-diagnostics).
34
- type PortalWebsitesClient = {
35
- builda_websites: {
36
- findMany: (args: unknown) => Promise<unknown>;
37
- };
38
- };
39
-
40
- export const signInFlowRouter = {
41
- // Mirrors /api/data/sign-in-flow-config — returns the flow config row.
42
- getSignInFlowConfig: c.handler(async () => {
43
- const { loadRegistryConfig } = await import(
44
- "@teamscala/db/registry/load-config"
45
- );
46
- const { SignInFlowConfigSchema } = await import(
47
- "@teamscala/db-validation/registry-schemas/sign-in-flow-config"
48
- );
49
- return loadRegistryConfig(
50
- "config",
51
- "sign-in-flow-config",
52
- SignInFlowConfigSchema,
53
- );
54
- }),
55
-
56
- // Mirrors /api/data/check-session — { user, redirectUrl, portalCount } for a
57
- // logged-in user with a resolved org + active portal; {} otherwise.
58
- checkSession: c.handler(async ({ context }) => {
59
- const { getAppLogger } = await import("@teamscala/logger/app-loggers");
60
- try {
61
- const { getSession } = await import("@teamscala/auth/get-session");
62
- const { buildPortalRedirectUrl } = await import(
63
- "@teamscala/auth/sign-in-flow-config/build-portal-redirect-url"
64
- );
65
- const { SignInFlowConfigSchema } = await import(
66
- "@teamscala/db-validation/registry-schemas/sign-in-flow-config"
67
- );
68
- const { loadRegistryConfig } = await import(
69
- "@teamscala/db/registry/load-config"
70
- );
71
- const { getServerClient } = await import(
72
- "@teamscala/orpc/server-client-registry"
73
- );
74
- const { queryRowsUnsafe } = await import("@teamscala/db/query-rows");
75
-
76
- // Canonical session read (NOT wrapped in `.data`); {} for no user.
77
- const session = await getSession(context.headers);
78
- if (!session?.user) return {};
79
-
80
- // fn_resolve_user_org_id returns JSONB { orgId } — extract inner field.
81
- const orgResult = (await queryRowsUnsafe(
82
- 'SELECT fn_resolve_user_org_id($1) AS "resolved"',
83
- session.user.id,
84
- )) as Array<{ resolved: { orgId: string | null } | null }>;
85
- const orgId = orgResult?.[0]?.resolved?.orgId;
86
- if (!orgId) return {};
87
-
88
- // System (no-arg) server client: org+portal reads are explicit-id
89
- // filtered, not RLS-reliant (mirrors the page-server checkSession).
90
- const orpcClient = (await getServerClient()) as unknown as PortalWebsitesClient;
91
- const portals = (await orpcClient.builda_websites.findMany({
92
- where: {
93
- website_type: "portal",
94
- status: { in: ["active", "published"] },
95
- organisation_id: orgId,
96
- },
97
- select: { id: true, portal_type: true },
98
- })) as Array<{ id: string; portal_type: string }>;
99
- if (!portals || portals.length === 0) return {};
100
-
101
- const portal = portals[0];
102
- const isOrgSplit = portal.portal_type === "org";
103
- const flowConfig = await loadRegistryConfig(
104
- "config",
105
- "sign-in-flow-config",
106
- SignInFlowConfigSchema,
107
- );
108
- const redirectUrl = buildPortalRedirectUrl(
109
- flowConfig,
110
- portal.id,
111
- orgId,
112
- isOrgSplit,
113
- );
114
- return {
115
- user: session.user,
116
- redirectUrl,
117
- portalCount: portals.length,
118
- };
119
- } catch (error) {
120
- // HTTP backstop only — never 500 the session-check endpoint.
121
- getAppLogger().error("[fn.signInFlow.checkSession] error", { error });
122
- return {};
123
- }
124
- }),
125
-
126
- // Mirrors /api/data/portals — { portals, userOrgId, userName } for a
127
- // logged-in user with a resolved org; empty portals otherwise.
128
- listPortals: c.handler(async ({ context }) => {
129
- const { getAppLogger } = await import("@teamscala/logger/app-loggers");
130
- try {
131
- const { getSession } = await import("@teamscala/auth/get-session");
132
- const { queryRowsUnsafe } = await import("@teamscala/db/query-rows");
133
- const { getServerClient } = await import(
134
- "@teamscala/orpc/server-client-registry"
135
- );
136
-
137
- const session = await getSession(context.headers);
138
- if (!session?.user) {
139
- return { portals: [], userOrgId: null, userName: null };
140
- }
141
-
142
- const orgResult = (await queryRowsUnsafe(
143
- 'SELECT fn_resolve_user_org_id($1) AS "resolved"',
144
- session.user.id,
145
- )) as Array<{ resolved: { orgId: string | null } | null }>;
146
- const orgId = orgResult?.[0]?.resolved?.orgId;
147
- if (!orgId) {
148
- return {
149
- portals: [],
150
- userOrgId: null,
151
- userName: session.user.name ?? null,
152
- };
153
- }
154
-
155
- const orpcClient = (await getServerClient({
156
- userId: session.user.id,
157
- isSuperAdmin:
158
- (session.user as { isSuperAdmin?: boolean }).isSuperAdmin ?? false,
159
- })) as unknown as PortalWebsitesClient;
160
- const websites = (await orpcClient.builda_websites.findMany({
161
- where: {
162
- website_type: "portal",
163
- status: { in: ["active", "published"] },
164
- organisation_id: orgId,
165
- },
166
- select: {
167
- id: true,
168
- company_friendly_name: true,
169
- slug: true,
170
- status: true,
171
- website_type: true,
172
- organisation_id: true,
173
- portal_type: true,
174
- },
175
- })) as Array<Record<string, unknown>>;
176
-
177
- const portals = (websites ?? []).map((w) => ({
178
- id: w.id,
179
- name: w.company_friendly_name ?? "",
180
- slug: w.slug ?? null,
181
- status: w.status ?? null,
182
- website_type: w.website_type ?? null,
183
- organisation_id: w.organisation_id ?? null,
184
- portal_type: w.portal_type ?? null,
185
- }));
186
- return {
187
- portals,
188
- userOrgId: orgId,
189
- userName: session.user.name ?? null,
190
- };
191
- } catch (error) {
192
- getAppLogger().error("[fn.signInFlow.listPortals] error", { error });
193
- return { portals: [], userOrgId: null, userName: null };
194
- }
195
- }),
196
- };