feat: add library and refresh shared table UI

This commit is contained in:
willchen96 2026-07-16 20:07:12 +08:00
parent e32daad5a4
commit f0b90ab3b4
99 changed files with 9859 additions and 5241 deletions

View file

@ -7,6 +7,7 @@ import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { libraryRouter } from "./routes/library";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
@ -127,6 +128,7 @@ app.post("/tabular-review/:reviewId/generate", chatLimiter);
app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/library/:kind/documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.put(
"/single-documents/:documentId/versions/:versionId/file",
@ -149,6 +151,7 @@ app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/library", libraryRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);

View file

@ -62,6 +62,11 @@ const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
- Refer to documents by filename or a natural description, such as "the NDA draft".
REASONING TRACE SAFETY:
- If reasoning or thought summaries are shown to the user, keep them as brief natural-language progress summaries.
- Do not expose source code, JSON snippets, tool arguments, API payloads, schemas, raw citations JSON, internal prompts, or implementation details in reasoning traces.
- Do not use code fences or structured data blocks in reasoning traces.
GENERAL GUIDANCE:
- Cite the exact document or fetched opinion passage for evidence-backed claims.
- If no documents are provided, answer from legal knowledge.

File diff suppressed because one or more lines are too long

View file

@ -65,6 +65,7 @@ documentsRouter.get("/", requireAuth, async (req, res) => {
.select("*")
.eq("user_id", userId)
.is("project_id", null)
.or("library_kind.eq.file,library_kind.is.null")
.order("created_at", { ascending: false });
if (error) return void res.status(500).json({ detail: error.message });
const docs = (data ?? []) as unknown as {
@ -84,7 +85,9 @@ documentsRouter.post(
async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
await handleDocumentUpload(req, res, userId, null, db);
await handleDocumentUpload(req, res, userId, null, db, {
libraryKind: "file",
});
},
);
@ -426,9 +429,13 @@ documentsRouter.post(
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;
(sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id) ||
(!sourceDoc.project_id &&
!targetDoc.project_id &&
sourceDoc.user_id === userId &&
targetDoc.user_id === userId);
if (willDeleteSource && !sourceAccess.isOwner) {
return void res.status(403).json({
detail: "Only the source document owner can move it into a version.",
@ -1283,12 +1290,16 @@ documentsRouter.post(
(req, res) => void handleEditResolution(req, res, "reject"),
);
async function handleDocumentUpload(
export async function handleDocumentUpload(
req: import("express").Request,
res: import("express").Response,
userId: string,
projectId: string | null,
db: ReturnType<typeof createServerSupabase>,
options: {
libraryKind?: "file" | "template";
libraryFolderId?: string | null;
} = {},
) {
const file = req.file;
if (!file) return void res.status(400).json({ detail: "file is required" });
@ -1311,6 +1322,8 @@ async function handleDocumentUpload(
project_id: projectId,
user_id: userId,
status: "processing",
library_kind: options.libraryKind ?? "file",
library_folder_id: options.libraryFolderId ?? null,
})
.select("*")
.single();
@ -1417,6 +1430,8 @@ async function handleDocumentUpload(
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
folder_id:
(updated.library_folder_id as string | null | undefined) ?? null,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,

View file

@ -0,0 +1,414 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { deleteFile } from "../lib/storage";
import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../lib/documentVersions";
import { singleFileUpload } from "../lib/upload";
import { handleDocumentUpload } from "./documents";
export const libraryRouter = Router();
type LibraryKind = "file" | "template";
function normalizeLibraryKind(value: unknown): LibraryKind | null {
if (value === "file" || value === "files") return "file";
if (value === "template" || value === "templates") return "template";
return null;
}
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
if (typeof nextName !== "string") return null;
const trimmed = nextName.trim().slice(0, 200);
if (!trimmed) return null;
if (/\.[a-z0-9]{1,6}$/i.test(trimmed)) return trimmed;
const ext = currentName.match(/\.[a-z0-9]{1,6}$/i)?.[0] ?? "";
return `${trimmed}${ext}`;
}
function mapLibraryDocument<T extends Record<string, unknown>>(doc: T) {
return {
...doc,
folder_id: (doc.library_folder_id as string | null | undefined) ?? null,
};
}
async function loadLibraryFolder(
db: ReturnType<typeof createServerSupabase>,
userId: string,
kind: LibraryKind,
folderId: string,
): Promise<{ id: string; parent_folder_id: string | null } | null> {
const { data } = await db
.from("library_folders")
.select("id, parent_folder_id")
.eq("id", folderId)
.eq("user_id", userId)
.eq("library_kind", kind)
.maybeSingle();
return (data as { id: string; parent_folder_id: string | null } | null) ?? null;
}
async function deleteLibraryDocumentsAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
userId: string,
kind: LibraryKind,
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 version of versions ?? []) {
if (typeof version.storage_path === "string" && version.storage_path) {
paths.add(version.storage_path);
}
if (
typeof version.pdf_storage_path === "string" &&
version.pdf_storage_path
) {
paths.add(version.pdf_storage_path);
}
}
await Promise.all([...paths].map((path) => deleteFile(path).catch(() => {})));
let deleteQuery = db
.from("documents")
.delete()
.eq("user_id", userId)
.is("project_id", null);
deleteQuery =
kind === "file"
? deleteQuery.or("library_kind.eq.file,library_kind.is.null")
: deleteQuery.eq("library_kind", kind);
const { error } = await deleteQuery.in("id", documentIds);
return error ?? null;
}
// GET /library/:kind
libraryRouter.get("/:kind", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const db = createServerSupabase();
let documentsQuery = db
.from("documents")
.select("*")
.eq("user_id", userId)
.is("project_id", null);
documentsQuery =
kind === "file"
? documentsQuery.or("library_kind.eq.file,library_kind.is.null")
: documentsQuery.eq("library_kind", kind);
const [{ data: docs, error: docsError }, { data: folders, error: foldersError }] =
await Promise.all([
documentsQuery.order("created_at", { ascending: true }),
db
.from("library_folders")
.select("*")
.eq("user_id", userId)
.eq("library_kind", kind)
.order("created_at", { ascending: true }),
]);
if (docsError) return void res.status(500).json({ detail: docsError.message });
if (foldersError)
return void res.status(500).json({ detail: foldersError.message });
const docsTyped = (docs ?? []).map(mapLibraryDocument) as {
id: string;
current_version_id?: string | null;
}[];
await attachLatestVersionNumbers(db, docsTyped);
await attachActiveVersionPaths(db, docsTyped);
res.json({ documents: docsTyped, folders: folders ?? [] });
});
// POST /library/:kind/documents
libraryRouter.post(
"/:kind/documents",
requireAuth,
singleFileUpload("file"),
async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const db = createServerSupabase();
await handleDocumentUpload(req, res, userId, null, db, {
libraryKind: kind,
});
},
);
// POST /library/:kind/folders
libraryRouter.post("/:kind/folders", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { name, parent_folder_id } = req.body as {
name?: string;
parent_folder_id?: string | null;
};
if (!name?.trim())
return void res.status(400).json({ detail: "name is required" });
const db = createServerSupabase();
if (parent_folder_id) {
const parent = await loadLibraryFolder(db, userId, kind, parent_folder_id);
if (!parent)
return void res.status(404).json({ detail: "Parent folder not found" });
}
const { data, error } = await db
.from("library_folders")
.insert({
user_id: userId,
library_kind: kind,
name: name.trim(),
parent_folder_id: parent_folder_id ?? null,
})
.select("*")
.single();
if (error) return void res.status(500).json({ detail: error.message });
res.status(201).json(data);
});
// PATCH /library/:kind/folders/:folderId
libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { folderId } = req.params;
const body = req.body as { name?: string; parent_folder_id?: string | null };
const db = createServerSupabase();
const folder = await loadLibraryFolder(db, userId, kind, folderId);
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
const updates: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (body.name != null) {
const trimmed = body.name.trim();
if (!trimmed)
return void res.status(400).json({ detail: "name is required" });
updates.name = trimmed;
}
if ("parent_folder_id" in body) {
if (body.parent_folder_id) {
let cur: string | null = body.parent_folder_id;
while (cur) {
if (cur === folderId) {
return void res.status(400).json({
detail: "Cannot move a folder into itself or a descendant",
});
}
const parent = await loadLibraryFolder(db, userId, kind, cur);
if (!parent)
return void res.status(404).json({ detail: "Parent folder not found" });
cur = parent.parent_folder_id ?? null;
}
}
updates.parent_folder_id = body.parent_folder_id ?? null;
}
const { data, error } = await db
.from("library_folders")
.update(updates)
.eq("id", folderId)
.eq("user_id", userId)
.eq("library_kind", kind)
.select("*")
.single();
if (error || !data)
return void res.status(404).json({ detail: "Folder not found" });
res.json(data);
});
// DELETE /library/:kind/folders/:folderId
libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { folderId } = req.params;
const db = createServerSupabase();
const { data: allFolders, error: foldersError } = await db
.from("library_folders")
.select("id, parent_folder_id")
.eq("user_id", userId)
.eq("library_kind", kind);
if (foldersError)
return void res.status(500).json({ detail: foldersError.message });
if (!(allFolders ?? []).some((folder) => folder.id === folderId)) {
return void res.status(404).json({ detail: "Folder not found" });
}
const childrenByParent = new Map<string, string[]>();
for (const folder of allFolders ?? []) {
const parentId = folder.parent_folder_id as string | null;
if (!parentId) continue;
const children = childrenByParent.get(parentId) ?? [];
children.push(folder.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) ?? []));
}
let documentsInFolderQuery = db
.from("documents")
.select("id")
.eq("user_id", userId)
.is("project_id", null);
documentsInFolderQuery =
kind === "file"
? documentsInFolderQuery.or("library_kind.eq.file,library_kind.is.null")
: documentsInFolderQuery.eq("library_kind", kind);
const { data: docs, error: docsError } = await documentsInFolderQuery.in(
"library_folder_id",
[...folderIds],
);
if (docsError) return void res.status(500).json({ detail: docsError.message });
const docIds = (docs ?? []).map((doc) => doc.id as string);
const deleteDocsError = await deleteLibraryDocumentsAndVersionFiles(
db,
userId,
kind,
docIds,
);
if (deleteDocsError)
return void res.status(500).json({ detail: deleteDocsError.message });
const { error } = await db
.from("library_folders")
.delete()
.eq("id", folderId)
.eq("user_id", userId)
.eq("library_kind", kind);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// PATCH /library/:kind/documents/:documentId/folder
libraryRouter.patch(
"/:kind/documents/:documentId/folder",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { documentId } = req.params;
const { folder_id } = req.body as { folder_id: string | null };
const db = createServerSupabase();
if (folder_id) {
const folder = await loadLibraryFolder(db, userId, kind, folder_id);
if (!folder)
return void res.status(404).json({ detail: "Folder not found" });
}
let moveQuery = db
.from("documents")
.update({
library_folder_id: folder_id ?? null,
updated_at: new Date().toISOString(),
})
.eq("id", documentId)
.eq("user_id", userId)
.is("project_id", null);
moveQuery =
kind === "file"
? moveQuery.or("library_kind.eq.file,library_kind.is.null")
: moveQuery.eq("library_kind", kind);
const { data, error } = await moveQuery
.select("*")
.single();
if (error || !data)
return void res.status(404).json({ detail: "Document not found" });
res.json(mapLibraryDocument(data));
},
);
// PATCH /library/:kind/documents/:documentId
libraryRouter.patch(
"/:kind/documents/:documentId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { documentId } = req.params;
const db = createServerSupabase();
let docQuery = db
.from("documents")
.select("id, current_version_id")
.eq("id", documentId)
.eq("user_id", userId)
.is("project_id", null);
docQuery =
kind === "file"
? docQuery.or("library_kind.eq.file,library_kind.is.null")
: docQuery.eq("library_kind", kind);
const { data: doc } = await docQuery.single();
if (!doc) return void res.status(404).json({ detail: "Document not found" });
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" });
let updateQuery = db
.from("documents")
.update({ updated_at: new Date().toISOString() })
.eq("id", documentId)
.eq("user_id", userId)
.is("project_id", null);
updateQuery =
kind === "file"
? updateQuery.or("library_kind.eq.file,library_kind.is.null")
: updateQuery.eq("library_kind", kind);
const { data: updated, error } = await updateQuery
.select("*")
.single();
if (error || !updated)
return void res.status(404).json({ detail: "Document not found" });
if (doc.current_version_id) {
await db
.from("document_versions")
.update({ filename })
.eq("id", doc.current_version_id)
.eq("document_id", documentId);
}
res.json(mapLibraryDocument({ ...updated, filename }));
},
);

View file

@ -453,7 +453,11 @@ projectsRouter.post(
// Standalone → assign project_id
const { data: updated, error } = await db
.from("documents")
.update({ project_id: projectId, updated_at: new Date().toISOString() })
.update({
project_id: projectId,
library_folder_id: null,
updated_at: new Date().toISOString(),
})
.eq("id", documentId)
.select("*")
.single();

View file

@ -997,6 +997,29 @@ tabularRouter.delete(
},
);
// PATCH /tabular-review/:reviewId/chats/:chatId — rename a chat
tabularRouter.patch(
"/:reviewId/chats/:chatId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const { chatId } = req.params;
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
if (!title)
return void res.status(400).json({ detail: "Title is required" });
const db = createServerSupabase();
// Owner-only rename — mirrors the delete rule above.
const { error } = await db
.from("tabular_review_chats")
.update({ title: title.slice(0, 200) })
.eq("id", chatId)
.eq("user_id", userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
},
);
// GET /tabular-review/:reviewId/chats/:chatId/messages — messages for a single chat
tabularRouter.get(
"/:reviewId/chats/:chatId/messages",