@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.
- package/package.json +1 -261
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/build-client-assets/styles-build.ts +11 -1
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-page-server-client.ts +1 -15
- package/src/register-registry-page-route/region-slots-page-data.ts +2 -2
- package/src/register-ws-routes.ts +1 -1
- package/src/fn-routers/agreement-certificate.ts +0 -224
- package/src/fn-routers/agreement-completion-email.ts +0 -114
- package/src/fn-routers/agreement-send-router.ts +0 -303
- package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
- package/src/fn-routers/agreement-signing-evidence.ts +0 -105
- package/src/fn-routers/assessment-report-data.test.ts +0 -404
- package/src/fn-routers/assessment-report-data.ts +0 -485
- package/src/fn-routers/assessment-report-email.ts +0 -256
- package/src/fn-routers/assessment-report-pair.test.ts +0 -107
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
- package/src/fn-routers/assessment-research-report-data.ts +0 -424
- package/src/fn-routers/assessment-scoring.ts +0 -105
- package/src/fn-routers/auth-router.ts +0 -65
- package/src/fn-routers/chat-session-router-shared.ts +0 -122
- package/src/fn-routers/chat-session-router.ts +0 -356
- package/src/fn-routers/conversation-ownership.ts +0 -24
- package/src/fn-routers/email-defaults.ts +0 -28
- package/src/fn-routers/email-link-ttl.ts +0 -28
- package/src/fn-routers/index.ts +0 -76
- package/src/fn-routers/linkedin-identity.test.ts +0 -27
- package/src/fn-routers/linkedin-identity.ts +0 -36
- package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
- package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
- package/src/fn-routers/panel-data-router.ts +0 -104
- package/src/fn-routers/persist-chat-messages.test.ts +0 -86
- package/src/fn-routers/persist-chat-messages.ts +0 -98
- package/src/fn-routers/proposal-send-router.ts +0 -207
- package/src/fn-routers/public-agreement-router.ts +0 -407
- package/src/fn-routers/public-assessment-router.test.ts +0 -437
- package/src/fn-routers/public-assessment-router.ts +0 -268
- package/src/fn-routers/public-gpt-session-router.ts +0 -378
- package/src/fn-routers/resend-client.ts +0 -39
- package/src/fn-routers/sign-in-flow-router.ts +0 -196
- package/src/fn-routers/user-preferences-router.ts +0 -130
- package/src/fn-routers/work-items-router.ts +0 -342
- package/src/page-server-orpc/audit-revert-router.ts +0 -102
- package/src/page-server-orpc/puck-pages-router.ts +0 -170
- 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
|
-
};
|