mirror of
https://github.com/willchen96/mike.git
synced 2026-06-26 21:39:39 +02:00
Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes
This commit is contained in:
parent
d39f5806e5
commit
44e868eb42
106 changed files with 16350 additions and 7753 deletions
84
backend/src/routes/caseLaw.ts
Normal file
84
backend/src/routes/caseLaw.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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 (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
|
||||
user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. 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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue