Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes

This commit is contained in:
willchen96 2026-06-06 15:48:47 +08:00
parent d39f5806e5
commit 44e868eb42
106 changed files with 16350 additions and 7753 deletions

View file

@ -0,0 +1,84 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { getCourtlistenerCaseOpinions } from "../lib/courtlistener";
import { createServerSupabase } from "../lib/supabase";
import { getUserModelSettings } from "../lib/userSettings";
export const caseLawRouter = Router();
caseLawRouter.use(requireAuth);
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
const sidepanelOpinionFetches = new Map<string, Promise<unknown>>();
function cleanClusterId(value: unknown): number | null {
const numeric =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: NaN;
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null;
}
caseLawRouter.post("/case-opinions", async (req, res) => {
const body =
req.body && typeof req.body === "object" && !Array.isArray(req.body)
? (req.body as Record<string, unknown>)
: {};
const clusterId = cleanClusterId(body.clusterId ?? body.cluster_id);
if (!clusterId) {
return res.status(400).json({
detail: "cluster_id is required",
});
}
try {
const userId = String(res.locals.userId ?? "");
const settings = await getUserModelSettings(userId);
devLog("[case-law/case-opinions] loading sidepanel opinions", {
clusterId,
});
const db = createServerSupabase();
const fetchKey = String(clusterId);
let fetchPromise = sidepanelOpinionFetches.get(fetchKey);
if (fetchPromise) {
devLog("[case-law/case-opinions] joining in-flight fetch", {
clusterId,
});
} else {
fetchPromise = getCourtlistenerCaseOpinions({
clusterId,
db,
includeFullText: true,
maxChars: 50000,
apiToken: settings.api_keys.courtlistener,
}).finally(() => {
sidepanelOpinionFetches.delete(fetchKey);
});
sidepanelOpinionFetches.set(fetchKey, fetchPromise);
}
const fetched = await fetchPromise;
const fetchedRecord =
fetched && typeof fetched === "object" && !Array.isArray(fetched)
? (fetched as Record<string, unknown>)
: {};
const opinions = Array.isArray(fetchedRecord.opinions)
? fetchedRecord.opinions
: [];
devLog("[case-law/case-opinions] returning sidepanel opinions", {
clusterId,
opinionCount: opinions.length,
});
return res.json({ opinions });
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch case opinions";
return res.status(502).json({ detail: message });
}
});

View file

@ -6,8 +6,11 @@ import {
buildMessages,
enrichWithPriorEvents,
buildWorkflowStore,
AssistantStreamError,
extractAnnotations,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
type ChatMessage,
} from "../lib/chatTools";
import { completeText } from "../lib/llm";
@ -22,6 +25,14 @@ const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
const TITLE_FALLBACK = "Misc. Query";
function normalizeGeneratedTitle(raw: string): string {
const title = raw.trim().replace(/^["'`]+|["'`.,:;!?]+$/g, "").trim();
if (!title) return TITLE_FALLBACK;
return title.slice(0, 80);
}
type AccessibleChat = {
id: string;
title: string | null;
@ -225,11 +236,12 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => {
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.
// Stored doc_edited 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 event is not. On chat load
// we merge the current DB status in so EditCards render with the real state.
// Legacy rows may also have duplicate edit_data in top-level annotations, so
// keep patching that path until old data no longer matters.
async function hydrateEditStatuses(
messages: Record<string, unknown>[],
db: ReturnType<typeof createServerSupabase>,
@ -401,11 +413,11 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
);
const titleText = await completeText({
model: title_model,
user: `Generate a concise title (36 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)}`,
user: `Generate a concise title (36 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. If there is not enough information to generate a title, return exactly "${TITLE_FALLBACK}". 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);
const title = normalizeGeneratedTitle(titleText);
await db
.from("chats")
@ -555,13 +567,18 @@ chatRouter.post("/", requireAuth, async (req, res) => {
res.flushHeaders();
const write = (line: string) => res.write(line);
const streamAbort = new AbortController();
let streamFinished = false;
res.on("close", () => {
if (!streamFinished) streamAbort.abort();
});
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
const { fullText, events } = await runLLMStream({
const { fullText, events, annotations } = await runLLMStream({
apiMessages,
docStore,
docIndex,
@ -571,6 +588,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
workflowStore,
model,
apiKeys,
signal: streamAbort.signal,
projectId: resolvedProjectId,
});
@ -579,11 +597,11 @@ chatRouter.post("/", requireAuth, async (req, res) => {
eventCount: events?.length ?? 0,
});
const annotations = extractAnnotations(fullText, docIndex, events);
const persistedEvents = stripTransientAssistantEvents(events);
await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
content: persistedEvents.length ? persistedEvents : null,
annotations: annotations.length ? annotations : null,
});
@ -594,16 +612,45 @@ chatRouter.post("/", requireAuth, async (req, res) => {
.eq("id", chatId);
}
} catch (err) {
if (isAbortError(err)) {
devLog("[chat/stream] client aborted stream", { chatId });
return;
}
console.error("[chat/stream] error:", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
const errorFullText =
err instanceof AssistantStreamError ? err.fullText : "";
try {
const annotations = extractAnnotations(
errorFullText,
docIndex,
errorEvents,
);
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: errorEvents.length ? errorEvents : null,
annotations: annotations.length ? annotations : null,
});
if (saveError)
console.error("[chat/stream] failed to save error", saveError);
} catch (saveErr) {
console.error("[chat/stream] failed to save error", saveErr);
}
try {
write(
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
streamFinished = true;
res.end();
}
});

View file

@ -26,6 +26,30 @@ import { singleFileUpload } from "../lib/upload";
export const documentsRouter = Router();
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
async function deleteDocumentAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
documentId: string,
) {
// Storage lives on document_versions — fan out and delete each version's
// bytes (source + PDF rendition) before dropping the document row.
const { data: versions } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.eq("document_id", documentId);
await Promise.all(
(versions ?? []).flatMap((v) =>
[v.storage_path, v.pdf_storage_path]
.filter((p): p is string => typeof p === "string" && p.length > 0)
.map((p) => deleteFile(p).catch(() => {})),
),
);
return db.from("documents").delete().eq("id", documentId);
}
// GET /single-documents
documentsRouter.get("/", requireAuth, async (req, res) => {
@ -74,20 +98,7 @@ documentsRouter.delete("/:documentId", requireAuth, async (req, res) => {
if (error || !doc)
return void res.status(404).json({ detail: "Document not found" });
// Storage now lives on document_versions — fan out and delete each
// version's bytes (DOCX + PDF rendition) before dropping rows.
const { data: versions } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.eq("document_id", documentId);
await Promise.all(
(versions ?? []).flatMap((v) =>
[v.storage_path, v.pdf_storage_path]
.filter((p): p is string => typeof p === "string" && p.length > 0)
.map((p) => deleteFile(p).catch(() => {})),
),
);
await db.from("documents").delete().eq("id", documentId);
await deleteDocumentAndVersionFiles(db, documentId);
res.status(204).send();
});
@ -104,7 +115,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
const { data: doc } = await db
.from("documents")
.select("id, filename, file_type, user_id, project_id")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (!doc)
@ -117,8 +128,13 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
if (!active)
return void res.status(404).json({ detail: "No file available" });
const fileType = (doc.file_type as string) ?? "";
const fileType = active.file_type ?? "";
const isDocx = fileType === "docx" || fileType === "doc";
const displayFilename = downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
);
// For DOCX, prefer the per-version PDF rendition if one exists.
const servePath =
@ -135,7 +151,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
buildContentDisposition("inline", doc.filename as string),
buildContentDisposition("inline", displayFilename),
);
res.send(Buffer.from(raw));
} else {
@ -146,7 +162,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
);
res.setHeader(
"Content-Disposition",
buildContentDisposition("inline", doc.filename as string),
buildContentDisposition("inline", displayFilename),
);
res.send(Buffer.from(raw));
}
@ -164,7 +180,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
const db = createServerSupabase();
const { data: rawDocs, error } = await db
.from("documents")
.select("id, filename, file_type, current_version_id, user_id, project_id")
.select("id, current_version_id, user_id, project_id")
.in("id", document_ids);
if (error) return void res.status(500).json({ detail: error.message });
@ -182,7 +198,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
);
const docs = accessChecks
.filter((x) => x.access.ok)
.map((x) => x.doc as { id: string; filename: string });
.map((x) => x.doc as { id: string });
if (!docs || docs.length === 0)
return void res.status(404).json({ detail: "No documents found" });
@ -195,7 +211,14 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
if (!active) return;
const raw = await downloadFile(active.storage_path);
if (!raw) return;
zip.file(doc.filename, Buffer.from(raw));
zip.file(
downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
),
Buffer.from(raw),
);
}),
);
@ -217,7 +240,7 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => {
const { data: doc, error } = await db
.from("documents")
.select("id, filename, user_id, project_id")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (error || !doc)
@ -230,10 +253,10 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => {
if (!active)
return void res.status(404).json({ detail: "No file available" });
const downloadFilename = resolveDownloadFilename(
doc.filename as string,
active.display_name,
const downloadFilename = downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
);
const url = await getSignedUrl(
active.storage_path,
@ -268,7 +291,7 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => {
const { data: doc, error } = await db
.from("documents")
.select("id, filename, user_id, project_id")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (error || !doc)
@ -293,51 +316,29 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => {
"Content-Disposition",
buildContentDisposition(
"inline",
resolveDownloadFilename(
doc.filename as string,
active.display_name,
downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
),
),
);
res.send(Buffer.from(raw));
});
// Compose a download-friendly filename that carries the edit version
// marker: "Purchase Agreement.docx" → "Purchase Agreement [Edited V2].docx".
// Preserves the original extension (fallback: .docx).
function versionedFilename(filename: string, version: number | null): string {
if (!version || version < 1) return filename;
const dot = filename.lastIndexOf(".");
const stem = dot > 0 ? filename.slice(0, dot) : filename;
const ext = dot > 0 ? filename.slice(dot) : ".docx";
return `${stem} [Edited V${version}]${ext}`;
}
// Produce the filename a download should present to the user for a given
// (document, version) pair. Prefers the version's display_name (appending
// the original extension if the user didn't include one), falling back to
// the versionedFilename heuristic.
function resolveDownloadFilename(
originalFilename: string,
displayName: string | null | undefined,
// Produce the filename a download should present to the user. Version
// filenames are expected to include the real extension.
function downloadFilenameForVersion(
filename: string | null | undefined,
versionNumber: number | null,
edited = false,
): string {
const dot = originalFilename.lastIndexOf(".");
const origExt = dot > 0 ? originalFilename.slice(dot) : "";
if (displayName && displayName.trim()) {
const trimmed = displayName.trim();
const trimmedDot = trimmed.lastIndexOf(".");
const hasExt =
trimmedDot > 0 &&
trimmed
.slice(trimmedDot)
.toLowerCase()
.match(/^\.[a-z0-9]{1,6}$/);
if (hasExt) return trimmed;
return origExt ? `${trimmed}${origExt}` : trimmed;
}
return versionedFilename(originalFilename, versionNumber);
const resolved = filename?.trim() || "Untitled document.docx";
if (!edited || !versionNumber || versionNumber < 1) return resolved;
const dot = resolved.lastIndexOf(".");
const stem = dot > 0 ? resolved.slice(0, dot) : resolved;
const ext = dot > 0 ? resolved.slice(dot) : "";
return `${stem} [Edited V${versionNumber}]${ext}`;
}
// GET /single-documents/:documentId/versions
@ -362,7 +363,9 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => {
const { data: rows } = await db
.from("document_versions")
.select("id, version_number, source, created_at, display_name")
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
.eq("document_id", documentId)
.order("created_at", { ascending: true });
@ -372,10 +375,204 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => {
});
});
// POST /single-documents/:documentId/versions/from-document
// Create a new version of documentId from another existing document's active
// bytes. This keeps signed storage URLs out of the browser fetch path.
documentsRouter.post(
"/:documentId/versions/from-document",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const sourceDocumentId =
typeof req.body?.source_document_id === "string"
? req.body.source_document_id
: "";
const db = createServerSupabase();
if (!sourceDocumentId) {
return void res
.status(400)
.json({ detail: "source_document_id is required" });
}
if (sourceDocumentId === documentId) {
return void res
.status(400)
.json({ detail: "Source and target documents must be different." });
}
const { data: targetDoc } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (!targetDoc)
return void res.status(404).json({ detail: "Document not found" });
const targetAccess = await ensureDocAccess(targetDoc, userId, userEmail, db);
if (!targetAccess.ok)
return void res.status(404).json({ detail: "Document not found" });
const { data: sourceDoc } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", sourceDocumentId)
.single();
if (!sourceDoc)
return void res.status(404).json({ detail: "Source document not found" });
const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db);
if (!sourceAccess.ok)
return void res.status(404).json({ detail: "Source document not found" });
const targetActive = await loadActiveVersion(documentId, db);
const targetType = targetActive?.file_type ?? "";
const active = await loadActiveVersion(sourceDocumentId, db);
if (!active)
return void res
.status(404)
.json({ detail: "Source document has no active version." });
const sourceType = active.file_type ?? "";
if (targetType && sourceType && targetType !== sourceType) {
return void res.status(400).json({
detail: `Source document type (${sourceType}) does not match document type (${targetType}).`,
});
}
const bytes = await downloadFile(active.storage_path);
if (!bytes)
return void res
.status(404)
.json({ detail: "Source document bytes not available." });
const filename =
typeof req.body?.filename === "string" && req.body.filename.trim()
? req.body.filename.trim().slice(0, 200)
: active.filename?.trim() || "Untitled document";
const suffix =
sourceType ||
(filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "");
const versionSlug = crypto.randomUUID().replace(/-/g, "");
const key = versionStorageKey(userId, documentId, versionSlug, filename);
const contentType =
suffix === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
try {
await uploadFile(key, bytes, contentType);
} catch (e) {
console.error("[versions/copy] storage write failed", e);
return void res
.status(500)
.json({ detail: "Failed to create new version." });
}
let pdfStoragePath: string | null = null;
if (suffix === "pdf") {
pdfStoragePath = key;
} else if (active.pdf_storage_path) {
if (active.pdf_storage_path === active.storage_path) {
pdfStoragePath = key;
} else {
const pdfBytes = await downloadFile(active.pdf_storage_path);
if (pdfBytes) {
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
await uploadFile(pdfKey, pdfBytes, "application/pdf");
pdfStoragePath = pdfKey;
}
}
} else if (suffix === "docx" || suffix === "doc") {
try {
const pdfBuf = await docxToPdf(Buffer.from(bytes));
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
await uploadFile(
pdfKey,
pdfBuf.buffer.slice(
pdfBuf.byteOffset,
pdfBuf.byteOffset + pdfBuf.byteLength,
) as ArrayBuffer,
"application/pdf",
);
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[versions/copy] DOCX→PDF conversion failed for ${filename}:`,
err,
);
}
}
const { data: maxRow } = await db
.from("document_versions")
.select("version_number")
.eq("document_id", documentId)
.in("source", ["upload", "user_upload", "assistant_edit"])
.order("version_number", { ascending: false, nullsFirst: false })
.limit(1)
.maybeSingle();
const nextVersionNumber =
((maxRow?.version_number as number | null) ?? 1) + 1;
const { data: versionRow, error: verErr } = await db
.from("document_versions")
.insert({
document_id: documentId,
storage_path: key,
pdf_storage_path: pdfStoragePath,
source: "user_upload",
version_number: nextVersionNumber,
filename: filename,
file_type: sourceType || null,
size_bytes: active.size_bytes ?? bytes.byteLength,
page_count: active.page_count,
})
.select("id, version_number, source, created_at, filename")
.single();
if (verErr || !versionRow) {
console.error("[versions/copy] insert failed", verErr);
return void res
.status(500)
.json({ detail: "Failed to record new version." });
}
const { error: updateDocErr } = await db
.from("documents")
.update({
current_version_id: versionRow.id,
})
.eq("id", documentId);
if (updateDocErr) {
console.error("[versions/copy] current version update failed", updateDocErr);
return void res
.status(500)
.json({ detail: "Failed to update document current version." });
}
if (
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id
) {
const { error: deleteErr } = await deleteDocumentAndVersionFiles(
db,
sourceDocumentId,
);
if (deleteErr) {
console.error("[versions/copy] source document delete failed", deleteErr);
return void res
.status(500)
.json({ detail: "Failed to delete source document." });
}
}
res.status(201).json(versionRow);
},
);
// POST /single-documents/:documentId/versions
// Upload a brand-new version of an existing document. The uploaded file
// becomes the new current_version_id. display_name defaults to the
// uploaded filename; client may override via the `display_name` form field.
// becomes the new current_version_id. filename defaults to the
// uploaded filename; client may override via the `filename` form field.
documentsRouter.post(
"/:documentId/versions",
requireAuth,
@ -392,7 +589,7 @@ documentsRouter.post(
const { data: doc } = await db
.from("documents")
.select("id, filename, file_type, user_id, project_id")
.select("id, user_id, project_id, current_version_id")
.eq("id", documentId)
.single();
if (!doc)
@ -406,9 +603,17 @@ documentsRouter.post(
const suffix = file.originalname.includes(".")
? file.originalname.split(".").pop()!.toLowerCase()
: "";
if (doc.file_type && suffix && doc.file_type !== suffix) {
if (!ALLOWED_TYPES.has(suffix)) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match document type (${doc.file_type}).`,
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
});
}
const currentActive = await loadActiveVersion(documentId, db);
const expectedType = currentActive?.file_type ?? "";
if (expectedType && expectedType !== suffix) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match document type (${expectedType}).`,
});
}
@ -469,6 +674,12 @@ documentsRouter.post(
pdfStoragePath = key;
}
const rawBuf = file.buffer.buffer.slice(
file.buffer.byteOffset,
file.buffer.byteOffset + file.buffer.byteLength,
) as ArrayBuffer;
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Per-document sequential version_number — the upload is V1 and
// user_upload + assistant_edit count forward from there.
const { data: maxRow } = await db
@ -482,10 +693,10 @@ documentsRouter.post(
const nextVersionNumber =
((maxRow?.version_number as number | null) ?? 1) + 1;
const defaultDisplayName =
typeof req.body?.display_name === "string" &&
req.body.display_name.trim()
? req.body.display_name.trim().slice(0, 200)
const requestedFilename =
typeof req.body?.filename === "string" &&
req.body.filename.trim()
? req.body.filename.trim().slice(0, 200)
: file.originalname;
const { data: versionRow, error: verErr } = await db
@ -496,9 +707,12 @@ documentsRouter.post(
pdf_storage_path: pdfStoragePath,
source: "user_upload",
version_number: nextVersionNumber,
display_name: defaultDisplayName,
filename: requestedFilename,
file_type: suffix,
size_bytes: file.buffer.byteLength,
page_count: pageCount,
})
.select("id, version_number, source, created_at, display_name")
.select("id, version_number, source, created_at, filename")
.single();
if (verErr || !versionRow) {
console.error("[versions/upload] insert failed", verErr);
@ -507,30 +721,11 @@ documentsRouter.post(
.json({ detail: "Failed to record new version." });
}
// Also propagate the user-provided display_name to the parent document's
// filename so the document's display name stays in sync across the UI.
// Preserve a sensible extension: if the display_name has none, append
// the uploaded file's extension (fallback: the existing doc's extension).
const documentsUpdate: Record<string, unknown> = {
current_version_id: versionRow.id,
};
const providedDisplayName =
typeof req.body?.display_name === "string" &&
req.body.display_name.trim()
? req.body.display_name.trim().slice(0, 200)
: null;
if (providedDisplayName) {
const hasExt = /\.[a-z0-9]{1,6}$/i.test(providedDisplayName);
const existingExt = (doc.filename as string | null)?.match(
/\.[a-z0-9]{1,6}$/i,
)?.[0];
const uploadedExt = suffix ? `.${suffix}` : "";
const ext = hasExt ? "" : uploadedExt || existingExt || "";
documentsUpdate.filename = `${providedDisplayName}${ext}`;
}
await db
.from("documents")
.update(documentsUpdate)
.update({
current_version_id: versionRow.id,
})
.eq("id", documentId);
res.status(201).json(versionRow);
@ -538,8 +733,7 @@ documentsRouter.post(
);
// PATCH /single-documents/:documentId/versions/:versionId
// Rename a version's display_name. Pass `{ "display_name": "…" }`; an empty
// or missing value clears the override so the UI falls back to V{n}.
// Rename a version's filename. Pass `{ "filename": "…" }`.
documentsRouter.patch(
"/:documentId/versions/:versionId",
requireAuth,
@ -560,16 +754,18 @@ documentsRouter.patch(
if (!access.ok)
return void res.status(404).json({ detail: "Document not found" });
const raw = req.body?.display_name;
const displayName =
const raw = req.body?.filename;
const filename =
typeof raw === "string" && raw.trim() ? raw.trim().slice(0, 200) : null;
const { data: updated, error } = await db
.from("document_versions")
.update({ display_name: displayName })
.update({ filename })
.eq("id", versionId)
.eq("document_id", documentId)
.select("id, version_number, source, created_at, display_name")
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
.single();
if (error || !updated) {
return void res.status(404).json({ detail: "Version not found" });
@ -578,6 +774,104 @@ documentsRouter.patch(
},
);
// DELETE /single-documents/:documentId/versions/:versionId
// Delete one version. The last remaining version cannot be deleted; if the
// deleted version is current, the newest remaining version becomes current.
documentsRouter.delete(
"/:documentId/versions/:versionId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId, versionId } = req.params;
const db = createServerSupabase();
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_id, current_version_id")
.eq("id", documentId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
if (!access.ok || !access.isOwner)
return void res.status(404).json({ detail: "Document not found" });
const { data: versions, error: versionsErr } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, version_number, created_at")
.eq("document_id", documentId);
if (versionsErr) {
return void res.status(500).json({ detail: versionsErr.message });
}
const rows = (versions ?? []) as {
id: string;
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
created_at: string | null;
}[];
const target = rows.find((row) => row.id === versionId);
if (!target)
return void res.status(404).json({ detail: "Version not found" });
if (rows.length <= 1) {
return void res
.status(400)
.json({ detail: "Cannot delete the only document version." });
}
const remaining = rows
.filter((row) => row.id !== versionId)
.sort((a, b) => {
const versionDelta =
(b.version_number ?? -1) - (a.version_number ?? -1);
if (versionDelta !== 0) return versionDelta;
return (
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
);
});
const nextCurrentVersionId =
doc.current_version_id === versionId
? (remaining[0]?.id ?? null)
: doc.current_version_id;
if (doc.current_version_id === versionId) {
const { error: updateErr } = await db
.from("documents")
.update({
current_version_id: nextCurrentVersionId,
updated_at: new Date().toISOString(),
})
.eq("id", documentId);
if (updateErr) {
return void res.status(500).json({ detail: updateErr.message });
}
}
const { error: deleteErr } = await db
.from("document_versions")
.delete()
.eq("id", versionId)
.eq("document_id", documentId);
if (deleteErr) {
return void res.status(500).json({ detail: deleteErr.message });
}
await Promise.all(
[target.storage_path, target.pdf_storage_path]
.filter((path): path is string => !!path)
.map((path) => deleteFile(path).catch(() => {})),
);
res.json({
deleted_version_id: versionId,
current_version_id: nextCurrentVersionId,
});
},
);
// GET /single-documents/:documentId/tracked-change-ids
// Returns the ordered list of { kind, w_id } for every w:ins / w:del in
// the current (or specified) version's document.xml. The frontend uses
@ -632,7 +926,7 @@ async function handleEditResolution(
const { documentId, editId } = req.params;
const db = createServerSupabase();
console.log(`[edit-resolution] incoming ${mode}`, {
devLog(`[edit-resolution] incoming ${mode}`, {
userId,
documentId,
editId,
@ -644,31 +938,31 @@ async function handleEditResolution(
.eq("id", editId)
.eq("document_id", documentId)
.single();
console.log(`[edit-resolution] fetched edit row`, { edit, editErr });
devLog(`[edit-resolution] fetched edit row`, { edit, editErr });
if (!edit) {
console.log(`[edit-resolution] edit not found, returning 404`);
devLog(`[edit-resolution] edit not found, returning 404`);
return void res.status(404).json({ detail: "Edit not found" });
}
// Idempotent: if the edit is already resolved, return the current doc
// state so stale UI (e.g. an old chat reloaded in a new session) can
// reconcile without throwing.
if (edit.status !== "pending") {
console.log(`[edit-resolution] edit already resolved`, {
devLog(`[edit-resolution] edit already resolved`, {
editId,
status: edit.status,
});
const { data: doc } = await db
.from("documents")
.select("current_version_id, filename, user_id, project_id")
.select("current_version_id, user_id, project_id")
.eq("id", documentId)
.single();
if (!doc) {
console.log(`[edit-resolution] doc not found for resolved edit`);
devLog(`[edit-resolution] doc not found for resolved edit`);
return void res.status(404).json({ detail: "Document not found" });
}
const accessResolved = await ensureDocAccess(doc, userId, userEmail, db);
if (!accessResolved.ok) {
console.log(`[edit-resolution] doc access denied for resolved edit`);
devLog(`[edit-resolution] doc access denied for resolved edit`);
return void res.status(404).json({ detail: "Document not found" });
}
const activeForResolved = await loadActiveVersion(documentId, db);
@ -680,12 +974,16 @@ async function handleEditResolution(
download_url: activeForResolved
? buildDownloadUrl(
activeForResolved.storage_path,
(doc.filename as string) ?? "document.docx",
downloadFilenameForVersion(
activeForResolved.filename,
activeForResolved.version_number,
activeForResolved.source === "assistant_edit",
),
)
: null,
remaining_pending: 0,
};
console.log(`[edit-resolution] returning already-resolved payload`, payload);
devLog(`[edit-resolution] returning already-resolved payload`, payload);
return void res.status(200).json(payload);
}
@ -694,7 +992,7 @@ async function handleEditResolution(
.select("id, current_version_id, user_id, project_id")
.eq("id", documentId)
.single();
console.log(`[edit-resolution] fetched doc`, { doc, docErr });
devLog(`[edit-resolution] fetched doc`, { doc, docErr });
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
@ -703,7 +1001,7 @@ async function handleEditResolution(
const active = await loadActiveVersion(documentId, db);
const latestPath = active?.storage_path ?? null;
console.log(`[edit-resolution] resolved latestPath`, {
devLog(`[edit-resolution] resolved latestPath`, {
latestPath,
current_version_id: doc.current_version_id,
});
@ -711,7 +1009,7 @@ async function handleEditResolution(
return void res.status(404).json({ detail: "No file to edit" });
const raw = await downloadFile(latestPath);
console.log(`[edit-resolution] downloaded bytes`, {
devLog(`[edit-resolution] downloaded bytes`, {
byteLength: raw?.byteLength ?? 0,
});
if (!raw)
@ -725,7 +1023,7 @@ async function handleEditResolution(
wIds,
mode,
);
console.log(`[edit-resolution] resolveTrackedChange result`, {
devLog(`[edit-resolution] resolveTrackedChange result`, {
mode,
change_id: edit.change_id,
wIds,
@ -733,7 +1031,7 @@ async function handleEditResolution(
resolvedByteLength: resolvedBytes?.byteLength ?? 0,
});
if (!found) {
console.log(
devLog(
`[edit-resolution] change_id not found in docx — updating status only`,
);
// Still update DB status so the UI reflects the decision — the change
@ -742,22 +1040,21 @@ async function handleEditResolution(
.from("document_edits")
.update({ status: mode === "accept" ? "accepted" : "rejected", resolved_at: new Date().toISOString() })
.eq("id", editId);
console.log(`[edit-resolution] status-only update`, { updErr });
const { data: filenameRow } = await db
.from("documents")
.select("filename")
.eq("id", documentId)
.single();
devLog(`[edit-resolution] status-only update`, { updErr });
const payload = {
ok: true,
version_id: doc.current_version_id,
download_url: buildDownloadUrl(
latestPath,
(filenameRow?.filename as string) ?? "document.docx",
downloadFilenameForVersion(
active?.filename,
active?.version_number ?? null,
active?.source === "assistant_edit",
),
),
remaining_pending: 0,
};
console.log(`[edit-resolution] returning not-found payload`, payload);
devLog(`[edit-resolution] returning not-found payload`, payload);
return void res.status(200).json(payload);
}
@ -770,7 +1067,7 @@ async function handleEditResolution(
resolvedBytes.byteOffset,
resolvedBytes.byteOffset + resolvedBytes.byteLength,
) as ArrayBuffer;
console.log(`[edit-resolution] overwriting bytes in place`, {
devLog(`[edit-resolution] overwriting bytes in place`, {
latestPath,
byteLength: ab.byteLength,
});
@ -787,7 +1084,7 @@ async function handleEditResolution(
resolved_at: new Date().toISOString(),
})
.eq("id", editId);
console.log(`[edit-resolution] updated document_edits status`, {
devLog(`[edit-resolution] updated document_edits status`, {
editId,
newStatus: mode === "accept" ? "accepted" : "rejected",
statusErr,
@ -798,23 +1095,22 @@ async function handleEditResolution(
.select("id", { count: "exact", head: true })
.eq("document_id", documentId)
.eq("status", "pending");
console.log(`[edit-resolution] remaining pending count`, { remainingPending });
devLog(`[edit-resolution] remaining pending count`, { remainingPending });
const { data: filenameRow } = await db
.from("documents")
.select("filename")
.eq("id", documentId)
.single();
const payload = {
ok: true,
version_id: doc.current_version_id,
download_url: buildDownloadUrl(
latestPath,
(filenameRow?.filename as string) ?? "document.docx",
downloadFilenameForVersion(
active?.filename,
active?.version_number ?? null,
active?.source === "assistant_edit",
),
),
remaining_pending: remainingPending ?? 0,
};
console.log(`[edit-resolution] returning success payload`, payload);
devLog(`[edit-resolution] returning success payload`, payload);
res.json(payload);
}
@ -857,13 +1153,19 @@ async function handleDocumentUpload(
.insert({
project_id: projectId,
user_id: userId,
filename,
file_type: suffix,
size_bytes: content.byteLength,
status: "processing",
})
.select("*")
.single();
if (insertErr || !doc)
console.error("[single-documents/upload] failed to create document row", {
userId,
projectId,
filename,
suffix,
error: insertErr,
});
if (insertErr || !doc)
return void res
.status(500)
@ -889,7 +1191,6 @@ async function handleDocumentUpload(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer;
const tree = await extractStructureTree(rawBuf, suffix, filename);
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
@ -928,7 +1229,10 @@ async function handleDocumentUpload(
pdf_storage_path: pdfStoragePath,
source: "upload",
version_number: 1,
display_name: filename,
filename: filename,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
})
.select("id")
.single();
@ -942,9 +1246,6 @@ async function handleDocumentUpload(
.from("documents")
.update({
current_version_id: versionRow.id,
size_bytes: content.byteLength,
page_count: pageCount,
structure_tree: tree ?? null,
status: "ready",
updated_at: new Date().toISOString(),
})
@ -957,7 +1258,16 @@ async function handleDocumentUpload(
.single();
// Surface storage paths to the caller for backward compatibility.
const responseDoc = updated
? { ...updated, storage_path: key, pdf_storage_path: pdfStoragePath }
? {
...updated,
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
active_version_number: 1,
}
: updated;
return void res.status(201).json(responseDoc);
} catch (e) {
@ -983,62 +1293,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> {
return null;
}
}
async function extractStructureTree(
content: ArrayBuffer,
fileType: string,
_filename: string,
): Promise<unknown[] | null> {
try {
if (fileType === "pdf") {
const pdfjsLib = await import(
"pdfjs-dist/legacy/build/pdf.mjs" as string
);
const pdf = await (
pdfjsLib as unknown as {
getDocument: (opts: unknown) => {
promise: Promise<{
numPages: number;
getOutline: () => Promise<{ title?: string }[]>;
}>;
};
}
).getDocument({ data: new Uint8Array(content) }).promise;
if (pdf.numPages <= 5) return null;
const outline = await pdf.getOutline();
if (outline?.length)
return outline.map((item, i) => ({
id: `h1-${i}`,
title: item.title ?? `Item ${i + 1}`,
level: 1,
page_number: null,
children: [],
}));
return Array.from({ length: pdf.numPages }, (_, i) => ({
id: `page-${i + 1}`,
title: `Page ${i + 1}`,
level: 1,
page_number: i + 1,
children: [],
}));
} else {
const mammoth = await import("mammoth");
const result = await mammoth.extractRawText({
buffer: Buffer.from(content),
});
const lines = result.value.split("\n").filter((l) => l.trim());
const nodes = lines
.slice(0, 30)
.map((line, i) => ({
id: `h1-${i}`,
title: line.slice(0, 100),
level: 1,
page_number: null,
children: [],
}));
return nodes.length ? nodes : null;
}
} catch {
return null;
}
}

View file

@ -6,8 +6,11 @@ import {
buildMessages,
buildWorkflowStore,
enrichWithPriorEvents,
AssistantStreamError,
extractAnnotations,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
PROJECT_EXTRA_TOOLS,
type ChatMessage,
} from "../lib/chatTools";
@ -151,13 +154,18 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
res.flushHeaders();
const write = (line: string) => res.write(line);
const streamAbort = new AbortController();
let streamFinished = false;
res.on("close", () => {
if (!streamFinished) streamAbort.abort();
});
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
const { fullText, events } = await runLLMStream({
const { events, annotations } = await runLLMStream({
apiMessages,
docStore,
docIndex,
@ -168,14 +176,15 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
workflowStore,
model,
apiKeys,
signal: streamAbort.signal,
projectId,
});
const annotations = extractAnnotations(fullText, docIndex, events);
const persistedEvents = stripTransientAssistantEvents(events);
await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
content: persistedEvents.length ? persistedEvents : null,
annotations: annotations.length ? annotations : null,
});
@ -186,16 +195,47 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
.eq("id", chatId);
}
} catch (err) {
if (isAbortError(err)) {
console.log("[project-chat/stream] client aborted stream", {
chatId,
});
return;
}
console.error("[project-chat/stream] error:", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
const errorFullText =
err instanceof AssistantStreamError ? err.fullText : "";
try {
const annotations = extractAnnotations(
errorFullText,
docIndex,
errorEvents,
);
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: errorEvents.length ? errorEvents : null,
annotations: annotations.length ? annotations : null,
});
if (saveError)
console.error("[project-chat/stream] failed to save error", saveError);
} catch (saveErr) {
console.error("[project-chat/stream] failed to save error", saveErr);
}
try {
write(
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
streamFinished = true;
res.end();
}
});

View file

@ -6,7 +6,12 @@ import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../lib/documentVersions";
import { downloadFile, uploadFile, storageKey } from "../lib/storage";
import {
deleteFile,
downloadFile,
uploadFile,
storageKey,
} from "../lib/storage";
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import { checkProjectAccess } from "../lib/access";
import { singleFileUpload } from "../lib/upload";
@ -367,6 +372,10 @@ projectsRouter.post(
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
await attachActiveVersionPaths(
db,
[doc as { id: string; current_version_id?: string | null }],
);
// Already in this project — idempotent
if (doc.project_id === projectId) return void res.json(doc);
@ -381,22 +390,49 @@ projectsRouter.post(
.single();
if (error || !updated)
return void res.status(500).json({ detail: "Failed to update document" });
await attachActiveVersionPaths(
db,
[updated as { id: string; current_version_id?: string | null }],
);
return void res.json(updated);
} else {
// Belongs to another project → duplicate record AND copy the
// underlying storage objects so each project's copy is fully
// independent (edits/version bumps on one don't leak into the
// other).
if (!doc.current_version_id) {
return void res
.status(404)
.json({ detail: "Source document has no active version" });
}
const { data: srcV } = await db
.from("document_versions")
.select(
"storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
)
.eq("id", doc.current_version_id)
.single();
if (!srcV?.storage_path) {
return void res
.status(404)
.json({ detail: "Source document has no active version" });
}
const activeVersionFilename =
(srcV.filename as string | null)?.trim() || "Untitled document";
const srcBytes = await downloadFile(srcV.storage_path);
if (!srcBytes) {
return void res
.status(500)
.json({ detail: "Failed to read source document bytes" });
}
const { data: copy, error } = await db
.from("documents")
.insert({
project_id: projectId,
user_id: userId,
filename: doc.filename,
file_type: doc.file_type,
size_bytes: doc.size_bytes,
page_count: doc.page_count,
structure_tree: doc.structure_tree,
status: doc.status,
})
.select("*")
@ -404,69 +440,90 @@ projectsRouter.post(
if (error || !copy)
return void res.status(500).json({ detail: "Failed to copy document" });
let copyVersionRowId: string | null = null;
if (doc.current_version_id) {
const { data: srcV } = await db
.from("document_versions")
.select(
"storage_path, pdf_storage_path, version_number, display_name, source",
)
.eq("id", doc.current_version_id)
.single();
if (srcV?.storage_path) {
const srcBytes = await downloadFile(srcV.storage_path);
if (!srcBytes) {
return void res
.status(500)
.json({ detail: "Failed to read source document bytes" });
}
const newKey = storageKey(userId, copy.id as string, doc.filename);
const contentType =
doc.file_type === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
await uploadFile(newKey, srcBytes, contentType);
const newKey = storageKey(
userId,
copy.id as string,
activeVersionFilename,
);
let newPdfPath: string | null = null;
try {
const contentType =
((srcV.file_type as string | null) ?? doc.file_type) === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
await uploadFile(newKey, srcBytes, contentType);
// PDFs share one object for source + display rendition. DOCX
// store the converted PDF at a separate `converted-pdfs/` key —
// copy that too if it exists so the copy renders without going
// back through libreoffice.
let newPdfPath: string | null = null;
if (srcV.pdf_storage_path) {
if (srcV.pdf_storage_path === srcV.storage_path) {
newPdfPath = newKey;
} else {
const pdfBytes = await downloadFile(srcV.pdf_storage_path);
if (pdfBytes) {
const newPdfKey = convertedPdfKey(userId, copy.id as string);
await uploadFile(newPdfKey, pdfBytes, "application/pdf");
newPdfPath = newPdfKey;
}
// PDFs share one object for source + display rendition. DOCX
// store the converted PDF at a separate `converted-pdfs/` key —
// copy that too if it exists so the copy renders without going
// back through libreoffice.
if (srcV.pdf_storage_path) {
if (srcV.pdf_storage_path === srcV.storage_path) {
newPdfPath = newKey;
} else {
const pdfBytes = await downloadFile(srcV.pdf_storage_path);
if (pdfBytes) {
const newPdfKey = convertedPdfKey(userId, copy.id as string);
await uploadFile(newPdfKey, pdfBytes, "application/pdf");
newPdfPath = newPdfKey;
}
}
const { data: newV } = await db
.from("document_versions")
.insert({
document_id: copy.id,
storage_path: newKey,
pdf_storage_path: newPdfPath,
source: (srcV.source as string | null) ?? "upload",
version_number: srcV.version_number ?? 1,
display_name: srcV.display_name ?? doc.filename,
})
.select("id")
.single();
copyVersionRowId = (newV?.id as string | null) ?? null;
if (copyVersionRowId) {
await db
.from("documents")
.update({ current_version_id: copyVersionRowId })
.eq("id", copy.id);
}
}
const { data: newV, error: newVError } = await db
.from("document_versions")
.insert({
document_id: copy.id,
storage_path: newKey,
pdf_storage_path: newPdfPath,
source: (srcV.source as string | null) ?? "upload",
version_number: srcV.version_number ?? 1,
filename: activeVersionFilename,
file_type: (srcV.file_type as string | null) ?? doc.file_type,
size_bytes:
(srcV.size_bytes as number | null) ?? doc.size_bytes ?? null,
page_count:
(srcV.page_count as number | null) ?? doc.page_count ?? null,
})
.select("id")
.single();
const copyVersionRowId = (newV?.id as string | null) ?? null;
if (newVError || !copyVersionRowId) {
throw new Error(
`Failed to create copied document version: ${newVError?.message ?? "unknown"}`,
);
}
const { data: updatedCopy, error: updateCopyError } = await db
.from("documents")
.update({
current_version_id: copyVersionRowId,
})
.eq("id", copy.id)
.select("*")
.single();
if (updateCopyError || !updatedCopy) {
throw new Error(
`Failed to activate copied document version: ${updateCopyError?.message ?? "unknown"}`,
);
}
await attachActiveVersionPaths(
db,
[updatedCopy as { id: string; current_version_id?: string | null }],
);
return void res.status(201).json(updatedCopy);
} catch (err) {
console.error("[projects/documents/copy] failed", err);
await Promise.all([
deleteFile(newKey).catch(() => {}),
newPdfPath && newPdfPath !== newKey
? deleteFile(newPdfPath).catch(() => {})
: Promise.resolve(),
db.from("documents").delete().eq("id", copy.id),
]);
return void res.status(500).json({ detail: "Failed to copy document" });
}
return void res.status(201).json(copy);
}
},
);
@ -484,20 +541,33 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re
const { data: doc } = await db
.from("documents")
.select("id, filename, current_version_id")
.select("id, current_version_id")
.eq("id", documentId)
.eq("project_id", projectId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const filename = normalizeDocumentFilename(req.body?.filename, doc.filename as string);
const active = doc.current_version_id
? await db
.from("document_versions")
.select("filename")
.eq("id", doc.current_version_id)
.eq("document_id", documentId)
.single()
: null;
const currentName =
typeof active?.data?.filename === "string" &&
active.data.filename.trim()
? active.data.filename.trim()
: "Untitled document";
const filename = normalizeDocumentFilename(req.body?.filename, currentName);
if (!filename)
return void res.status(400).json({ detail: "filename is required" });
const { data: updated, error } = await db
.from("documents")
.update({ filename, updated_at: new Date().toISOString() })
.update({ updated_at: new Date().toISOString() })
.eq("id", documentId)
.eq("project_id", projectId)
.select("*")
@ -508,12 +578,15 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re
if (doc.current_version_id) {
await db
.from("document_versions")
.update({ display_name: filename })
.update({ filename })
.eq("id", doc.current_version_id)
.eq("document_id", documentId);
}
res.json(updated);
res.json({
...updated,
filename,
});
});
// POST /projects/:projectId/documents
@ -714,9 +787,6 @@ export async function handleDocumentUpload(
.insert({
project_id: projectId,
user_id: userId,
filename,
file_type: suffix,
size_bytes: content.byteLength,
status: "processing",
})
.select("*")
@ -747,7 +817,6 @@ export async function handleDocumentUpload(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer;
const tree = await extractStructureTree(rawBuf, suffix, filename);
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
@ -785,7 +854,10 @@ export async function handleDocumentUpload(
pdf_storage_path: pdfStoragePath,
source: "upload",
version_number: 1,
display_name: filename,
filename,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
})
.select("id")
.single();
@ -799,9 +871,6 @@ export async function handleDocumentUpload(
.from("documents")
.update({
current_version_id: versionRow.id,
size_bytes: content.byteLength,
page_count: pageCount,
structure_tree: tree ?? null,
status: "ready",
updated_at: new Date().toISOString(),
})
@ -813,10 +882,15 @@ export async function handleDocumentUpload(
.eq("id", docId)
.single();
const responseDoc = updated
? {
? {
...updated,
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
active_version_number: 1,
}
: updated;
return void res.status(201).json(responseDoc);
@ -843,63 +917,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> {
return null;
}
}
async function extractStructureTree(
content: ArrayBuffer,
fileType: string,
filename: string,
): Promise<unknown[] | null> {
try {
if (fileType === "pdf") {
const pdfjsLib = await import(
"pdfjs-dist/legacy/build/pdf.mjs" as string
);
const pdf = await (
pdfjsLib as unknown as {
getDocument: (opts: unknown) => {
promise: Promise<{
numPages: number;
getOutline: () => Promise<{ title?: string }[]>;
}>;
};
}
).getDocument({ data: new Uint8Array(content) }).promise;
if (pdf.numPages <= 5) return null;
const outline = await pdf.getOutline();
if (outline?.length) {
return outline.map((item, i) => ({
id: `h1-${i}`,
title: item.title ?? `Item ${i + 1}`,
level: 1,
page_number: null,
children: [],
}));
}
return Array.from({ length: pdf.numPages }, (_, i) => ({
id: `page-${i + 1}`,
title: `Page ${i + 1}`,
level: 1,
page_number: i + 1,
children: [],
}));
} else {
const mammoth = await import("mammoth");
const result = await mammoth.extractRawText({
buffer: Buffer.from(content),
});
const lines = result.value.split("\n").filter((l) => l.trim());
const nodes = lines
.slice(0, 30)
.map((line, i) => ({
id: `h1-${i}`,
title: line.slice(0, 100),
level: 1,
page_number: null,
children: [],
}));
return nodes.length ? nodes : null;
}
} catch {
return null;
}
}

View file

@ -2,10 +2,16 @@ import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { downloadFile } from "../lib/storage";
import { loadActiveVersion } from "../lib/documentVersions";
import {
attachActiveVersionPaths,
loadActiveVersion,
} from "../lib/documentVersions";
import { normalizeDocxZipPaths } from "../lib/convert";
import {
AssistantStreamError,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
TABULAR_TOOLS,
type ChatMessage,
type TabularCellStore,
@ -370,6 +376,11 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
docIds.length > 0
? await db.from("documents").select("*").in("id", docIds)
: { data: [] as Record<string, unknown>[] };
const docs = (docsResult.data ?? []) as unknown as {
id: string;
current_version_id?: string | null;
}[];
await attachActiveVersionPaths(db, docs);
res.json({
review: { ...review, is_owner: access.isOwner },
@ -377,7 +388,7 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
...cell,
content: parseCellContent(cell.content),
})),
documents: docsResult.data ?? [],
documents: docs,
});
});
@ -471,8 +482,19 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
if (req.body.title != null) updates.title = req.body.title;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
if (req.body.project_id !== undefined)
updates.project_id = req.body.project_id;
const projectIdUpdateProvided = req.body.project_id !== undefined;
const projectIdUpdate =
req.body.project_id === null
? null
: typeof req.body.project_id === "string" &&
req.body.project_id.trim()
? req.body.project_id.trim()
: undefined;
if (projectIdUpdateProvided && projectIdUpdate === undefined) {
return void res.status(400).json({
detail: "project_id must be a non-empty string or null",
});
}
// shared_with edits are owner-only — gated below after we know who's
// making the call. Normalize lowercase + dedupe + drop empties.
let sharedWithUpdate: string[] | undefined;
@ -519,6 +541,27 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
.json({ detail: "Only the review owner can change sharing" });
updates.shared_with = sharedWithUpdate;
}
if (projectIdUpdateProvided) {
if (!access.isOwner) {
return void res.status(403).json({
detail: "Only the review owner can move a review",
});
}
if (projectIdUpdate) {
const projectAccess = await checkProjectAccess(
projectIdUpdate,
userId,
userEmail,
db,
);
if (!projectAccess.ok) {
return void res
.status(404)
.json({ detail: "Target project not found" });
}
}
updates.project_id = projectIdUpdate;
}
const { data: updatedReview, error: updateError } = await db
.from("tabular_reviews")
@ -744,7 +787,7 @@ tabularRouter.post(
return void res.status(404).json({ detail: "Document not found" });
const { data: doc } = await db
.from("documents")
.select("id, filename, file_type")
.select("id, current_version_id")
.eq("id", document_id)
.single();
if (!doc)
@ -776,7 +819,7 @@ tabularRouter.post(
if (buf) {
try {
markdown =
(doc.file_type as string) === "pdf"
docActive.file_type === "pdf"
? await extractPdfMarkdown(buf)
: await extractDocxMarkdown(buf);
} catch (err) {
@ -790,7 +833,7 @@ tabularRouter.post(
const result = await queryTabularCell(
tabular_model,
doc.filename as string,
docActive?.filename?.trim() || "Untitled document",
markdown,
column.prompt,
column.format,
@ -866,18 +909,25 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
filteredIds.length > 0
? await db
.from("documents")
.select("id, filename, file_type, page_count")
.select("id, current_version_id")
.in("id", filteredIds)
: { data: [] as Record<string, unknown>[] };
docs = data ?? [];
} else if (review.project_id) {
const { data } = await db
.from("documents")
.select("id, filename, file_type, page_count")
.select("id, current_version_id")
.eq("project_id", review.project_id)
.order("created_at", { ascending: true });
docs = data ?? [];
}
await attachActiveVersionPaths(
db,
docs as {
id: string;
current_version_id?: string | null;
}[],
);
const { tabular_model, api_keys } = await getUserModelSettings(userId, db);
const missingKey = missingModelApiKey(tabular_model, api_keys);
@ -900,16 +950,22 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
await Promise.all(
docs.map(async (doc) => {
const docId = doc.id as string;
const filename = doc.filename as string;
let markdown = "";
const active = await loadActiveVersion(docId, db);
if (active) {
const buf = await downloadFile(active.storage_path);
const filename =
(typeof doc.filename === "string" && doc.filename.trim()
? doc.filename.trim()
: "Untitled document");
const storagePath =
typeof doc.storage_path === "string" ? doc.storage_path : "";
const fileType =
typeof doc.file_type === "string" ? doc.file_type : "";
if (storagePath) {
const buf = await downloadFile(storagePath);
if (buf) {
try {
markdown =
(doc.file_type as string) === "pdf"
fileType === "pdf"
? await extractPdfMarkdown(buf)
: await extractDocxMarkdown(buf);
} catch (err) {
@ -1253,14 +1309,29 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
const docIds = [
...new Set((cells ?? []).map((c: any) => c.document_id as string)),
];
let docs: { id: string; filename: string }[] = [];
let docs: {
id: string;
filename: string;
current_version_id?: string | null;
}[] = [];
if (docIds.length > 0) {
const { data } = await db
.from("documents")
.select("id, filename")
.select("id, current_version_id")
.in("id", docIds)
.order("created_at", { ascending: true });
docs = (data ?? []) as { id: string; filename: string }[];
const attachedDocs = (data ?? []) as {
id: string;
current_version_id?: string | null;
filename?: string | null;
}[];
await attachActiveVersionPaths(db, attachedDocs);
docs = attachedDocs.map((doc) => ({
...doc,
filename:
(typeof doc.filename === "string" && doc.filename.trim()) ||
"Untitled document",
}));
}
const sortedColumns = (
@ -1339,6 +1410,11 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
const write = (line: string) => res.write(line);
const streamAbort = new AbortController();
let streamFinished = false;
res.on("close", () => {
if (!streamFinished) streamAbort.abort();
});
if (chatId) {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
@ -1353,20 +1429,23 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
db,
write,
extraTools: TABULAR_TOOLS,
includeResearchTools: false,
tabularStore,
buildCitations: (text) =>
extractTabularAnnotations(text, tabularStore),
model: tabular_model,
apiKeys: api_keys,
signal: streamAbort.signal,
});
const persistedEvents = stripTransientAssistantEvents(events);
const annotations = extractTabularAnnotations(fullText, tabularStore);
if (chatId) {
await db.from("tabular_review_chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
content: persistedEvents.length ? persistedEvents : null,
annotations: annotations.length ? annotations : null,
});
await db
@ -1398,16 +1477,48 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
}
}
} catch (err) {
if (isAbortError(err)) {
console.log("[tabular/chat] client aborted stream", { chatId });
return;
}
console.error("[tabular/chat] error", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
const errorFullText =
err instanceof AssistantStreamError ? err.fullText : "";
if (chatId) {
try {
const annotations = extractTabularAnnotations(
errorFullText,
tabularStore,
);
const { error: saveError } = await db
.from("tabular_review_chat_messages")
.insert({
chat_id: chatId,
role: "assistant",
content: errorEvents.length ? errorEvents : null,
annotations: annotations.length ? annotations : null,
});
if (saveError)
console.error("[tabular/chat] failed to save error", saveError);
} catch (saveErr) {
console.error("[tabular/chat] failed to save error", saveErr);
}
}
try {
write(
`data: ${JSON.stringify({ type: "error", message: String(err) })}\n\n`,
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
streamFinished = true;
res.end();
}
});

View file

@ -1,7 +1,13 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm";
import {
DEFAULT_TABULAR_MODEL,
DEFAULT_TITLE_MODEL,
CLAUDE_LOW_MODELS,
OPENAI_LOW_MODELS,
resolveModel,
} from "../lib/llm";
import {
type ApiKeyStatus,
getUserApiKeyStatus,
@ -20,14 +26,85 @@ type UserProfileRow = {
message_credits_used: number;
credits_reset_date: string;
tier: string;
title_model: string | null;
tabular_model: string;
};
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (error && typeof error === "object") {
const record = error as {
message?: unknown;
details?: unknown;
hint?: unknown;
code?: unknown;
};
return [record.message, record.details, record.hint, record.code]
.filter((value): value is string => typeof value === "string" && !!value)
.join(" ")
|| JSON.stringify(error);
}
return String(error);
}
const PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model";
const LEGACY_PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model";
function isMissingProfileModelColumn(error: unknown): boolean {
const record =
error && typeof error === "object"
? (error as { code?: unknown; message?: unknown })
: {};
const message = typeof record.message === "string" ? record.message : "";
return (
record.code === "42703" ||
message.includes("title_model")
);
}
async function selectProfile(
db: ReturnType<typeof createServerSupabase>,
userId: string,
mode: "maybe" | "single",
) {
const query = db
.from("user_profiles")
.select(PROFILE_SELECT)
.eq("user_id", userId);
const result = mode === "single" ? await query.single() : await query.maybeSingle();
if (!result.error || !isMissingProfileModelColumn(result.error)) {
return result;
}
const legacyQuery = db
.from("user_profiles")
.select(LEGACY_PROFILE_SELECT)
.eq("user_id", userId);
const legacy =
mode === "single" ? await legacyQuery.single() : await legacyQuery.maybeSingle();
if (legacy.data && typeof legacy.data === "object") {
const row = legacy.data as Record<string, unknown>;
Object.assign(row, {
title_model: null,
});
}
return legacy;
}
function serializeProfile(
row: UserProfileRow,
apiKeyStatus?: ApiKeyStatus,
) {
const creditsUsed = row.message_credits_used ?? 0;
const titleFallback = apiKeyStatus?.gemini
? DEFAULT_TITLE_MODEL
: apiKeyStatus?.openai
? OPENAI_LOW_MODELS[0]
: apiKeyStatus?.claude
? CLAUDE_LOW_MODELS[0]
: DEFAULT_TITLE_MODEL;
return {
displayName: row.display_name,
organisation: row.organisation,
@ -35,6 +112,7 @@ function serializeProfile(
creditsResetDate: row.credits_reset_date,
creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0),
tier: row.tier || "Free",
titleModel: resolveModel(row.title_model, titleFallback),
tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL),
...(apiKeyStatus ? { apiKeyStatus } : {}),
};
@ -46,6 +124,7 @@ function validateProfilePayload(body: unknown):
update: {
display_name?: string | null;
organisation?: string | null;
title_model?: string;
tabular_model?: string;
updated_at: string;
};
@ -59,6 +138,7 @@ function validateProfilePayload(body: unknown):
const allowedFields = new Set([
"displayName",
"organisation",
"titleModel",
"tabularModel",
]);
const invalidField = Object.keys(raw).find((key) => !allowedFields.has(key));
@ -69,6 +149,7 @@ function validateProfilePayload(body: unknown):
const update: {
display_name?: string | null;
organisation?: string | null;
title_model?: string;
tabular_model?: string;
updated_at: string;
} = { updated_at: new Date().toISOString() };
@ -98,6 +179,17 @@ function validateProfilePayload(body: unknown):
update.tabular_model = resolved;
}
if ("titleModel" in raw) {
if (typeof raw.titleModel !== "string") {
return { ok: false, detail: "titleModel must be a string" };
}
const resolved = resolveModel(raw.titleModel, "");
if (!resolved) {
return { ok: false, detail: "Unsupported titleModel" };
}
update.title_model = resolved;
}
return { ok: true, update };
}
@ -117,15 +209,9 @@ async function ensureProfileRow(
async function loadProfile(
db: ReturnType<typeof createServerSupabase>,
userId: string,
options: { repairMissing?: boolean } = {},
options: { repairMissing?: boolean; apiKeyStatus?: ApiKeyStatus } = {},
) {
let { data, error } = await db
.from("user_profiles")
.select(
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
)
.eq("user_id", userId)
.maybeSingle();
let { data, error } = await selectProfile(db, userId, "maybe");
if (error) return { data: null, error };
if (!data) {
@ -136,13 +222,7 @@ async function loadProfile(
const ensureError = await ensureProfileRow(db, userId);
if (ensureError) return { data: null, error: ensureError };
const created = await db
.from("user_profiles")
.select(
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
)
.eq("user_id", userId)
.single();
const created = await selectProfile(db, userId, "single");
if (created.error) return { data: null, error: created.error };
data = created.data;
}
@ -151,24 +231,26 @@ async function loadProfile(
if (row.credits_reset_date && new Date() > new Date(row.credits_reset_date)) {
const creditsResetDate = new Date();
creditsResetDate.setDate(creditsResetDate.getDate() + 30);
const { data: resetData, error: resetError } = await db
const { error: resetError } = await db
.from("user_profiles")
.update({
message_credits_used: 0,
credits_reset_date: creditsResetDate.toISOString(),
updated_at: new Date().toISOString(),
})
.eq("user_id", userId)
.select(
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
)
.single();
.eq("user_id", userId);
if (resetError) return { data: null, error: resetError };
const { data: resetData, error: resetLoadError } = await selectProfile(
db,
userId,
"single",
);
if (resetLoadError) return { data: null, error: resetLoadError };
row = resetData as UserProfileRow;
}
return { data: serializeProfile(row), error: null };
return { data: serializeProfile(row, options.apiKeyStatus), error: null };
}
// POST /user/profile
@ -184,11 +266,12 @@ userRouter.post("/profile", requireAuth, async (_req, res) => {
userRouter.get("/profile", requireAuth, async (_req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
const { data, error } = await loadProfile(db, userId, {
repairMissing: true,
apiKeyStatus,
});
if (error) return void res.status(500).json({ detail: error.message });
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
res.json({ ...data, apiKeyStatus });
});
@ -210,9 +293,9 @@ userRouter.patch("/profile", requireAuth, async (req, res) => {
if (updateError)
return void res.status(500).json({ detail: updateError.message });
const { data, error } = await loadProfile(db, userId);
if (error) return void res.status(500).json({ detail: error.message });
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
const { data, error } = await loadProfile(db, userId, { apiKeyStatus });
if (error) return void res.status(500).json({ detail: error.message });
res.json({ ...data, apiKeyStatus });
});
@ -245,11 +328,12 @@ userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => {
const status = await getUserApiKeyStatus(userId, db);
res.json(status);
} catch (err) {
const detail = errorMessage(err);
console.error("[user/api-keys] save failed", {
provider,
error: err instanceof Error ? err.message : String(err),
error: detail,
});
res.status(500).json({ detail: "Failed to save API key" });
res.status(500).json({ detail });
}
});