2026-04-29 19:49:06 +02:00
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 ( ) ;
2026-05-08 20:45:16 +08:00
type Db = ReturnType < typeof createServerSupabase > ;
type AccessibleChat = {
id : string ;
title : string | null ;
user_id : string ;
project_id : string | null ;
} & Record < string , unknown > ;
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 < string , unknown > ;
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 < AccessibleChat | null > {
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 ;
}
2026-04-29 19:49:06 +02:00
// 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 ;
2026-05-08 20:45:16 +08:00
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 ;
2026-04-29 19:49:06 +02:00
const db = createServerSupabase ( ) ;
2026-05-08 20:45:16 +08:00
const projectAccess = await validateAccessibleProjectId (
projectId ,
userId ,
userEmail ,
db ,
) ;
if ( ! projectAccess . ok )
return void res
. status ( projectAccess . status )
. json ( { detail : projectAccess.detail } ) ;
2026-04-29 19:49:06 +02:00
const { data , error } = await db
. from ( "chats" )
2026-05-08 20:45:16 +08:00
. insert ( { user_id : userId , project_id : projectId ? ? null } )
2026-04-29 19:49:06 +02:00
. 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 ( ) ;
2026-05-08 20:45:16 +08:00
const chat = await getAccessibleChat ( chatId , userId , userEmail , db ) ;
if ( ! chat )
2026-04-29 19:49:06 +02:00
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 < string , unknown > [ ] ,
db : ReturnType < typeof createServerSupabase > ,
) : Promise < Record < string , unknown > [ ] > {
const editIds = new Set < string > ( ) ;
const versionIds = new Set < string > ( ) ;
const collectFromAnnList = ( list : unknown ) = > {
if ( ! Array . isArray ( list ) ) return ;
for ( const a of list as Record < string , unknown > [ ] ) {
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 < string , unknown > [ ] ) {
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 < string , "pending" | "accepted" | "rejected" > ( ) ;
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 < string , number | null > ( ) ;
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 < string , unknown > [ ] ) . 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 < string , unknown > = { . . . m } ;
next . annotations = patchAnnList ( m . annotations ) ;
if ( Array . isArray ( m . content ) ) {
next . content = ( m . content as Record < string , unknown > [ ] ) . map (
( ev ) = > {
if ( ev ? . type !== "doc_edited" ) return ev ;
let patched : Record < string , unknown > = {
. . . 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 ;
2026-05-08 20:45:16 +08:00
const message =
typeof req . body ? . message === "string" ? req . body . message . trim ( ) : "" ;
2026-04-29 19:49:06 +02:00
if ( ! message )
return void res . status ( 400 ) . json ( { detail : "message is required" } ) ;
const db = createServerSupabase ( ) ;
2026-05-08 20:45:16 +08:00
const chat = await getAccessibleChat ( chatId , userId , userEmail , db ) ;
if ( ! chat )
2026-04-29 19:49:06 +02:00
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 } )
2026-05-08 20:45:16 +08:00
. eq ( "id" , chatId ) ;
2026-04-29 19:49:06 +02:00
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 ;
2026-05-08 20:45:16 +08:00
const body =
req . body && typeof req . body === "object" && ! Array . isArray ( req . body )
? ( req . body as Record < string , unknown > )
: { } ;
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 ;
2026-04-29 19:49:06 +02:00
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 ;
2026-05-08 20:45:16 +08:00
let resolvedProjectId : string | null = parsedProjectId . projectId ;
2026-04-29 19:49:06 +02:00
if ( chatId ) {
2026-05-08 20:45:16 +08:00
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" } ) ;
2026-04-29 19:49:06 +02:00
}
2026-05-08 20:45:16 +08:00
resolvedProjectId = existingProjectId ;
chatTitle = existing . title ;
2026-04-29 19:49:06 +02:00
}
if ( ! chatId ) {
// If creating a chat tied to a project, the user must have access
// to the project (own or shared).
2026-05-08 20:45:16 +08:00
const projectAccess = await validateAccessibleProjectId (
resolvedProjectId ,
userId ,
userEmail ,
db ,
) ;
if ( ! projectAccess . ok )
return void res
. status ( projectAccess . status )
. json ( { detail : projectAccess.detail } ) ;
2026-04-29 19:49:06 +02:00
const { data : newChat , error } = await db
. from ( "chats" )
2026-05-08 20:45:16 +08:00
. insert ( { user_id : userId , project_id : resolvedProjectId } )
2026-04-29 19:49:06 +02:00
. 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 ,
2026-05-08 20:45:16 +08:00
projectId : resolvedProjectId ,
2026-04-29 19:49:06 +02:00
} ) ;
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 ( ) ;
}
} ) ;