@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.
- 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-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 -136
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
- 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,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
|
-
}
|