@teamscala/service-boot 0.23.481 → 0.23.493

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 (45) 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-ws-routes.ts +1 -1
  7. package/src/fn-routers/agreement-certificate.ts +0 -224
  8. package/src/fn-routers/agreement-completion-email.ts +0 -114
  9. package/src/fn-routers/agreement-send-router.ts +0 -303
  10. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  11. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  12. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  13. package/src/fn-routers/assessment-report-data.ts +0 -485
  14. package/src/fn-routers/assessment-report-email.ts +0 -256
  15. package/src/fn-routers/assessment-report-pair.test.ts +0 -136
  16. package/src/fn-routers/assessment-report-pair.ts +0 -155
  17. package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
  18. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  19. package/src/fn-routers/assessment-scoring.ts +0 -105
  20. package/src/fn-routers/auth-router.ts +0 -65
  21. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  22. package/src/fn-routers/chat-session-router.ts +0 -356
  23. package/src/fn-routers/conversation-ownership.ts +0 -24
  24. package/src/fn-routers/email-defaults.ts +0 -28
  25. package/src/fn-routers/email-link-ttl.ts +0 -28
  26. package/src/fn-routers/index.ts +0 -76
  27. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  28. package/src/fn-routers/linkedin-identity.ts +0 -36
  29. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  30. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  31. package/src/fn-routers/panel-data-router.ts +0 -104
  32. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  33. package/src/fn-routers/persist-chat-messages.ts +0 -98
  34. package/src/fn-routers/proposal-send-router.ts +0 -207
  35. package/src/fn-routers/public-agreement-router.ts +0 -407
  36. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  37. package/src/fn-routers/public-assessment-router.ts +0 -268
  38. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  39. package/src/fn-routers/resend-client.ts +0 -39
  40. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  41. package/src/fn-routers/user-preferences-router.ts +0 -130
  42. package/src/fn-routers/work-items-router.ts +0 -342
  43. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  44. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  45. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,105 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Public assessment (quiz) scoring: the stored-question shape, the scale/choice
7
- * earned-vs-max rules, per-category + total scores, and the stable stage-band
8
- * slug derivation.
9
- *
10
- * Scoring (recovered from live data, 2026-06-13): each scale answer contributes
11
- * (scaleValue - 1) earned out of 4 max (1–5 scale → 0–4); choice answers
12
- * contribute the selected option's points out of the question's max option
13
- * points. A category's score is round(100 * earned / max) over that category's
14
- * questions; the overall total is the same over all questions. Unanswered
15
- * questions count toward the denominator (contribute 0) — matches the historical
16
- * rows (e.g. Σ(v-1)=78 over 36 questions → 78/144 = 54).
17
- *
18
- * Split out of fn-routers/public-assessment-router.ts (591 lines) —
19
- * byte-for-byte move; import paths adjusted only.
20
- */
21
-
22
- export interface StoredQuestion {
23
- id: string;
24
- category?: string | null;
25
- type?: string | null;
26
- weight?: number | null;
27
- options?: Array<{ value: string; points: number }> | null;
28
- }
29
-
30
- /** Scale answers are 1–5; earned = value-1, max = 4. */
31
- const SCALE_MAX = 4;
32
-
33
- /**
34
- * A stage band names itself; the event tag needs a stable slug. Derived from
35
- * the authored name rather than stored beside it, so the two cannot drift.
36
- */
37
- export function toBandSlug(name: string): string {
38
- return name
39
- .toLowerCase()
40
- .replace(/[^a-z0-9]+/g, "-")
41
- .replace(/^-+|-+$/g, "");
42
- }
43
-
44
- export function scoreAnswers(
45
- questions: StoredQuestion[],
46
- answers: Array<{
47
- questionId: string;
48
- scaleValue?: number;
49
- choiceValue?: string;
50
- choiceValues?: string[];
51
- }>,
52
- ): { totalScore: number; categoryScores: Record<string, number> } {
53
- const answerByQ = new Map(answers.map((a) => [a.questionId, a]));
54
- // Per-category and overall earned/max accumulators.
55
- const cat = new Map<string, { earned: number; max: number }>();
56
- let totalEarned = 0;
57
- let totalMax = 0;
58
-
59
- for (const q of questions) {
60
- if (q.type === "text") continue; // text questions are qualitative — never scored
61
- const category = q.category || "General";
62
- const a = answerByQ.get(q.id);
63
- let earned = 0;
64
- let max = 0;
65
-
66
- if (q.options && q.options.length > 0) {
67
- // Choice question: max is the highest single option's points; earned is
68
- // the selected option(s)' points (capped at max).
69
- max = Math.max(0, ...q.options.map((o) => o.points || 0));
70
- if (a?.choiceValue) {
71
- earned = q.options.find((o) => o.value === a.choiceValue)?.points ?? 0;
72
- } else if (a?.choiceValues?.length) {
73
- earned = Math.min(
74
- max,
75
- a.choiceValues.reduce(
76
- (s, val) =>
77
- s + (q.options?.find((o) => o.value === val)?.points ?? 0),
78
- 0,
79
- ),
80
- );
81
- }
82
- } else {
83
- // Scale question (the common case): 1–5 → earned 0–4.
84
- max = SCALE_MAX;
85
- if (typeof a?.scaleValue === "number") {
86
- earned = Math.max(0, Math.min(SCALE_MAX, a.scaleValue - 1));
87
- }
88
- }
89
-
90
- if (max <= 0) continue; // text/unscored questions don't affect the score
91
- const entry = cat.get(category) ?? { earned: 0, max: 0 };
92
- entry.earned += earned;
93
- entry.max += max;
94
- cat.set(category, entry);
95
- totalEarned += earned;
96
- totalMax += max;
97
- }
98
-
99
- const categoryScores: Record<string, number> = {};
100
- for (const [name, { earned, max }] of cat) {
101
- categoryScores[name] = max > 0 ? Math.round((100 * earned) / max) : 0;
102
- }
103
- const totalScore = totalMax > 0 ? Math.round((100 * totalEarned) / totalMax) : 0;
104
- return { totalScore, categoryScores };
105
- }
@@ -1,65 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC `authed` middleware shared by the fn-routers NOT YET row-composed:
7
- * session resolve → UNAUTHORIZED → setRlsContext → orgId injection. The same
8
- * logic the generated fn.ts renders per service (orpc-bundle emits fn.ts
9
- * from api_route rows; consult that file when adding a procedure). This
10
- * handwritten copy will vanish with the last remaining family.
11
- *
12
- * isOrgAdmin + resolveAdminOverride moved to @teamscala/db/org-admin and are
13
- * ROW-composed as fn.auth.* procedures now.
14
- */
15
-
16
- import { os } from "@orpc/server";
17
- import type { PrismaClient } from "@teamscala/db/client";
18
- import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
19
-
20
- interface User {
21
- id: string;
22
- name?: string | null;
23
- email?: string;
24
- organisationId: string | null;
25
- }
26
-
27
- interface Context {
28
- user: User | null | undefined;
29
- headers: Headers;
30
- resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
31
- prisma: PrismaClient;
32
- orgId?: string | null;
33
- }
34
-
35
- const authed = os.$context<Context>().use(async (opt) => {
36
- const { context } = opt;
37
- let user: User | null | undefined = context.user;
38
- if (!user && context.headers && context.resolveUser) {
39
- user = await context.resolveUser(context.headers, context.prisma);
40
- }
41
- if (!user) {
42
- const { ORPCError } = await import("@orpc/server");
43
- throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
44
- }
45
- const orgId = user.organisationId || context.orgId;
46
- // Audit attribution: fn procedures are custom-named writes/actions
47
- // (deleteEmpty, etc.), so detecting "is write" by method name is unreliable —
48
- // set the audit session context (app.current_user_name) for every authed call
49
- // so the DB audit trigger records the actor. Same bridge the generated CRUD
50
- // protectedProcedure uses; reset in finally to avoid leaking onto the pooled
51
- // connection. Without this, fn.* writes record an empty actor ("by .").
52
- await setRlsContext(context.prisma, {
53
- userId: user.id,
54
- userName: user.name || user.email,
55
- organisationId: orgId,
56
- });
57
- try {
58
- return await opt.next({ context: { ...context, user, orgId } });
59
- } finally {
60
- await resetRlsContext(context.prisma);
61
- }
62
- });
63
-
64
- export type { Context, User };
65
- export { authed };
@@ -1,122 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The valibot schemas + row helpers of the chatSession ORPC router: procedure
7
- * input schemas, and the stored-row → wire message mapping.
8
- *
9
- * Split out of fn-routers/chat-session-router.ts (455 lines) — byte-for-byte
10
- * move; import paths adjusted only. Conversation ownership lives in its own
11
- * sibling, ./conversation-ownership.ts (single-canonical-unit).
12
- */
13
-
14
- import * as v from "valibot";
15
-
16
- /** channel_data discriminator carrying the agent this thread belongs to. */
17
- export const assistantType = (agentId: string) => `chat:${agentId}`;
18
-
19
- /**
20
- * How many recent messages createOrRestore returns. The full history was
21
- * previously loaded (unbounded) — a heavy thread paid ~2.5s + ~460KB on every
22
- * open/prefetch. Older messages paginate via `loadEarlier`. Adjustable; when a
23
- * tunable, lift to a registry config row (config-constants-db-driven).
24
- */
25
- export const RECENT_MESSAGE_LIMIT = 50;
26
-
27
- export const createOrRestoreSchema = v.object({
28
- agentId: v.string(),
29
- organisationId: v.string(),
30
- /**
31
- * SSR-only: the real user's id, passed by the AiChat serverLoader. The
32
- * in-process server client is system-authed (context.user.id === "system"),
33
- * so without this the conversation is created under user_id="system" and
34
- * the real user's saveMessages hits assertOwns → FORBIDDEN. Only honoured
35
- * when the caller is the system user (server-side SSR), never from a
36
- * client-side ORPC call.
37
- */
38
- userId: v.optional(v.string()),
39
- });
40
- export const loadSchema = v.object({
41
- conversationId: v.string(),
42
- });
43
- export const saveMessagesSchema = v.object({
44
- conversationId: v.string(),
45
- messages: v.array(
46
- v.object({
47
- // The SDK UIMessage id — persisted AS the row PK so a reload/retry/
48
- // dual-write upserts the SAME row instead of inserting a duplicate
49
- // (chat-message-ids-server-stable). The duplicate truncated-assistant
50
- // rows came from create-without-id: each save inserted a fresh row the
51
- // next save could never reconcile against.
52
- id: v.optional(v.string()),
53
- role: v.string(),
54
- content: v.string(),
55
- // Verbatim UIMessage parts (messages-stored-as-uimessage-verbatim).
56
- parts: v.optional(v.array(v.any())),
57
- // UIMessage metadata — per-message token usage (inputTokens/
58
- // outputTokens/contextWindow) the stream attaches; persisted so the
59
- // context-usage gauge rehydrates on reload (was dropped before).
60
- metadata: v.optional(v.record(v.string(), v.any())),
61
- }),
62
- ),
63
- });
64
- export const clearSchema = v.object({
65
- conversationId: v.string(),
66
- });
67
- export const listSchema = v.object({
68
- agentId: v.string(),
69
- organisationId: v.string(),
70
- });
71
- export const loadEarlierSchema = v.object({
72
- conversationId: v.string(),
73
- /** Cursor: the occurred_at (ISO) of the oldest currently-loaded message. */
74
- before: v.pipe(
75
- v.string(),
76
- v.isoTimestamp(),
77
- ),
78
- /** Cap; defaults to RECENT_MESSAGE_LIMIT, clamped to it. */
79
- limit: v.optional(v.number()),
80
- });
81
-
82
- export interface SessionUser {
83
- id: string;
84
- name?: string | null;
85
- email?: string;
86
- }
87
-
88
- export async function throwForbidden(message: string): Promise<never> {
89
- const { ORPCError } = await import("@orpc/server");
90
- throw new ORPCError("FORBIDDEN", { message });
91
- }
92
-
93
- /** Map stored message rows to the wire shape the chat client restores. */
94
- export function mapMessages(
95
- msgs: Array<{
96
- id: string;
97
- role: string | null;
98
- content: string | null;
99
- channel_data: unknown;
100
- created_at: Date | null;
101
- occurred_at: Date | null;
102
- }>,
103
- ) {
104
- return msgs
105
- .filter((m) => m.role === "user" || m.role === "assistant")
106
- .map((m) => ({
107
- // Restore the stable row id so the client re-seeds useChat with the
108
- // SAME ids it persisted — a later re-save then upserts instead of
109
- // duplicating (chat-message-ids-server-stable).
110
- id: m.id,
111
- role: m.role,
112
- content: m.content,
113
- parts: (m.channel_data as { parts?: unknown[] } | null)?.parts,
114
- // Restore the per-message token usage so the context-usage gauge can
115
- // recompute on reload (the stream attaches it as UIMessage metadata).
116
- metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata,
117
- created_at: m.created_at,
118
- // The ordering field — exposed so the client can pass the oldest
119
- // loaded message's occurred_at as the loadEarlier cursor.
120
- occurred_at: m.occurred_at,
121
- }));
122
- }
@@ -1,356 +0,0 @@
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
- import {
40
- assistantType,
41
- clearSchema,
42
- createOrRestoreSchema,
43
- listSchema,
44
- loadEarlierSchema,
45
- loadSchema,
46
- mapMessages,
47
- RECENT_MESSAGE_LIMIT,
48
- saveMessagesSchema,
49
- SessionUser,
50
- throwForbidden,
51
- } from "./chat-session-router-shared.ts";
52
- import { assertOwns } from "./conversation-ownership.ts";
53
-
54
- export const chatSessionRouter = {
55
- // createOrRestore — resume the caller's most-recent ACTIVE thread for this
56
- // agent in the supplied (membership-verified) org, else reactivate the
57
- // most-recent archived one, else create a new empty thread. Mirrors
58
- // publicGptSession.createOrRestore's restore logic but keyed on
59
- // (org, user_id, assistant_type) instead of contact_email.
60
- createOrRestore: authed
61
- .input(createOrRestoreSchema)
62
- .handler(async ({ input, context }) => {
63
- const { agentId, organisationId: orgId, userId: overrideUserId } = input;
64
- const prisma = (context as Context).prisma;
65
- const ctxUser = context.user as SessionUser;
66
- // SSR serverLoader override: when the caller is the system user AND a
67
- // real userId is provided, use that instead — so the conversation is
68
- // created under the real user's identity, not "system".
69
- const effectiveUserId =
70
- ctxUser.id === "system" && overrideUserId ? overrideUserId : ctxUser.id;
71
- const user = { ...ctxUser, id: effectiveUserId };
72
- const log = getAppLogger();
73
- const aType = assistantType(agentId);
74
-
75
- try {
76
- // Resolve the agent's configured model so the chat footer can show
77
- // it next to the session ID ("Session ID · <model>"). ai_model is
78
- // the agent's model key (e.g. "FLASH"), mapped to an llm_model row.
79
- const agent = await prisma.ai_agents.findUnique({
80
- where: { id: agentId },
81
- select: { ai_model: true },
82
- });
83
- const model = agent?.ai_model ?? null;
84
- const candidates = await prisma.conversations.findMany({
85
- where: {
86
- channel: "ai",
87
- organisation_id: orgId,
88
- user_id: user.id,
89
- channel_data: { path: ["assistant_type"], equals: aType },
90
- },
91
- orderBy: { created_at: "desc" },
92
- });
93
-
94
- let existing =
95
- candidates.find((c) => c.status === "active") ?? null;
96
- // A returning user whose only thread is archived → reactivate the
97
- // most-recent one so their history continues (matches the GPT
98
- // "session gone" fix).
99
- if (!existing && candidates.length > 0) {
100
- existing = candidates[0];
101
- await prisma.conversations.update({
102
- where: { id: existing.id },
103
- data: { status: "active" },
104
- });
105
- }
106
-
107
- if (existing) {
108
- // Bound to the most-recent RECENT_MESSAGE_LIMIT messages so a heavy
109
- // thread doesn't pay ~2.5s + ~460KB on every open/prefetch (the
110
- // prior unbounded load). Older messages paginate via `loadEarlier`.
111
- // desc+take N+1, then reverse → asc for display; take N+1 detects
112
- // whether older rows exist (hasMore drives the load-more control).
113
- const recentRows = await prisma.messages.findMany({
114
- where: { conversation_id: existing.id },
115
- orderBy: { occurred_at: "desc" },
116
- take: RECENT_MESSAGE_LIMIT + 1,
117
- });
118
- const hasMore = recentRows.length > RECENT_MESSAGE_LIMIT;
119
- const windowRows = hasMore
120
- ? recentRows.slice(0, RECENT_MESSAGE_LIMIT)
121
- : recentRows;
122
- const msgs = windowRows.reverse();
123
- const cd = (existing.channel_data ?? {}) as Record<
124
- string,
125
- unknown
126
- >;
127
- return {
128
- conversationId: existing.id,
129
- model,
130
- messages: mapMessages(msgs),
131
- tokenTotals: {
132
- inputTokens: (cd.total_input_tokens as number) ?? 0,
133
- outputTokens: (cd.total_output_tokens as number) ?? 0,
134
- },
135
- hasMore,
136
- };
137
- }
138
-
139
- const conversation = await prisma.conversations.create({
140
- data: {
141
- channel: "ai",
142
- organisation_id: orgId,
143
- user_id: user.id,
144
- title: "New chat",
145
- status: "active",
146
- channel_data: {
147
- assistant_type: aType,
148
- source: "authed-chat",
149
- total_input_tokens: 0,
150
- total_output_tokens: 0,
151
- },
152
- ...(user.name || user.email
153
- ? {
154
- members: {
155
- name: user.name ?? null,
156
- email: user.email ?? null,
157
- },
158
- }
159
- : {}),
160
- },
161
- });
162
-
163
- return {
164
- conversationId: conversation.id,
165
- model,
166
- messages: [],
167
- tokenTotals: { inputTokens: 0, outputTokens: 0 },
168
- hasMore: false,
169
- };
170
- } catch (err) {
171
- log.error(
172
- `[chatSession.createOrRestore] failed: ${err instanceof Error ? err.message : String(err)}`,
173
- );
174
- throw err;
175
- }
176
- }),
177
-
178
- // loadEarlier — paginate OLDER messages for a thread (the recent-N window
179
- // createOrRestore returns is complemented by this). Cursor-based on
180
- // occurred_at (the ordering field) so a new message appended at the tail
181
- // never shifts the older window. Ownership is user_id (assertOwns).
182
- loadEarlier: authed
183
- .input(loadEarlierSchema)
184
- .handler(async ({ input, context }) => {
185
- const prisma = (context as Context).prisma;
186
- const user = context.user as SessionUser;
187
- const limit = Math.min(
188
- input.limit ?? RECENT_MESSAGE_LIMIT,
189
- RECENT_MESSAGE_LIMIT,
190
- );
191
- await assertOwns(prisma, input.conversationId, user);
192
- const olderRows = await prisma.messages.findMany({
193
- where: {
194
- conversation_id: input.conversationId,
195
- occurred_at: { lt: new Date(input.before) },
196
- },
197
- orderBy: { occurred_at: "desc" },
198
- take: limit + 1,
199
- });
200
- const hasMore = olderRows.length > limit;
201
- const windowRows = hasMore ? olderRows.slice(0, limit) : olderRows;
202
- const msgs = windowRows.reverse();
203
- return { messages: mapMessages(msgs), hasMore };
204
- }),
205
-
206
- // saveMessages — the onMessagesPersist writer. Stamps occurred_at with a
207
- // 1ms step preserving array order (created_at is the DB backstop) and
208
- // stores verbatim UIMessage parts in channel_data.parts. Reuses the GPT
209
- // saveMessages shape. Ownership is user_id (assertOwns).
210
- saveMessages: authed
211
- .input(saveMessagesSchema)
212
- .handler(async ({ input, context }) => {
213
- const prisma = (context as Context).prisma;
214
- const user = context.user as SessionUser;
215
- const conv = await assertOwns(prisma, input.conversationId, user);
216
- const orgId = conv.organisation_id;
217
-
218
- // The write itself lives in ONE shared surface (persist-chat-messages);
219
- // this router only resolves identity (assertOwns by user_id) above.
220
- await persistChatMessages(prisma, {
221
- conversationId: input.conversationId,
222
- organisationId: orgId,
223
- messages: input.messages,
224
- });
225
- return { success: true };
226
- }),
227
-
228
- // clear = START FRESH. Archive the old thread + drop its (now-cleared)
229
- // messages so it never auto-restores, AND create a brand-new empty thread
230
- // for the same user+org+agent with a NEW id (deterministic, no reload).
231
- // Mirrors publicGptSession.clear. Ownership is user_id (assertOwns).
232
- clear: authed.input(clearSchema).handler(async ({ input, context }) => {
233
- const prisma = (context as Context).prisma;
234
- const user = context.user as SessionUser;
235
- const log = getAppLogger();
236
-
237
- const old = await assertOwns(prisma, input.conversationId, user);
238
-
239
- await prisma.messages.deleteMany({
240
- where: { conversation_id: input.conversationId },
241
- });
242
- await prisma.conversations.update({
243
- where: { id: input.conversationId },
244
- data: { status: "archived" },
245
- });
246
-
247
- const cd = (old.channel_data ?? {}) as {
248
- assistant_type?: string;
249
- };
250
- const conversation = await prisma.conversations.create({
251
- data: {
252
- channel: "ai",
253
- organisation_id: old.organisation_id,
254
- user_id: old.user_id,
255
- title: "New chat",
256
- status: "active",
257
- channel_data: {
258
- assistant_type: cd.assistant_type,
259
- source: "authed-chat",
260
- total_input_tokens: 0,
261
- total_output_tokens: 0,
262
- },
263
- ...(old.members ?? undefined ? { members: old.members } : {}),
264
- },
265
- });
266
-
267
- log.info("[chatSession.clear] archived + created fresh", {
268
- oldId: input.conversationId,
269
- newId: conversation.id,
270
- });
271
- return {
272
- conversationId: conversation.id,
273
- messages: [],
274
- tokenTotals: { inputTokens: 0, outputTokens: 0 },
275
- };
276
- }),
277
-
278
- // list — the user's threads for this agent in the supplied (membership-
279
- // verified) org, most-recent first. Powers the conversation history list.
280
- // Includes a short preview from the latest user message in each thread.
281
- list: authed.input(listSchema).handler(async ({ input, context }) => {
282
- const prisma = (context as Context).prisma;
283
- const user = context.user as SessionUser;
284
- const { agentId, organisationId: orgId } = input;
285
- const aType = assistantType(agentId);
286
-
287
- const conversations = await prisma.conversations.findMany({
288
- where: {
289
- channel: "ai",
290
- organisation_id: orgId,
291
- user_id: user.id,
292
- channel_data: { path: ["assistant_type"], equals: aType },
293
- },
294
- orderBy: { last_message_at: "desc" },
295
- select: {
296
- id: true,
297
- title: true,
298
- status: true,
299
- last_message_at: true,
300
- created_at: true,
301
- },
302
- });
303
-
304
- if (conversations.length === 0) return { conversations: [] };
305
-
306
- // One grouped fetch for the latest user message per thread (preview),
307
- // mapped in JS — avoids an N+1.
308
- const ids = conversations.map((c) => c.id);
309
- const lastMsgs = await prisma.messages.findMany({
310
- where: { conversation_id: { in: ids }, role: "user" },
311
- orderBy: { occurred_at: "desc" },
312
- select: { conversation_id: true, content: true },
313
- });
314
- const previewById = new Map<string, string>();
315
- for (const m of lastMsgs) {
316
- if (m.conversation_id && !previewById.has(m.conversation_id)) {
317
- previewById.set(
318
- m.conversation_id,
319
- (m.content ?? "").slice(0, 120),
320
- );
321
- }
322
- }
323
-
324
- return {
325
- conversations: conversations.map((c) => ({
326
- id: c.id,
327
- title: c.title,
328
- status: c.status,
329
- last_message_at: c.last_message_at ?? c.created_at,
330
- preview: previewById.get(c.id) ?? "",
331
- })),
332
- };
333
- }),
334
-
335
- // load — switch to a past thread from the history list. Returns its full
336
- // message history (verbatim parts). Ownership is user_id (assertOwns).
337
- load: authed.input(loadSchema).handler(async ({ input, context }) => {
338
- const prisma = (context as Context).prisma;
339
- const user = context.user as SessionUser;
340
- const conv = await assertOwns(prisma, input.conversationId, user);
341
-
342
- const msgs = await prisma.messages.findMany({
343
- where: { conversation_id: input.conversationId },
344
- orderBy: { occurred_at: "asc" },
345
- });
346
- const cd = (conv.channel_data ?? {}) as Record<string, unknown>;
347
- return {
348
- conversationId: conv.id,
349
- messages: mapMessages(msgs),
350
- tokenTotals: {
351
- inputTokens: (cd.total_input_tokens as number) ?? 0,
352
- outputTokens: (cd.total_output_tokens as number) ?? 0,
353
- },
354
- };
355
- }),
356
- };
@@ -1,24 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Conversation ownership assertion for the chatSession ORPC router — the
7
- * reject-if-not-yours gate every mutating procedure runs before touching rows.
8
- */
9
-
10
- import { throwForbidden, type SessionUser } from "./chat-session-router-shared.ts";
11
-
12
- import type { PrismaClient } from "@teamscala/db/client";
13
-
14
- export async function assertOwns(
15
- prisma: PrismaClient,
16
- conversationId: string,
17
- user: SessionUser,
18
- ): Promise<unknown> {
19
- const conv = await prisma.conversations.findUnique({
20
- where: { id: conversationId },
21
- });
22
- if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
23
- return conv;
24
- }