mike/backend/src/routes/documents.ts
2026-07-16 20:07:12 +08:00

1464 lines
48 KiB
TypeScript

import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import {
buildContentDisposition,
downloadFile,
deleteFile,
getSignedUrl,
storageKey,
uploadFile,
versionStorageKey,
} from "../lib/storage";
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import {
extractTrackedChangeIds,
resolveTrackedChange,
} from "../lib/docxTrackedChanges";
import { buildDownloadUrl } from "../lib/downloadTokens";
import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
loadActiveVersion,
} from "../lib/documentVersions";
import { ensureDocAccess } from "../lib/access";
import { singleFileUpload } from "../lib/upload";
import {
ALLOWED_DOCUMENT_TYPES,
ALLOWED_DOCUMENT_TYPES_LABEL,
contentTypeForDocumentType,
shouldConvertToPdf,
} from "../lib/documentTypes";
export const documentsRouter = Router();
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) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { data, error } = await db
.from("documents")
.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 {
id: string;
current_version_id?: string | null;
}[];
await attachLatestVersionNumbers(db, docs);
await attachActiveVersionPaths(db, docs);
res.json(docs);
});
// POST /single-documents
documentsRouter.post(
"/",
requireAuth,
singleFileUpload("file"),
async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
await handleDocumentUpload(req, res, userId, null, db, {
libraryKind: "file",
});
},
);
// DELETE /single-documents/:documentId
documentsRouter.delete("/:documentId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { documentId } = req.params;
const db = createServerSupabase();
const { data: doc, error } = await db
.from("documents")
.select("id")
.eq("id", documentId)
.eq("user_id", userId)
.single();
if (error || !doc)
return void res.status(404).json({ detail: "Document not found" });
await deleteDocumentAndVersionFiles(db, documentId);
res.status(204).send();
});
// GET /single-documents/:documentId/display
// Optional ?version_id= renders a historical version. Defaults to the
// document's current_version_id.
documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string;
const { documentId } = req.params;
const versionIdParam =
typeof req.query.version_id === "string" ? req.query.version_id : null;
const db = createServerSupabase();
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_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)
return void res.status(404).json({ detail: "Document not found" });
const active = await loadActiveVersion(documentId, db, versionIdParam);
if (!active)
return void res.status(404).json({ detail: "No file available" });
const fileType = active.file_type ?? "";
const isConvertibleOffice = shouldConvertToPdf(fileType);
const displayFilename = downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
);
// For Office files, prefer the per-version PDF rendition if one exists.
const servePath =
isConvertibleOffice && active.pdf_storage_path
? active.pdf_storage_path
: active.storage_path;
const raw = await downloadFile(servePath);
if (!raw)
return void res
.status(404)
.json({ detail: "Document not found in storage" });
if (fileType === "pdf" || (isConvertibleOffice && active.pdf_storage_path)) {
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
buildContentDisposition("inline", displayFilename),
);
res.send(Buffer.from(raw));
} else {
// Fallback: serve raw Office bytes when PDF conversion was unavailable.
res.setHeader("Content-Type", contentTypeForDocumentType(fileType));
res.setHeader(
"Content-Disposition",
buildContentDisposition("inline", displayFilename),
);
res.send(Buffer.from(raw));
}
});
// POST /single-documents/download-zip
documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { document_ids } = req.body as { document_ids?: string[] };
if (!Array.isArray(document_ids) || document_ids.length === 0)
return void res.status(400).json({ detail: "document_ids is required" });
const db = createServerSupabase();
const { data: rawDocs, error } = await db
.from("documents")
.select("id, current_version_id, user_id, project_id")
.in("id", document_ids);
if (error) return void res.status(500).json({ detail: error.message });
// Filter to docs the user actually has access to (own + shared-project).
const accessChecks = await Promise.all(
(rawDocs ?? []).map(async (d) => ({
doc: d,
access: await ensureDocAccess(
d as { user_id: string; project_id: string | null },
userId,
userEmail,
db,
),
})),
);
const docs = accessChecks
.filter((x) => x.access.ok)
.map((x) => x.doc as { id: string });
if (!docs || docs.length === 0)
return void res.status(404).json({ detail: "No documents found" });
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
await Promise.all(
docs.map(async (doc) => {
const active = await loadActiveVersion(doc.id, db);
if (!active) return;
const raw = await downloadFile(active.storage_path);
if (!raw) return;
zip.file(
downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
),
Buffer.from(raw),
);
}),
);
const content = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", 'attachment; filename="documents.zip"');
res.send(content);
});
// GET /single-documents/:documentId/url
// Optional ?version_id= selects a specific tracked-changes version.
// Otherwise falls back to documents.current_version_id, else the original upload.
documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const versionIdParam = typeof req.query.version_id === "string" ? req.query.version_id : null;
const db = createServerSupabase();
const { data: doc, error } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (error || !doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Document not found" });
const active = await loadActiveVersion(documentId, db, versionIdParam);
if (!active)
return void res.status(404).json({ detail: "No file available" });
const downloadFilename = downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
);
const url = await getSignedUrl(
active.storage_path,
3600,
downloadFilename,
);
if (!url)
return void res.status(503).json({ detail: "Storage not configured" });
res.json({
url,
document_id: documentId,
filename: downloadFilename,
version_id: active.id,
// Lets the frontend decide between DocView (PDF.js) and DocxView
// (docx-preview) without a follow-up round-trip.
has_pdf_rendition: !!active.pdf_storage_path,
});
});
// GET /single-documents/:documentId/docx
// Streams the raw .docx bytes for the given document, optionally at a
// specific tracked-changes version. Unlike /url, this bypasses R2 (avoids
// the browser CORS problem on signed URLs) so the frontend docx-preview
// viewer can load tracked-change documents directly.
documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const versionIdParam = typeof req.query.version_id === "string" ? req.query.version_id : null;
const db = createServerSupabase();
const { data: doc, error } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (error || !doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Document not found" });
const active = await loadActiveVersion(documentId, db, versionIdParam);
if (!active)
return void res.status(404).json({ detail: "No file available" });
const raw = await downloadFile(active.storage_path);
if (!raw)
return void res.status(404).json({ detail: "Document bytes not available" });
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
res.setHeader(
"Content-Disposition",
buildContentDisposition(
"inline",
downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
),
),
);
res.send(Buffer.from(raw));
});
// 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 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
// Returns every version row for the document in document order, with
// the human-friendly version number when present.
documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const db = createServerSupabase();
const { data: doc } = await db
.from("documents")
.select("id, current_version_id, user_id, project_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)
return void res.status(404).json({ detail: "Document not found" });
const { data: rows } = await db
.from("document_versions")
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count, deleted_at, deleted_by",
)
.eq("document_id", documentId)
.order("created_at", { ascending: true });
res.json({
current_version_id: doc.current_version_id,
versions: rows ?? [],
});
});
// 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 willDeleteSource =
(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.",
});
}
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 ?? "";
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 = contentTypeForDocumentType(suffix);
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 (shouldConvertToPdf(suffix)) {
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] Office→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 (willDeleteSource) {
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. filename defaults to the
// uploaded filename; client may override via the `filename` form field.
documentsRouter.post(
"/:documentId/versions",
requireAuth,
singleFileUpload("file"),
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const db = createServerSupabase();
const file = req.file;
if (!file)
return void res.status(400).json({ detail: "file is required" });
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)
return void res.status(404).json({ detail: "Document not found" });
const suffix = file.originalname.includes(".")
? file.originalname.split(".").pop()!.toLowerCase()
: "";
if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) {
return void res.status(400).json({
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
});
}
// Peg the new version into a predictable /versions/:id path under the
// existing document folder so ops can spot the history in storage.
const versionSlug = crypto.randomUUID().replace(/-/g, "");
const key = versionStorageKey(
userId,
documentId,
versionSlug,
file.originalname,
);
const contentType = contentTypeForDocumentType(suffix);
try {
await uploadFile(
key,
file.buffer.buffer.slice(
file.buffer.byteOffset,
file.buffer.byteOffset + file.buffer.byteLength,
) as ArrayBuffer,
contentType,
);
} catch (e) {
console.error("[versions/upload] storage write failed", e);
return void res
.status(500)
.json({ detail: "Failed to upload new version." });
}
// Render this version's bytes to PDF up front so /display can show
// historical versions without on-demand conversion. Same logic as the
// initial-upload pipeline; failures don't block the version row.
let pdfStoragePath: string | null = null;
if (shouldConvertToPdf(suffix)) {
try {
const pdfBuf = await docxToPdf(file.buffer);
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/upload] Office→PDF conversion failed for ${file.originalname}:`,
err,
);
}
} else if (suffix === "pdf") {
// For PDF uploads, the uploaded bytes are themselves the PDF rendition.
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
.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 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
.from("document_versions")
.insert({
document_id: documentId,
storage_path: key,
pdf_storage_path: pdfStoragePath,
source: "user_upload",
version_number: nextVersionNumber,
filename: requestedFilename,
file_type: suffix,
size_bytes: file.buffer.byteLength,
page_count: pageCount,
})
.select("id, version_number, source, created_at, filename")
.single();
if (verErr || !versionRow) {
console.error("[versions/upload] 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/upload] current version update failed",
updateDocErr,
);
return void res
.status(500)
.json({ detail: "Failed to update document current version." });
}
res.status(201).json(versionRow);
},
);
// PATCH /single-documents/:documentId/versions/:versionId
// Rename a version's filename. Pass `{ "filename": "…" }`.
documentsRouter.patch(
"/: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")
.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)
return void res.status(404).json({ detail: "Document not found" });
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({ filename })
.eq("id", versionId)
.eq("document_id", documentId)
.is("deleted_at", null)
.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" });
}
res.json(updated);
},
);
// PUT /single-documents/:documentId/versions/:versionId/file
// Replace the file bytes and metadata for an existing version while keeping
// its version number and id. This is destructive and owner-only.
documentsRouter.put(
"/:documentId/versions/:versionId/file",
requireAuth,
singleFileUpload("file"),
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 file = req.file;
if (!file)
return void res.status(400).json({ detail: "file is required" });
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_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: target, error: targetErr } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, file_type, deleted_at")
.eq("id", versionId)
.eq("document_id", documentId)
.single();
if (targetErr || !target)
return void res.status(404).json({ detail: "Version not found" });
if (target.deleted_at)
return void res.status(400).json({ detail: "Version is deleted." });
const suffix = file.originalname.includes(".")
? file.originalname.split(".").pop()!.toLowerCase()
: "";
if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) {
return void res.status(400).json({
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
});
}
if (target.file_type && target.file_type !== suffix) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match version type (${target.file_type}).`,
});
}
const versionSlug = crypto.randomUUID().replace(/-/g, "");
const key = versionStorageKey(
userId,
documentId,
versionSlug,
file.originalname,
);
const contentType = contentTypeForDocumentType(suffix);
try {
await uploadFile(
key,
file.buffer.buffer.slice(
file.buffer.byteOffset,
file.buffer.byteOffset + file.buffer.byteLength,
) as ArrayBuffer,
contentType,
);
} catch (e) {
console.error("[versions/replace] storage write failed", e);
return void res
.status(500)
.json({ detail: "Failed to upload replacement version." });
}
let pdfStoragePath: string | null = null;
if (shouldConvertToPdf(suffix)) {
try {
const pdfBuf = await docxToPdf(file.buffer);
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/replace] Office→PDF conversion failed for ${file.originalname}:`,
err,
);
}
} else if (suffix === "pdf") {
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;
const requestedFilename =
typeof req.body?.filename === "string" && req.body.filename.trim()
? req.body.filename.trim().slice(0, 200)
: file.originalname;
const uploadedAt = new Date().toISOString();
const { data: updated, error: updateErr } = await db
.from("document_versions")
.update({
storage_path: key,
pdf_storage_path: pdfStoragePath,
filename: requestedFilename,
file_type: suffix,
size_bytes: file.buffer.byteLength,
page_count: pageCount,
created_at: uploadedAt,
})
.eq("id", versionId)
.eq("document_id", documentId)
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
.single();
if (updateErr || !updated) {
await Promise.all(
[key, pdfStoragePath]
.filter((path): path is string => !!path)
.map((path) => deleteFile(path).catch(() => {})),
);
return void res.status(500).json({
detail: updateErr?.message ?? "Failed to replace version.",
});
}
await Promise.all(
[target.storage_path, target.pdf_storage_path]
.filter((path): path is string => !!path)
.map((path) => deleteFile(path).catch(() => {})),
);
res.json(updated);
},
);
// 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, deleted_at",
)
.eq("document_id", documentId)
.is("deleted_at", null);
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;
deleted_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;
const deletedAt = new Date().toISOString();
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")
.update({
storage_path: null,
pdf_storage_path: null,
deleted_at: deletedAt,
deleted_by: userId,
})
.eq("id", versionId)
.eq("document_id", documentId)
.is("deleted_at", null);
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,
deleted_at: deletedAt,
});
},
);
// 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
// this to tag each rendered <ins>/<del> with data-w-id, since
// docx-preview drops the w:id attribute during parsing.
documentsRouter.get(
"/:documentId/tracked-change-ids",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const versionIdParam =
typeof req.query.version_id === "string" ? req.query.version_id : null;
const db = createServerSupabase();
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_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)
return void res.status(404).json({ detail: "Document not found" });
const active = await loadActiveVersion(documentId, db, versionIdParam);
if (!active)
return void res.status(404).json({ detail: "No file available" });
const raw = await downloadFile(active.storage_path);
if (!raw)
return void res
.status(404)
.json({ detail: "Document bytes not available" });
const ids = await extractTrackedChangeIds(Buffer.from(raw));
res.json({ ids });
},
);
// POST /single-documents/:documentId/edits/:editId/accept
// POST /single-documents/:documentId/edits/:editId/reject
async function handleEditResolution(
req: import("express").Request,
res: import("express").Response,
mode: "accept" | "reject",
) {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId, editId } = req.params;
const db = createServerSupabase();
devLog(`[edit-resolution] incoming ${mode}`, {
userId,
documentId,
editId,
});
const { data: edit, error: editErr } = await db
.from("document_edits")
.select("id, document_id, change_id, del_w_id, ins_w_id, status")
.eq("id", editId)
.eq("document_id", documentId)
.single();
devLog(`[edit-resolution] fetched edit row`, { edit, editErr });
if (!edit) {
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") {
devLog(`[edit-resolution] edit already resolved`, {
editId,
status: edit.status,
});
const { data: doc } = await db
.from("documents")
.select("current_version_id, user_id, project_id")
.eq("id", documentId)
.single();
if (!doc) {
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) {
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);
const payload = {
ok: true,
already_resolved: true,
status: edit.status,
version_id: doc.current_version_id ?? null,
download_url: activeForResolved
? buildDownloadUrl(
activeForResolved.storage_path,
downloadFilenameForVersion(
activeForResolved.filename,
activeForResolved.version_number,
activeForResolved.source === "assistant_edit",
),
)
: null,
remaining_pending: 0,
};
devLog(`[edit-resolution] returning already-resolved payload`, payload);
return void res.status(200).json(payload);
}
const { data: doc, error: docErr } = await db
.from("documents")
.select("id, current_version_id, user_id, project_id")
.eq("id", documentId)
.single();
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);
if (!access.ok)
return void res.status(404).json({ detail: "Document not found" });
const active = await loadActiveVersion(documentId, db);
const latestPath = active?.storage_path ?? null;
devLog(`[edit-resolution] resolved latestPath`, {
latestPath,
current_version_id: doc.current_version_id,
});
if (!latestPath)
return void res.status(404).json({ detail: "No file to edit" });
const raw = await downloadFile(latestPath);
devLog(`[edit-resolution] downloaded bytes`, {
byteLength: raw?.byteLength ?? 0,
});
if (!raw)
return void res.status(404).json({ detail: "Document bytes not available" });
const wIds = [edit.del_w_id, edit.ins_w_id].filter(
(v): v is string => typeof v === "string" && v.length > 0,
);
const { bytes: resolvedBytes, found } = await resolveTrackedChange(
Buffer.from(raw),
wIds,
mode,
);
devLog(`[edit-resolution] resolveTrackedChange result`, {
mode,
change_id: edit.change_id,
wIds,
found,
resolvedByteLength: resolvedBytes?.byteLength ?? 0,
});
if (!found) {
devLog(
`[edit-resolution] change_id not found in docx — updating status only`,
);
// Still update DB status so the UI reflects the decision — the change
// may have been auto-consumed by a previous accept/reject pass.
const { error: updErr } = await db
.from("document_edits")
.update({ status: mode === "accept" ? "accepted" : "rejected", resolved_at: new Date().toISOString() })
.eq("id", editId);
devLog(`[edit-resolution] status-only update`, { updErr });
const payload = {
ok: true,
version_id: doc.current_version_id,
download_url: buildDownloadUrl(
latestPath,
downloadFilenameForVersion(
active?.filename,
active?.version_number ?? null,
active?.source === "assistant_edit",
),
),
remaining_pending: 0,
};
devLog(`[edit-resolution] returning not-found payload`, payload);
return void res.status(200).json(payload);
}
// Overwrite bytes in place at the current version's storage path —
// accept/reject mutates the existing version rather than spawning a
// new row. This keeps document_versions lean (one row per assistant
// edit, not one per accept/reject click) and avoids the N-versions-
// per-doc churn as users resolve pending changes.
const ab = resolvedBytes.buffer.slice(
resolvedBytes.byteOffset,
resolvedBytes.byteOffset + resolvedBytes.byteLength,
) as ArrayBuffer;
devLog(`[edit-resolution] overwriting bytes in place`, {
latestPath,
byteLength: ab.byteLength,
});
await uploadFile(
latestPath,
ab,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
const { error: statusErr } = await db
.from("document_edits")
.update({
status: mode === "accept" ? "accepted" : "rejected",
resolved_at: new Date().toISOString(),
})
.eq("id", editId);
devLog(`[edit-resolution] updated document_edits status`, {
editId,
newStatus: mode === "accept" ? "accepted" : "rejected",
statusErr,
});
const { count: remainingPending } = await db
.from("document_edits")
.select("id", { count: "exact", head: true })
.eq("document_id", documentId)
.eq("status", "pending");
devLog(`[edit-resolution] remaining pending count`, { remainingPending });
const payload = {
ok: true,
version_id: doc.current_version_id,
download_url: buildDownloadUrl(
latestPath,
downloadFilenameForVersion(
active?.filename,
active?.version_number ?? null,
active?.source === "assistant_edit",
),
),
remaining_pending: remainingPending ?? 0,
};
devLog(`[edit-resolution] returning success payload`, payload);
res.json(payload);
}
documentsRouter.post(
"/:documentId/edits/:editId/accept",
requireAuth,
(req, res) => void handleEditResolution(req, res, "accept"),
);
documentsRouter.post(
"/:documentId/edits/:editId/reject",
requireAuth,
(req, res) => void handleEditResolution(req, res, "reject"),
);
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" });
const filename = file.originalname;
const suffix = filename.includes(".")
? filename.split(".").pop()!.toLowerCase()
: "";
if (!ALLOWED_DOCUMENT_TYPES.has(suffix))
return void res
.status(400)
.json({
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
});
const content = file.buffer;
const { data: doc, error: insertErr } = await db
.from("documents")
.insert({
project_id: projectId,
user_id: userId,
status: "processing",
library_kind: options.libraryKind ?? "file",
library_folder_id: options.libraryFolderId ?? null,
})
.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)
.json({ detail: "Failed to create document record" });
try {
const docId = doc.id as string;
const key = storageKey(userId, docId, filename);
const contentType = contentTypeForDocumentType(suffix);
await uploadFile(
key,
content.buffer.slice(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer,
contentType,
);
const rawBuf = content.buffer.slice(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer;
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Convert Office files → PDF for display. PDFs are their own rendition.
let pdfStoragePath: string | null = null;
if (shouldConvertToPdf(suffix)) {
try {
const pdfBuf = await docxToPdf(content);
const pdfKey = convertedPdfKey(userId, docId);
await uploadFile(
pdfKey,
pdfBuf.buffer.slice(
pdfBuf.byteOffset,
pdfBuf.byteOffset + pdfBuf.byteLength,
) as ArrayBuffer,
"application/pdf",
);
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[upload] Office→PDF conversion failed for ${filename}:`,
err,
);
}
} else if (suffix === "pdf") {
pdfStoragePath = key;
}
// storage_path / pdf_storage_path live on document_versions now —
// create the V1 "upload" row and point documents.current_version_id
// at it.
const { data: versionRow, error: verErr } = await db
.from("document_versions")
.insert({
document_id: docId,
storage_path: key,
pdf_storage_path: pdfStoragePath,
source: "upload",
version_number: 1,
filename: filename,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
})
.select("id")
.single();
if (verErr || !versionRow) {
throw new Error(
`Failed to record upload version: ${verErr?.message ?? "unknown"}`,
);
}
await db
.from("documents")
.update({
current_version_id: versionRow.id,
status: "ready",
updated_at: new Date().toISOString(),
})
.eq("id", docId);
const { data: updated } = await db
.from("documents")
.select("*")
.eq("id", docId)
.single();
// Surface storage paths to the caller for backward compatibility.
const responseDoc = updated
? {
...updated,
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,
active_version_number: 1,
}
: updated;
return void res.status(201).json(responseDoc);
} catch (e) {
await db.from("documents").update({ status: "error" }).eq("id", doc.id);
return void res
.status(500)
.json({ detail: `Document processing failed: ${String(e)}` });
}
}
async function countPdfPages(buf: ArrayBuffer): Promise<number | null> {
try {
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 }>;
};
}
).getDocument({ data: new Uint8Array(buf) }).promise;
return pdf.numPages;
} catch {
return null;
}
}