import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { buildDocContext, buildMessages, enrichWithPriorEvents, buildWorkflowStore, extractAnnotations, runLLMStream, type ChatMessage, } from "../lib/chatTools"; import { completeText } from "../lib/llm"; import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; export const chatRouter = Router(); type Db = ReturnType; type AccessibleChat = { id: string; title: string | null; user_id: string; project_id: string | null; } & Record; function parseOptionalProjectId(value: unknown): | { ok: true; provided: boolean; projectId: string | null } | { ok: false; detail: string } { if (value === undefined) return { ok: true, provided: false, projectId: null }; if (value === null) return { ok: true, provided: true, projectId: null }; if (typeof value !== "string" || !value.trim()) { return { ok: false, detail: "project_id must be a non-empty string or null", }; } return { ok: true, provided: true, projectId: value.trim() }; } function parseOptionalChatId(value: unknown): | { ok: true; chatId: string | null } | { ok: false; detail: string } { if (value === undefined || value === null) return { ok: true, chatId: null }; if (typeof value !== "string" || !value.trim()) { return { ok: false, detail: "chat_id must be a non-empty string" }; } return { ok: true, chatId: value.trim() }; } function parseChatMessages(value: unknown): | { ok: true; messages: ChatMessage[] } | { ok: false; detail: string } { if (!Array.isArray(value) || value.length === 0) { return { ok: false, detail: "messages must be a non-empty array" }; } for (const message of value) { if (!message || typeof message !== "object" || Array.isArray(message)) { return { ok: false, detail: "messages must contain objects" }; } const row = message as Record; if (typeof row.role !== "string") { return { ok: false, detail: "message.role must be a string" }; } if (row.content !== null && typeof row.content !== "string") { return { ok: false, detail: "message.content must be a string or null", }; } } return { ok: true, messages: value as ChatMessage[] }; } function parseOptionalModel(value: unknown): | { ok: true; model: string | undefined } | { ok: false; detail: string } { if (value === undefined) return { ok: true, model: undefined }; if (typeof value !== "string" || !value.trim()) { return { ok: false, detail: "model must be a non-empty string" }; } return { ok: true, model: value.trim() }; } async function validateAccessibleProjectId( projectId: string | null, userId: string, userEmail: string | null | undefined, db: Db, ): Promise<{ ok: true } | { ok: false; status: number; detail: string }> { if (!projectId) return { ok: true }; const access = await checkProjectAccess(projectId, userId, userEmail, db); if (!access.ok) return { ok: false, status: 404, detail: "Project not found" }; return { ok: true }; } async function getAccessibleChat( chatId: string, userId: string, userEmail: string | null | undefined, db: Db, ): Promise { const { data: chat, error } = await db .from("chats") .select("*") .eq("id", chatId) .maybeSingle(); if (error || !chat) return null; const row = chat as AccessibleChat; if (row.user_id === userId) return row; if (row.project_id) { const access = await checkProjectAccess( row.project_id, userId, userEmail, db, ); if (access.ok) return row; } return null; } // GET /chat // Visible chats = the user's own chats + every chat under a project the // user owns (so a project owner sees all collaborator chats in their // own projects in the global recent-chats list). Chats in projects that // are merely *shared with* the user are NOT included here — those are // listed per-project via GET /projects/:projectId/chats. chatRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const db = createServerSupabase(); const { data: ownProjects, error: projErr } = await db .from("projects") .select("id") .eq("user_id", userId); if (projErr) return void res.status(500).json({ detail: projErr.message }); const ownProjectIds = ((ownProjects ?? []) as { id: string }[]).map( (p) => p.id, ); const filter = ownProjectIds.length > 0 ? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})` : `user_id.eq.${userId}`; const { data, error } = await db .from("chats") .select("*") .or(filter) .order("created_at", { ascending: false }); if (error) return void res.status(500).json({ detail: error.message }); res.json(data ?? []); }); // POST /chat/create chatRouter.post("/create", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const parsedProjectId = parseOptionalProjectId(req.body?.project_id); if (!parsedProjectId.ok) { return void res.status(400).json({ detail: parsedProjectId.detail }); } const projectId = parsedProjectId.projectId; const db = createServerSupabase(); const projectAccess = await validateAccessibleProjectId( projectId, userId, userEmail, db, ); if (!projectAccess.ok) return void res .status(projectAccess.status) .json({ detail: projectAccess.detail }); const { data, error } = await db .from("chats") .insert({ user_id: userId, project_id: projectId ?? null }) .select("id") .single(); if (error) return void res.status(500).json({ detail: error.message }); res.json({ id: data.id }); }); // GET /chat/:chatId chatRouter.get("/:chatId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { chatId } = req.params; const db = createServerSupabase(); const chat = await getAccessibleChat(chatId, userId, userEmail, db); if (!chat) return void res.status(404).json({ detail: "Chat not found" }); const { data: messages } = await db .from("chat_messages") .select("*") .eq("chat_id", chatId) .order("created_at", { ascending: true }); const hydrated = await hydrateEditStatuses(messages ?? [], db); res.json({ chat, messages: hydrated }); }); // Stored message annotations/events capture the `status` at the time the // assistant produced the edit (always "pending"). If the user later accepts // or rejects, `document_edits.status` is updated but the stored message // annotation is not. On chat load we merge the current DB status in so // EditCards render with the real state. async function hydrateEditStatuses( messages: Record[], db: ReturnType, ): Promise[]> { const editIds = new Set(); const versionIds = new Set(); const collectFromAnnList = (list: unknown) => { if (!Array.isArray(list)) return; for (const a of list as Record[]) { if (typeof a?.edit_id === "string") editIds.add(a.edit_id); if (typeof a?.version_id === "string") versionIds.add(a.version_id); } }; for (const m of messages) { collectFromAnnList(m.annotations); const content = m.content; if (Array.isArray(content)) { for (const ev of content as Record[]) { if (ev?.type === "doc_edited") { collectFromAnnList(ev.annotations); if (typeof ev.version_id === "string") versionIds.add(ev.version_id); } } } } if (editIds.size === 0 && versionIds.size === 0) return messages; // Edit status patch. const statusById = new Map(); if (editIds.size > 0) { const { data: rows } = await db .from("document_edits") .select("id, status") .in("id", Array.from(editIds)); for (const r of (rows ?? []) as { id: string; status: string }[]) { if ( r.status === "pending" || r.status === "accepted" || r.status === "rejected" ) { statusById.set(r.id, r.status); } } } // Version-number patch — old stored events don't carry `version_number` // because they predate the schema change. Look it up from // document_versions so the UI can render "V3" chips + download filenames. const versionNumberById = new Map(); if (versionIds.size > 0) { const { data: vrows } = await db .from("document_versions") .select("id, version_number") .in("id", Array.from(versionIds)); for (const r of (vrows ?? []) as { id: string; version_number: number | null; }[]) { versionNumberById.set(r.id, r.version_number ?? null); } } const patchAnnList = (list: unknown): unknown => { if (!Array.isArray(list)) return list; return (list as Record[]).map((a) => { let next = a; if (typeof a?.edit_id === "string" && statusById.has(a.edit_id)) { next = { ...next, status: statusById.get(a.edit_id) }; } if ( typeof a?.version_id === "string" && versionNumberById.has(a.version_id) ) { next = { ...next, version_number: versionNumberById.get(a.version_id) ?? null, }; } return next; }); }; return messages.map((m) => { const next: Record = { ...m }; next.annotations = patchAnnList(m.annotations); if (Array.isArray(m.content)) { next.content = (m.content as Record[]).map( (ev) => { if (ev?.type !== "doc_edited") return ev; let patched: Record = { ...ev, annotations: patchAnnList(ev.annotations), }; if ( typeof ev.version_id === "string" && versionNumberById.has(ev.version_id) ) { patched = { ...patched, version_number: versionNumberById.get(ev.version_id) ?? null, }; } return patched; }, ); } return next; }); } // PATCH /chat/:chatId chatRouter.patch("/:chatId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const { chatId } = req.params; const title = (req.body.title ?? "").trim(); if (!title) return void res.status(400).json({ detail: "title is required" }); const db = createServerSupabase(); const { data, error } = await db .from("chats") .update({ title }) .eq("id", chatId) .eq("user_id", userId) .select("id, title") .single(); if (error || !data) return void res.status(404).json({ detail: "Chat not found" }); res.json(data); }); // DELETE /chat/:chatId chatRouter.delete("/:chatId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const { chatId } = req.params; const db = createServerSupabase(); const { error } = await db .from("chats") .delete() .eq("id", chatId) .eq("user_id", userId); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); }); // POST /chat/:chatId/generate-title chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { chatId } = req.params; const message = typeof req.body?.message === "string" ? req.body.message.trim() : ""; if (!message) return void res.status(400).json({ detail: "message is required" }); const db = createServerSupabase(); const chat = await getAccessibleChat(chatId, userId, userEmail, db); if (!chat) return void res.status(404).json({ detail: "Chat not found" }); try { const { title_model, api_keys } = await getUserModelSettings( userId, db, ); const titleText = await completeText({ model: title_model, user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`, maxTokens: 64, apiKeys: api_keys, }); const title = titleText.trim() || message.slice(0, 60); await db .from("chats") .update({ title }) .eq("id", chatId); res.json({ title }); } catch (err) { console.error("[generate-title]", err); res.status(500).json({ detail: "Failed to generate title" }); } }); // POST /chat — streaming chatRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? (req.body as Record) : {}; const parsedMessages = parseChatMessages(body.messages); if (!parsedMessages.ok) { return void res.status(400).json({ detail: parsedMessages.detail }); } const parsedChatId = parseOptionalChatId(body.chat_id); if (!parsedChatId.ok) { return void res.status(400).json({ detail: parsedChatId.detail }); } const parsedProjectId = parseOptionalProjectId(body.project_id); if (!parsedProjectId.ok) { return void res.status(400).json({ detail: parsedProjectId.detail }); } const parsedModel = parseOptionalModel(body.model); if (!parsedModel.ok) { return void res.status(400).json({ detail: parsedModel.detail }); } const messages = parsedMessages.messages; const chat_id = parsedChatId.chatId; const project_id = parsedProjectId.projectId; const model = parsedModel.model; console.log("[chat/stream] incoming request", { userId, chat_id, project_id, model, messageCount: messages?.length, }); const userEmail = res.locals.userEmail as string | undefined; const db = createServerSupabase(); let chatId = chat_id ?? null; let chatTitle: string | null = null; let resolvedProjectId: string | null = parsedProjectId.projectId; if (chatId) { const existing = await getAccessibleChat(chatId, userId, userEmail, db); if (!existing) return void res.status(404).json({ detail: "Chat not found" }); const existingProjectId = existing.project_id ?? null; if ( parsedProjectId.provided && parsedProjectId.projectId !== existingProjectId ) { return void res .status(400) .json({ detail: "project_id does not match chat" }); } resolvedProjectId = existingProjectId; chatTitle = existing.title; } if (!chatId) { // If creating a chat tied to a project, the user must have access // to the project (own or shared). const projectAccess = await validateAccessibleProjectId( resolvedProjectId, userId, userEmail, db, ); if (!projectAccess.ok) return void res .status(projectAccess.status) .json({ detail: projectAccess.detail }); const { data: newChat, error } = await db .from("chats") .insert({ user_id: userId, project_id: resolvedProjectId }) .select("id, title") .single(); if (error || !newChat) { console.error("[chat/stream] failed to create chat", error); return void res .status(500) .json({ detail: "Failed to create chat" }); } chatId = newChat.id as string; chatTitle = newChat.title; } console.log("[chat/stream] resolved chatId", chatId); const lastUser = [...messages].reverse().find((m) => m.role === "user"); if (lastUser) { await db.from("chat_messages").insert({ chat_id: chatId, role: "user", content: lastUser.content, files: lastUser.files ?? null, workflow: lastUser.workflow ?? null, }); } const { docIndex, docStore } = await buildDocContext( messages, userId, db, chatId, ); const docAvailability = Object.entries(docIndex).map(([doc_id, info]) => ({ doc_id, filename: info.filename, })); const enrichedMessages = await enrichWithPriorEvents( messages, chatId, db, docIndex, ); const apiMessages = buildMessages(enrichedMessages, docAvailability); const workflowStore = await buildWorkflowStore(userId, userEmail, db); console.log("[chat/stream] starting LLM stream", { apiMessageCount: apiMessages.length, docCount: Object.keys(docIndex).length, workflowCount: Object.keys(workflowStore).length, }); res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); const write = (line: string) => res.write(line); const apiKeys = await getUserApiKeys(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); const { fullText, events } = await runLLMStream({ apiMessages, docStore, docIndex, userId, db, write, workflowStore, model, apiKeys, projectId: resolvedProjectId, }); console.log("[chat/stream] LLM stream finished", { fullTextLen: fullText?.length ?? 0, eventCount: events?.length ?? 0, }); const annotations = extractAnnotations(fullText, docIndex, events); await db.from("chat_messages").insert({ chat_id: chatId, role: "assistant", content: events.length ? events : null, annotations: annotations.length ? annotations : null, }); if (!chatTitle && lastUser?.content) { await db .from("chats") .update({ title: lastUser.content.slice(0, 120) }) .eq("id", chatId); } } catch (err) { console.error("[chat/stream] error:", err); try { write( `data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`, ); write("data: [DONE]\n\n"); } catch { /* ignore */ } } finally { res.end(); } });