Sync CourtListener verification and document safety updates

- Refine CourtListener citation verification, bulk lookup logging, and API fallback behavior
- Persist cancelled chat stream output and render cancellation as the final assistant message
- Add document/version deletion safety fixes and shared warning/modal UI updates
- Sync document panel, case law panel, and response UI styling refinements
- Harden OSS sync script to preserve local env, dependency, and generated files
This commit is contained in:
willchen96 2026-06-09 01:46:58 +08:00
parent 44e868eb42
commit f32a194b33
24 changed files with 2494 additions and 1222 deletions

View file

@ -44,7 +44,7 @@ caseLawRouter.post("/case-opinions", async (req, res) => {
clusterId,
});
const db = createServerSupabase();
const fetchKey = String(clusterId);
const fetchKey = `${userId}:${clusterId}`;
let fetchPromise = sidepanelOpinionFetches.get(fetchKey);
if (fetchPromise) {
devLog("[case-law/case-opinions] joining in-flight fetch", {

View file

@ -7,6 +7,7 @@ import {
enrichWithPriorEvents,
buildWorkflowStore,
AssistantStreamError,
buildCancelledAssistantMessage,
extractAnnotations,
isAbortError,
runLLMStream,
@ -614,6 +615,28 @@ chatRouter.post("/", requireAuth, async (req, res) => {
} catch (err) {
if (isAbortError(err)) {
devLog("[chat/stream] client aborted stream", { chatId });
if (err instanceof AssistantStreamError) {
const partial = buildCancelledAssistantMessage({
fullText: err.fullText,
events: err.events,
buildAnnotations: (fullText, events) =>
extractAnnotations(fullText, docIndex, events),
});
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: partial.events.length ? partial.events : null,
annotations: partial.annotations.length
? partial.annotations
: null,
});
if (saveError) {
console.error(
"[chat/stream] failed to save aborted stream",
saveError,
);
}
}
return;
}
console.error("[chat/stream] error:", err);

View file

@ -423,6 +423,15 @@ documentsRouter.post(
const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db);
if (!sourceAccess.ok)
return void res.status(404).json({ detail: "Source document not found" });
const willDeleteSource =
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id;
if (willDeleteSource && !sourceAccess.isOwner) {
return void res.status(403).json({
detail: "Only the source document owner can move it into a version.",
});
}
const targetActive = await loadActiveVersion(documentId, db);
const targetType = targetActive?.file_type ?? "";
@ -548,11 +557,7 @@ documentsRouter.post(
.json({ detail: "Failed to update document current version." });
}
if (
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id
) {
if (willDeleteSource) {
const { error: deleteErr } = await deleteDocumentAndVersionFiles(
db,
sourceDocumentId,
@ -721,12 +726,21 @@ documentsRouter.post(
.json({ detail: "Failed to record new version." });
}
await db
const { error: updateDocErr } = await db
.from("documents")
.update({
current_version_id: versionRow.id,
})
.eq("id", documentId);
if (updateDocErr) {
console.error(
"[versions/upload] current version update failed",
updateDocErr,
);
return void res
.status(500)
.json({ detail: "Failed to update document current version." });
}
res.status(201).json(versionRow);
},

View file

@ -7,6 +7,7 @@ import {
buildWorkflowStore,
enrichWithPriorEvents,
AssistantStreamError,
buildCancelledAssistantMessage,
extractAnnotations,
isAbortError,
runLLMStream,
@ -199,6 +200,28 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
console.log("[project-chat/stream] client aborted stream", {
chatId,
});
if (err instanceof AssistantStreamError) {
const partial = buildCancelledAssistantMessage({
fullText: err.fullText,
events: err.events,
buildAnnotations: (fullText, events) =>
extractAnnotations(fullText, docIndex, events),
});
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: partial.events.length ? partial.events : null,
annotations: partial.annotations.length
? partial.annotations
: null,
});
if (saveError) {
console.error(
"[project-chat/stream] failed to save aborted stream",
saveError,
);
}
}
return;
}
console.error("[project-chat/stream] error:", err);

View file

@ -28,6 +28,37 @@ function normalizeDocumentFilename(nextName: unknown, currentName: string) {
return `${trimmed}${ext}`;
}
async function deleteProjectDocumentsAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
projectId: string,
documentIds: string[],
) {
if (documentIds.length === 0) return null;
const { data: versions, error: versionsError } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.in("document_id", documentIds);
if (versionsError) return versionsError;
const paths = new Set<string>();
for (const v of versions ?? []) {
if (typeof v.storage_path === "string" && v.storage_path.length > 0) {
paths.add(v.storage_path);
}
if (typeof v.pdf_storage_path === "string" && v.pdf_storage_path.length > 0) {
paths.add(v.pdf_storage_path);
}
}
await Promise.all([...paths].map((p) => deleteFile(p).catch(() => {})));
const { error } = await db
.from("documents")
.delete()
.eq("project_id", projectId)
.in("id", documentIds);
return error ?? null;
}
// GET /projects
projectsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
@ -710,11 +741,48 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req,
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
const folder = await loadProjectFolder(db, projectId, folderId);
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
const { data: allFolders, error: foldersError } = await db
.from("project_subfolders")
.select("id, parent_folder_id")
.eq("project_id", projectId);
if (foldersError)
return void res.status(500).json({ detail: foldersError.message });
if (!(allFolders ?? []).some((f) => f.id === folderId))
return void res.status(404).json({ detail: "Folder not found" });
// Move direct documents to root before cascade-deleting subfolders
await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId).eq("project_id", projectId);
const childrenByParent = new Map<string, string[]>();
for (const f of allFolders ?? []) {
const parentId = f.parent_folder_id as string | null;
if (!parentId) continue;
const children = childrenByParent.get(parentId) ?? [];
children.push(f.id as string);
childrenByParent.set(parentId, children);
}
const folderIds = new Set<string>();
const stack = [folderId];
while (stack.length > 0) {
const id = stack.pop()!;
if (folderIds.has(id)) continue;
folderIds.add(id);
stack.push(...(childrenByParent.get(id) ?? []));
}
const { data: docs, error: docsError } = await db
.from("documents")
.select("id")
.eq("project_id", projectId)
.in("folder_id", [...folderIds]);
if (docsError) return void res.status(500).json({ detail: docsError.message });
const docIds = (docs ?? []).map((d) => d.id as string);
const deleteDocsError = await deleteProjectDocumentsAndVersionFiles(
db,
projectId,
docIds,
);
if (deleteDocsError)
return void res.status(500).json({ detail: deleteDocsError.message });
const { error } = await db.from("project_subfolders")
.delete().eq("id", folderId).eq("project_id", projectId);

View file

@ -9,6 +9,7 @@ import {
import { normalizeDocxZipPaths } from "../lib/convert";
import {
AssistantStreamError,
buildCancelledAssistantMessage,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
@ -480,8 +481,6 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
const { reviewId } = req.params;
const updates: Record<string, unknown> = {};
if (req.body.title != null) updates.title = req.body.title;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
const projectIdUpdateProvided = req.body.project_id !== undefined;
const projectIdUpdate =
req.body.project_id === null
@ -534,6 +533,14 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
);
if (!access.ok)
return void res.status(404).json({ detail: "Review not found" });
if (req.body.columns_config != null) {
if (!access.isOwner) {
return void res.status(403).json({
detail: "Only the review owner can change columns",
});
}
updates.columns_config = req.body.columns_config;
}
if (sharedWithUpdate !== undefined) {
if (!access.isOwner)
return void res
@ -1365,8 +1372,9 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
messages.filter((m) => m.role === "user").length === 1;
if (chatId) {
// Either chat owner OR any project member of the parent review can
// continue the chat. We've already verified review access above.
// The chat must belong to this exact review and to the requester.
// Review access alone is not enough: otherwise a user could reuse one
// of their chats from a different review in this route.
const { data: existing } = await db
.from("tabular_review_chats")
.select("id, title, review_id, user_id")
@ -1374,7 +1382,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
.single();
const canUse =
!!existing &&
(existing.review_id === reviewId || existing.user_id === userId);
existing.review_id === reviewId &&
existing.user_id === userId;
if (!canUse || !existing) chatId = null;
else chatTitle = existing.title;
}
@ -1479,6 +1488,34 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
} catch (err) {
if (isAbortError(err)) {
console.log("[tabular/chat] client aborted stream", { chatId });
if (chatId && err instanceof AssistantStreamError) {
const partial = buildCancelledAssistantMessage({
fullText: err.fullText,
events: err.events,
buildAnnotations: (fullText) =>
extractTabularAnnotations(fullText, tabularStore),
});
const { error: saveError } = await db
.from("tabular_review_chat_messages")
.insert({
chat_id: chatId,
role: "assistant",
content: partial.events.length ? partial.events : null,
annotations: partial.annotations.length
? partial.annotations
: null,
});
if (saveError) {
console.error(
"[tabular/chat] failed to save aborted stream",
saveError,
);
}
await db
.from("tabular_review_chats")
.update({ updated_at: new Date().toISOString() })
.eq("id", chatId);
}
return;
}
console.error("[tabular/chat] error", err);