Add local repo contents

This commit is contained in:
willchen96 2026-04-29 19:49:06 +02:00
parent 65739ef1ce
commit d9690965b5
176 changed files with 68998 additions and 0 deletions

487
backend/src/routes/chat.ts Normal file
View file

@ -0,0 +1,487 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import {
buildDocContext,
buildMessages,
enrichWithPriorEvents,
buildWorkflowStore,
extractAnnotations,
runLLMStream,
type ChatMessage,
} from "../lib/chatTools";
import { completeText } from "../lib/llm";
import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
export const chatRouter = Router();
// GET /chat
// Visible chats = the user's own chats + every chat under a project the
// user owns (so a project owner sees all collaborator chats in their
// own projects in the global recent-chats list). Chats in projects that
// are merely *shared with* the user are NOT included here — those are
// listed per-project via GET /projects/:projectId/chats.
chatRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { data: ownProjects, error: projErr } = await db
.from("projects")
.select("id")
.eq("user_id", userId);
if (projErr) return void res.status(500).json({ detail: projErr.message });
const ownProjectIds = ((ownProjects ?? []) as { id: string }[]).map(
(p) => p.id,
);
const filter =
ownProjectIds.length > 0
? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})`
: `user_id.eq.${userId}`;
const { data, error } = await db
.from("chats")
.select("*")
.or(filter)
.order("created_at", { ascending: false });
if (error) return void res.status(500).json({ detail: error.message });
res.json(data ?? []);
});
// POST /chat/create
chatRouter.post("/create", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const projectId: string | null = req.body.project_id ?? null;
const db = createServerSupabase();
const { data, error } = await db
.from("chats")
.insert({ user_id: userId, project_id: projectId ?? undefined })
.select("id")
.single();
if (error) return void res.status(500).json({ detail: error.message });
res.json({ id: data.id });
});
// GET /chat/:chatId
chatRouter.get("/:chatId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { chatId } = req.params;
const db = createServerSupabase();
const { data: chat, error } = await db
.from("chats")
.select("*")
.eq("id", chatId)
.single();
if (error || !chat)
return void res.status(404).json({ detail: "Chat not found" });
// Owner of the chat OR a member of the chat's project can view it.
let canView = chat.user_id === userId;
if (!canView && chat.project_id) {
const access = await checkProjectAccess(
chat.project_id,
userId,
userEmail,
db,
);
canView = access.ok;
}
if (!canView)
return void res.status(404).json({ detail: "Chat not found" });
const { data: messages } = await db
.from("chat_messages")
.select("*")
.eq("chat_id", chatId)
.order("created_at", { ascending: true });
const hydrated = await hydrateEditStatuses(messages ?? [], db);
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.
async function hydrateEditStatuses(
messages: Record<string, unknown>[],
db: ReturnType<typeof createServerSupabase>,
): Promise<Record<string, unknown>[]> {
const editIds = new Set<string>();
const versionIds = new Set<string>();
const collectFromAnnList = (list: unknown) => {
if (!Array.isArray(list)) return;
for (const a of list as Record<string, unknown>[]) {
if (typeof a?.edit_id === "string") editIds.add(a.edit_id);
if (typeof a?.version_id === "string")
versionIds.add(a.version_id);
}
};
for (const m of messages) {
collectFromAnnList(m.annotations);
const content = m.content;
if (Array.isArray(content)) {
for (const ev of content as Record<string, unknown>[]) {
if (ev?.type === "doc_edited") {
collectFromAnnList(ev.annotations);
if (typeof ev.version_id === "string")
versionIds.add(ev.version_id);
}
}
}
}
if (editIds.size === 0 && versionIds.size === 0) return messages;
// Edit status patch.
const statusById = new Map<string, "pending" | "accepted" | "rejected">();
if (editIds.size > 0) {
const { data: rows } = await db
.from("document_edits")
.select("id, status")
.in("id", Array.from(editIds));
for (const r of (rows ?? []) as { id: string; status: string }[]) {
if (
r.status === "pending" ||
r.status === "accepted" ||
r.status === "rejected"
) {
statusById.set(r.id, r.status);
}
}
}
// Version-number patch — old stored events don't carry `version_number`
// because they predate the schema change. Look it up from
// document_versions so the UI can render "V3" chips + download filenames.
const versionNumberById = new Map<string, number | null>();
if (versionIds.size > 0) {
const { data: vrows } = await db
.from("document_versions")
.select("id, version_number")
.in("id", Array.from(versionIds));
for (const r of (vrows ?? []) as {
id: string;
version_number: number | null;
}[]) {
versionNumberById.set(r.id, r.version_number ?? null);
}
}
const patchAnnList = (list: unknown): unknown => {
if (!Array.isArray(list)) return list;
return (list as Record<string, unknown>[]).map((a) => {
let next = a;
if (typeof a?.edit_id === "string" && statusById.has(a.edit_id)) {
next = { ...next, status: statusById.get(a.edit_id) };
}
if (
typeof a?.version_id === "string" &&
versionNumberById.has(a.version_id)
) {
next = {
...next,
version_number: versionNumberById.get(a.version_id) ?? null,
};
}
return next;
});
};
return messages.map((m) => {
const next: Record<string, unknown> = { ...m };
next.annotations = patchAnnList(m.annotations);
if (Array.isArray(m.content)) {
next.content = (m.content as Record<string, unknown>[]).map(
(ev) => {
if (ev?.type !== "doc_edited") return ev;
let patched: Record<string, unknown> = {
...ev,
annotations: patchAnnList(ev.annotations),
};
if (
typeof ev.version_id === "string" &&
versionNumberById.has(ev.version_id)
) {
patched = {
...patched,
version_number:
versionNumberById.get(ev.version_id) ?? null,
};
}
return patched;
},
);
}
return next;
});
}
// PATCH /chat/:chatId
chatRouter.patch("/:chatId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { chatId } = req.params;
const title = (req.body.title ?? "").trim();
if (!title)
return void res.status(400).json({ detail: "title is required" });
const db = createServerSupabase();
const { data, error } = await db
.from("chats")
.update({ title })
.eq("id", chatId)
.eq("user_id", userId)
.select("id, title")
.single();
if (error || !data)
return void res.status(404).json({ detail: "Chat not found" });
res.json(data);
});
// DELETE /chat/:chatId
chatRouter.delete("/:chatId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { chatId } = req.params;
const db = createServerSupabase();
const { error } = await db
.from("chats")
.delete()
.eq("id", chatId)
.eq("user_id", userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// POST /chat/:chatId/generate-title
chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { chatId } = req.params;
const message: string = (req.body.message ?? "").trim();
if (!message)
return void res.status(400).json({ detail: "message is required" });
const db = createServerSupabase();
const { data: chat, error } = await db
.from("chats")
.select("id, user_id, project_id")
.eq("id", chatId)
.single();
if (error || !chat)
return void res.status(404).json({ detail: "Chat not found" });
let canTitle = chat.user_id === userId;
if (!canTitle && chat.project_id) {
const access = await checkProjectAccess(
chat.project_id,
userId,
userEmail,
db,
);
canTitle = access.ok;
}
if (!canTitle)
return void res.status(404).json({ detail: "Chat not found" });
try {
const { title_model, api_keys } = await getUserModelSettings(
userId,
db,
);
const titleText = await completeText({
model: title_model,
user: `Generate a concise title (36 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
maxTokens: 64,
apiKeys: api_keys,
});
const title = titleText.trim() || message.slice(0, 60);
await db
.from("chats")
.update({ title })
.eq("id", chatId)
.eq("user_id", userId);
res.json({ title });
} catch (err) {
console.error("[generate-title]", err);
res.status(500).json({ detail: "Failed to generate title" });
}
});
// POST /chat — streaming
chatRouter.post("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { messages, chat_id, project_id, model } = req.body as {
messages: ChatMessage[];
chat_id?: string;
project_id?: string;
model?: string;
};
console.log("[chat/stream] incoming request", {
userId,
chat_id,
project_id,
model,
messageCount: messages?.length,
});
const userEmail = res.locals.userEmail as string | undefined;
const db = createServerSupabase();
let chatId = chat_id ?? null;
let chatTitle: string | null = null;
if (chatId) {
// Either chat owner OR a member of the chat's project can post.
const { data: existing } = await db
.from("chats")
.select("id, title, user_id, project_id")
.eq("id", chatId)
.single();
let canUse = !!existing && existing.user_id === userId;
if (!canUse && existing?.project_id) {
const access = await checkProjectAccess(
existing.project_id,
userId,
userEmail,
db,
);
canUse = access.ok;
}
if (!canUse || !existing) chatId = null;
else chatTitle = existing.title;
}
if (!chatId) {
// If creating a chat tied to a project, the user must have access
// to the project (own or shared).
if (project_id) {
const access = await checkProjectAccess(
project_id,
userId,
userEmail,
db,
);
if (!access.ok)
return void res
.status(404)
.json({ detail: "Project not found" });
}
const { data: newChat, error } = await db
.from("chats")
.insert({ user_id: userId, project_id: project_id ?? null })
.select("id, title")
.single();
if (error || !newChat) {
console.error("[chat/stream] failed to create chat", error);
return void res
.status(500)
.json({ detail: "Failed to create chat" });
}
chatId = newChat.id as string;
chatTitle = newChat.title;
}
console.log("[chat/stream] resolved chatId", chatId);
const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (lastUser) {
await db.from("chat_messages").insert({
chat_id: chatId,
role: "user",
content: lastUser.content,
files: lastUser.files ?? null,
workflow: lastUser.workflow ?? null,
});
}
const { docIndex, docStore } = await buildDocContext(
messages,
userId,
db,
chatId,
);
const docAvailability = Object.entries(docIndex).map(([doc_id, info]) => ({
doc_id,
filename: info.filename,
}));
const enrichedMessages = await enrichWithPriorEvents(
messages,
chatId,
db,
docIndex,
);
const apiMessages = buildMessages(enrichedMessages, docAvailability);
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
console.log("[chat/stream] starting LLM stream", {
apiMessageCount: apiMessages.length,
docCount: Object.keys(docIndex).length,
workflowCount: Object.keys(workflowStore).length,
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
const write = (line: string) => res.write(line);
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
const { fullText, events } = await runLLMStream({
apiMessages,
docStore,
docIndex,
userId,
db,
write,
workflowStore,
model,
apiKeys,
projectId: project_id ?? null,
});
console.log("[chat/stream] LLM stream finished", {
fullTextLen: fullText?.length ?? 0,
eventCount: events?.length ?? 0,
});
const annotations = extractAnnotations(fullText, docIndex, events);
await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
annotations: annotations.length ? annotations : null,
});
if (!chatTitle && lastUser?.content) {
await db
.from("chats")
.update({ title: lastUser.content.slice(0, 120) })
.eq("id", chatId);
}
} catch (err) {
console.error("[chat/stream] error:", err);
try {
write(
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
res.end();
}
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { buildContentDisposition, downloadFile } from "../lib/storage";
import { verifyDownload } from "../lib/downloadTokens";
import { ensureDocAccess } from "../lib/access";
export const downloadsRouter = Router();
function contentTypeFor(filename: string): string {
const lower = filename.toLowerCase();
if (lower.endsWith(".docx"))
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".xlsx"))
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
return "application/octet-stream";
}
// GET /download/:token
downloadsRouter.get("/:token", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const info = verifyDownload(req.params.token);
if (!info)
return void res.status(404).json({ detail: "Invalid link" });
const db = createServerSupabase();
let version:
| {
id: string;
document_id: string;
}
| null = null;
const { data: byStoragePath } = await db
.from("document_versions")
.select("id, document_id")
.eq("storage_path", info.path)
.maybeSingle();
if (byStoragePath) {
version = byStoragePath as { id: string; document_id: string };
}
if (!version)
return void res.status(404).json({ detail: "File not found" });
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", version.document_id)
.single();
if (!doc)
return void res.status(404).json({ detail: "File not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "File not found" });
const raw = await downloadFile(info.path);
if (!raw)
return void res.status(404).json({ detail: "File not found" });
res.setHeader("Content-Type", contentTypeFor(info.filename));
res.setHeader(
"Content-Disposition",
buildContentDisposition("attachment", info.filename),
);
res.send(Buffer.from(raw));
});

View file

@ -0,0 +1,201 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import {
buildProjectDocContext,
buildMessages,
buildWorkflowStore,
enrichWithPriorEvents,
extractAnnotations,
runLLMStream,
PROJECT_EXTRA_TOOLS,
type ChatMessage,
} from "../lib/chatTools";
import { getUserApiKeys } from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT:
You are operating within a project folder that contains a collection of legal documents the user has organised for a single matter. The user's questions will usually refer to one or more documents in this project your job is to find the relevant files to work on. Use list_documents to see what is available and fetch_documents / read_document to pull in any documents you need before answering.
A document may currently be displayed in the user's side panel; when provided, treat it as context for the user's likely focus, but do NOT assume it is the only or definitive document the user is asking about. If the request could apply to other files in the project, identify and read those as well. Prefer coverage across the relevant project documents over an over-narrow reading of only the displayed one.
REPLICATING A DOCUMENT:
When the user wants to use an existing project document as a starting point for a new file (e.g. "use this NDA as a template", "make me a copy of the SOW so I can edit it", "duplicate this and adapt it for company X"), call the replicate_document tool with the source doc_id. This creates a byte-for-byte copy as a new project document, returns a fresh doc_id slug, and shows a download/open card in the UI. Then call edit_document on the returned slug to make the user's requested changes — do NOT call generate_docx for cases where the user clearly wants the existing document's structure and formatting preserved.`;
export const projectChatRouter = Router({ mergeParams: true });
// POST /projects/:projectId/chat — streaming
projectChatRouter.post("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId } = req.params;
const { messages, chat_id, model, displayed_doc, attached_documents } =
req.body as {
messages: ChatMessage[];
chat_id?: string;
model?: string;
displayed_doc?: { filename: string; document_id: string };
attached_documents?: { filename: string; document_id: string }[];
};
const db = createServerSupabase();
// Verify the user has access to the project (owner or shared member).
const projectAccess = await checkProjectAccess(
projectId,
userId,
userEmail,
db,
);
if (!projectAccess.ok)
return void res.status(404).json({ detail: "Project not found" });
let chatId = chat_id ?? null;
let chatTitle: string | null = null;
if (chatId) {
const { data: existing } = await db
.from("chats")
.select("id, title, project_id")
.eq("id", chatId)
.single();
const canUse = !!existing && existing.project_id === projectId;
if (!canUse) chatId = null;
else chatTitle = existing!.title;
}
if (!chatId) {
const { data: newChat, error } = await db
.from("chats")
.insert({ user_id: userId, project_id: projectId })
.select("id, title")
.single();
if (error || !newChat)
return void res
.status(500)
.json({ detail: "Failed to create chat" });
chatId = newChat.id as string;
chatTitle = newChat.title;
}
const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (lastUser) {
await db.from("chat_messages").insert({
chat_id: chatId,
role: "user",
content: lastUser.content,
files: lastUser.files ?? null,
workflow: lastUser.workflow ?? null,
});
}
const { docIndex, docStore, folderPaths } = await buildProjectDocContext(
projectId,
userId,
db,
);
const docAvailability = Object.entries(docIndex).map(([doc_id, info]) => ({
doc_id,
filename: info.filename,
folder_path: folderPaths.get(doc_id),
}));
const enrichedMessages = await enrichWithPriorEvents(
messages,
chatId,
db,
docIndex,
);
const messagesForLLM: ChatMessage[] = displayed_doc
? enrichedMessages.map((m, i) => {
if (i !== enrichedMessages.length - 1 || m.role !== "user")
return m;
return {
...m,
content: `${m.content}\n\ndisplayed_doc: ${displayed_doc.filename}, displayed_doc_id: ${displayed_doc.document_id}`,
};
})
: enrichedMessages;
// The user-attached docs for this turn (dragged into / picked from
// the chat input) come in as a request-level field. Surface them in
// the system prompt with the current-turn doc_id slugs so the model
// knows which docs the user is highlighting *now*, distinct from
// the broader project doc list.
let systemPromptExtra = PROJECT_SYSTEM_PROMPT_EXTRA;
if (attached_documents?.length) {
const slugByDocumentId = new Map<string, string>();
for (const [slug, info] of Object.entries(docIndex)) {
if (info.document_id)
slugByDocumentId.set(info.document_id, slug);
}
const lines = attached_documents.map((d) => {
const slug = slugByDocumentId.get(d.document_id);
return slug ? `- ${slug}: ${d.filename}` : `- ${d.filename}`;
});
systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`;
}
const apiMessages = buildMessages(
messagesForLLM,
docAvailability,
systemPromptExtra,
);
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
const write = (line: string) => res.write(line);
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
const { fullText, events } = await runLLMStream({
apiMessages,
docStore,
docIndex,
userId,
db,
write,
extraTools: PROJECT_EXTRA_TOOLS,
workflowStore,
model,
apiKeys,
projectId,
});
const annotations = extractAnnotations(fullText, docIndex, events);
await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
annotations: annotations.length ? annotations : null,
});
if (!chatTitle && lastUser?.content) {
await db
.from("chats")
.update({ title: lastUser.content.slice(0, 120) })
.eq("id", chatId);
}
} catch (err) {
console.error("[project-chat/stream] error:", err);
try {
write(
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
res.end();
}
});

View file

@ -0,0 +1,801 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { createClient } from "@supabase/supabase-js";
import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../lib/documentVersions";
import { downloadFile, uploadFile, storageKey } from "../lib/storage";
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import { checkProjectAccess } from "../lib/access";
import { singleFileUpload } from "../lib/upload";
export const projectsRouter = Router();
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
// GET /projects
projectsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string;
const db = createServerSupabase();
const { data: ownProjects, error: ownError } = await db
.from("projects")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (ownError) return void res.status(500).json({ detail: ownError.message });
const { data: sharedProjects, error: sharedError } = userEmail
? await db
.from("projects")
.select("*")
.contains("shared_with", [userEmail])
.neq("user_id", userId)
.order("created_at", { ascending: false })
: { data: [], error: null };
if (sharedError)
return void res.status(500).json({ detail: sharedError.message });
const projects = [...(ownProjects ?? []), ...(sharedProjects ?? [])].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
const result = await Promise.all(
projects.map(async (p) => {
const [docs, chats, reviews] = await Promise.all([
db
.from("documents")
.select("id", { count: "exact", head: true })
.eq("project_id", p.id),
db
.from("chats")
.select("id", { count: "exact", head: true })
.eq("project_id", p.id),
db
.from("tabular_reviews")
.select("id", { count: "exact", head: true })
.eq("project_id", p.id),
]);
return {
...p,
is_owner: p.user_id === userId,
document_count: docs.count ?? 0,
chat_count: chats.count ?? 0,
review_count: reviews.count ?? 0,
};
}),
);
res.json(result);
});
// POST /projects
projectsRouter.post("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { name, cm_number, shared_with } = req.body as {
name: string;
cm_number?: string;
shared_with?: string[];
};
if (!name?.trim())
return void res.status(400).json({ detail: "name is required" });
const db = createServerSupabase();
const { data, error } = await db
.from("projects")
.insert({
user_id: userId,
name: name.trim(),
cm_number: cm_number ?? null,
shared_with: shared_with ?? [],
})
.select("*")
.single();
if (error) return void res.status(500).json({ detail: error.message });
res.status(201).json({ ...data, documents: [] });
});
// GET /projects/:projectId
projectsRouter.get("/:projectId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string;
const { projectId } = req.params;
const db = createServerSupabase();
const { data: project, error } = await db
.from("projects")
.select("*")
.eq("id", projectId)
.single();
if (error || !project)
return void res.status(404).json({ detail: "Project not found" });
const canAccess =
project.user_id === userId ||
(userEmail &&
Array.isArray(project.shared_with) &&
project.shared_with.includes(userEmail));
if (!canAccess)
return void res.status(404).json({ detail: "Project not found" });
const [{ data: docs }, { data: folderData }] = await Promise.all([
db.from("documents").select("*").eq("project_id", projectId).order("created_at", { ascending: true }),
db.from("project_subfolders").select("*").eq("project_id", projectId).order("created_at", { ascending: true }),
]);
const docsTyped = (docs ?? []) as unknown as {
id: string;
current_version_id?: string | null;
}[];
await attachLatestVersionNumbers(db, docsTyped);
await attachActiveVersionPaths(db, docsTyped);
res.json({
...project,
is_owner: project.user_id === userId,
documents: docsTyped,
folders: folderData ?? [],
});
});
// GET /projects/:projectId/people
// Resolve the owner + every shared member to {email, display_name}. Used
// by the People modal so the UI can show display names where available
// and tag the current user as "You".
projectsRouter.get("/:projectId/people", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId } = req.params;
const db = createServerSupabase();
const { data: project } = await db
.from("projects")
.select("id, user_id, shared_with")
.eq("id", projectId)
.single();
if (!project)
return void res.status(404).json({ detail: "Project not found" });
const isOwner = project.user_id === userId;
const sharedWith = (Array.isArray(project.shared_with)
? (project.shared_with as string[])
: []
).map((e) => e.toLowerCase());
const isShared =
!!userEmail && sharedWith.includes(userEmail.toLowerCase());
if (!isOwner && !isShared)
return void res.status(404).json({ detail: "Project not found" });
// Pull every auth user (matching the lookup endpoint's pattern). For
// larger deployments this should page or be replaced with a bulk-by-id
// RPC, but it keeps things simple while user counts are modest.
const { data: usersData } = await db.auth.admin.listUsers({ perPage: 1000 });
const allUsers = usersData?.users ?? [];
const userByEmail = new Map<string, { id: string; email: string }>();
const userById = new Map<string, { id: string; email: string }>();
for (const u of allUsers) {
if (!u.email) continue;
const lower = u.email.toLowerCase();
userByEmail.set(lower, { id: u.id, email: u.email });
userById.set(u.id, { id: u.id, email: u.email });
}
const memberUserIds: string[] = [];
for (const email of sharedWith) {
const u = userByEmail.get(email);
if (u) memberUserIds.push(u.id);
}
const profileIds = [
project.user_id as string,
...memberUserIds,
].filter((x, i, arr) => arr.indexOf(x) === i);
const profileByUserId = new Map<
string,
{ display_name: string | null; organisation: string | null }
>();
if (profileIds.length > 0) {
const { data: profiles } = await db
.from("user_profiles")
.select("user_id, display_name, organisation")
.in("user_id", profileIds);
for (const p of profiles ?? []) {
profileByUserId.set(p.user_id as string, {
display_name: (p.display_name as string | null) ?? null,
organisation: (p.organisation as string | null) ?? null,
});
}
}
const ownerInfo = userById.get(project.user_id as string);
const owner = {
user_id: project.user_id,
email: ownerInfo?.email ?? null,
display_name:
profileByUserId.get(project.user_id as string)?.display_name ?? null,
};
const members = sharedWith.map((email) => {
const u = userByEmail.get(email);
const display_name = u
? profileByUserId.get(u.id)?.display_name ?? null
: null;
return { email, display_name };
});
res.json({ owner, members });
});
// PATCH /projects/:projectId
projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { projectId } = req.params;
const updates: Record<string, unknown> = {};
if (req.body.name != null) updates.name = req.body.name;
if (req.body.cm_number != null) updates.cm_number = req.body.cm_number;
if (Array.isArray(req.body.shared_with)) {
// Normalise: lowercase + dedupe + drop empties.
const seen = new Set<string>();
const cleaned: string[] = [];
for (const raw of req.body.shared_with) {
if (typeof raw !== "string") continue;
const e = raw.trim().toLowerCase();
if (!e || seen.has(e)) continue;
seen.add(e);
cleaned.push(e);
}
updates.shared_with = cleaned;
}
const db = createServerSupabase();
const { data, error } = await db
.from("projects")
.update({ ...updates, updated_at: new Date().toISOString() })
.eq("id", projectId)
.eq("user_id", userId)
.select("*")
.single();
if (error || !data)
return void res.status(404).json({ detail: "Project not found" });
const [{ data: docs }, { data: folderData }] = await Promise.all([
db.from("documents").select("*").eq("project_id", projectId).order("created_at", { ascending: true }),
db.from("project_subfolders").select("*").eq("project_id", projectId).order("created_at", { ascending: true }),
]);
const docsTyped = (docs ?? []) as unknown as {
id: string;
current_version_id?: string | null;
}[];
await attachActiveVersionPaths(db, docsTyped);
res.json({ ...data, documents: docsTyped, folders: folderData ?? [] });
});
// DELETE /projects/:projectId
projectsRouter.delete("/:projectId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { projectId } = req.params;
const db = createServerSupabase();
const { error } = await db
.from("projects")
.delete()
.eq("id", projectId)
.eq("user_id", userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// GET /projects/:projectId/documents
projectsRouter.get("/:projectId/documents", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId } = req.params;
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Project not found" });
const { data: docs } = await db
.from("documents")
.select("*")
.eq("project_id", projectId)
.order("created_at", { ascending: true });
const docsTyped = (docs ?? []) as unknown as {
id: string;
current_version_id?: string | null;
}[];
await attachActiveVersionPaths(db, docsTyped);
res.json(docsTyped);
});
// POST /projects/:projectId/documents/:documentId — assign or copy existing doc into project
projectsRouter.post(
"/:projectId/documents/:documentId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId, documentId } = req.params;
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Project not found" });
// Adding-by-id pulls a doc into the project — only the doc's owner
// is allowed to do that, so other people's standalone docs can't be
// siphoned into a project the requester happens to share.
const { data: doc } = await db
.from("documents")
.select("*")
.eq("id", documentId)
.eq("user_id", userId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
// Already in this project — idempotent
if (doc.project_id === projectId) return void res.json(doc);
if (doc.project_id === null) {
// Standalone → assign project_id
const { data: updated, error } = await db
.from("documents")
.update({ project_id: projectId, updated_at: new Date().toISOString() })
.eq("id", documentId)
.select("*")
.single();
if (error || !updated)
return void res.status(500).json({ detail: "Failed to update document" });
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).
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("*")
.single();
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);
// 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;
}
}
}
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);
}
}
}
return void res.status(201).json(copy);
}
},
);
// POST /projects/:projectId/documents
projectsRouter.post(
"/:projectId/documents",
requireAuth,
singleFileUpload("file"),
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId } = req.params;
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Project not found" });
await handleDocumentUpload(req, res, userId, projectId, db);
},
);
// GET /projects/:projectId/chats — every assistant chat under this project
// (any author with project access). Used by the project page's chat tab so
// it doesn't have to filter the global GET /chat list — and so collaborators
// see each other's chats inside the project even though those don't appear
// in the global list.
projectsRouter.get("/:projectId/chats", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId } = req.params;
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Project not found" });
const { data, error } = await db
.from("chats")
.select("*")
.eq("project_id", projectId)
.order("created_at", { ascending: false });
if (error) return void res.status(500).json({ detail: error.message });
res.json(data ?? []);
});
// ── Folder routes ─────────────────────────────────────────────────────────────
// POST /projects/:projectId/folders
projectsRouter.post("/:projectId/folders", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId } = req.params;
const { name, parent_folder_id } = req.body as { name: string; parent_folder_id?: string | null };
if (!name?.trim()) return void res.status(400).json({ detail: "name is required" });
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
// Verify parent folder belongs to this project
if (parent_folder_id) {
const { data: parent } = await db.from("project_subfolders").select("id").eq("id", parent_folder_id).eq("project_id", projectId).single();
if (!parent) return void res.status(404).json({ detail: "Parent folder not found" });
}
const { data, error } = await db.from("project_subfolders").insert({
project_id: projectId,
user_id: userId,
name: name.trim(),
parent_folder_id: parent_folder_id ?? null,
}).select("*").single();
if (error) return void res.status(500).json({ detail: error.message });
res.status(201).json(data);
});
// PATCH /projects/:projectId/folders/:folderId
projectsRouter.patch("/:projectId/folders/:folderId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId, folderId } = req.params;
const body = req.body as { name?: string; parent_folder_id?: string | null };
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
const updates: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (body.name != null) updates.name = body.name.trim();
if ("parent_folder_id" in body) {
// Cycle check: walk up the tree from the proposed parent to ensure folderId is not an ancestor
if (body.parent_folder_id) {
let cur: string | null = body.parent_folder_id;
while (cur) {
if (cur === folderId) return void res.status(400).json({ detail: "Cannot move a folder into itself or a descendant" });
const { data: p }: { data: { parent_folder_id: string | null } | null } =
await db.from("project_subfolders").select("parent_folder_id").eq("id", cur).single();
cur = p?.parent_folder_id ?? null;
}
}
updates.parent_folder_id = body.parent_folder_id ?? null;
}
const { data, error } = await db.from("project_subfolders")
.update(updates)
.eq("id", folderId).eq("project_id", projectId)
.select("*").single();
if (error || !data) return void res.status(404).json({ detail: "Folder not found" });
res.json(data);
});
// DELETE /projects/:projectId/folders/:folderId
projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId, folderId } = req.params;
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
// Move direct documents to root before cascade-deleting subfolders
await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId);
const { error } = await db.from("project_subfolders")
.delete().eq("id", folderId).eq("project_id", projectId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// PATCH /projects/:projectId/documents/:documentId/folder — move doc to a folder
projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId, documentId } = req.params;
const { folder_id } = req.body as { folder_id: string | null };
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
const { data, error } = await db.from("documents")
.update({ folder_id: folder_id ?? null, updated_at: new Date().toISOString() })
.eq("id", documentId).eq("project_id", projectId)
.select("*").single();
if (error || !data) return void res.status(404).json({ detail: "Document not found" });
res.json(data);
});
export async function handleDocumentUpload(
req: import("express").Request,
res: import("express").Response,
userId: string,
projectId: string | null,
db: ReturnType<typeof createServerSupabase>,
) {
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_TYPES.has(suffix))
return void res
.status(400)
.json({
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
});
const content = file.buffer;
const { data: doc, error: insertErr } = await db
.from("documents")
.insert({
project_id: projectId,
user_id: userId,
filename,
file_type: suffix,
size_bytes: content.byteLength,
status: "processing",
})
.select("*")
.single();
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 =
suffix === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
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 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.
let pdfStoragePath: string | null = null;
if (suffix === "docx" || suffix === "doc") {
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] DOCX→PDF conversion failed for ${filename}:`,
err,
);
}
} else if (suffix === "pdf") {
pdfStoragePath = key;
}
// Storage paths live on document_versions — create the V1 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,
display_name: filename,
})
.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,
size_bytes: content.byteLength,
page_count: pageCount,
structure_tree: tree ?? null,
status: "ready",
updated_at: new Date().toISOString(),
})
.eq("id", docId);
const { data: updated } = await db
.from("documents")
.select("*")
.eq("id", docId)
.single();
const responseDoc = updated
? {
...updated,
storage_path: key,
pdf_storage_path: pdfStoragePath,
}
: 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;
}
}
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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
export const userRouter = Router();
// POST /user/profile
userRouter.post("/profile", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { error } = await db
.from("user_profiles")
.upsert(
{ user_id: userId },
{ onConflict: "user_id", ignoreDuplicates: true },
);
if (error) return void res.status(500).json({ detail: error.message });
res.json({ ok: true });
});
// DELETE /user/account
userRouter.delete("/account", requireAuth, async (_req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { error } = await db.auth.admin.deleteUser(userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});

View file

@ -0,0 +1,367 @@
import { Router } from "express";
import { createClient } from "@supabase/supabase-js";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
function getAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.SUPABASE_SECRET_KEY ?? "",
{ auth: { autoRefreshToken: false, persistSession: false } },
);
}
export const workflowsRouter = Router();
type Db = ReturnType<typeof createServerSupabase>;
type WorkflowRecord = {
id: string;
user_id: string | null;
is_system: boolean;
[key: string]: unknown;
};
type WorkflowAccess =
| {
workflow: WorkflowRecord;
allowEdit: boolean;
isOwner: boolean;
}
| null;
function withWorkflowAccess<T extends Record<string, unknown>>(
workflow: T,
access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null },
) {
return {
...workflow,
allow_edit: access.allowEdit,
is_owner: access.isOwner,
shared_by_name: access.sharedByName ?? null,
};
}
async function resolveWorkflowAccess(
workflowId: string,
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<WorkflowAccess> {
const { data: workflow } = await db
.from("workflows")
.select("*")
.eq("id", workflowId)
.single();
if (!workflow) return null;
const workflowRecord = workflow as WorkflowRecord;
if (workflowRecord.user_id === userId) {
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
}
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
if (!normalizedUserEmail) return null;
const { data: share } = await db
.from("workflow_shares")
.select("allow_edit")
.eq("workflow_id", workflowId)
.eq("shared_with_email", normalizedUserEmail)
.maybeSingle();
if (!share) return null;
return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false };
}
// GET /workflows
workflowsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string;
const { type } = req.query as { type?: string };
const db = createServerSupabase();
// Own workflows
let ownQuery = db
.from("workflows")
.select("*")
.eq("user_id", userId)
.eq("is_system", false)
.order("created_at", { ascending: false });
if (type) ownQuery = ownQuery.eq("type", type);
const { data: own, error: ownErr } = await ownQuery;
if (ownErr) return void res.status(500).json({ detail: ownErr.message });
// Shared workflows (where the current user's email appears in workflow_shares)
const normalizedUserEmail = userEmail.trim().toLowerCase();
const { data: shares } = await db
.from("workflow_shares")
.select("workflow_id, shared_by_user_id, allow_edit")
.eq("shared_with_email", normalizedUserEmail);
let sharedWorkflows: Record<string, unknown>[] = [];
if (shares && shares.length > 0) {
const sharedIds = shares.map((s) => s.workflow_id);
let sharedQuery = db.from("workflows").select("*").in("id", sharedIds);
if (type) sharedQuery = sharedQuery.eq("type", type);
const { data: wfs } = await sharedQuery;
if (wfs && wfs.length > 0) {
// Fetch sharer profiles
const sharerIds = [...new Set(shares.map((s) => s.shared_by_user_id).filter(Boolean))];
const { data: profiles } = sharerIds.length > 0
? await db.from("user_profiles").select("user_id, display_name").in("user_id", sharerIds)
: { data: [] };
// Fetch sharer emails via admin client
const admin = getAdminClient();
const { data: authData } = await admin.auth.admin.listUsers({ perPage: 1000 });
const authUsers = authData?.users ?? [];
sharedWorkflows = wfs.map((wf) => {
const share = shares.find((s) => s.workflow_id === wf.id);
const sharerId = share?.shared_by_user_id;
const profile = profiles?.find((p) => p.user_id === sharerId);
const authUser = authUsers.find((u) => u.id === sharerId);
const shared_by_name = profile?.display_name || authUser?.email || null;
return withWorkflowAccess(wf, {
allowEdit: !!share?.allow_edit,
isOwner: false,
sharedByName: shared_by_name,
});
});
}
}
const ownWithFlag = (own ?? []).map((wf) =>
withWorkflowAccess(wf, { allowEdit: true, isOwner: true }),
);
res.json([...ownWithFlag, ...sharedWorkflows]);
});
// POST /workflows
workflowsRouter.post("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { title, type, prompt_md, columns_config, practice } = req.body as {
title: string;
type: string;
prompt_md?: string;
columns_config?: unknown;
practice?: string | null;
};
if (!title?.trim())
return void res.status(400).json({ detail: "title is required" });
if (!["assistant", "tabular"].includes(type))
return void res
.status(400)
.json({ detail: "type must be 'assistant' or 'tabular'" });
const db = createServerSupabase();
const { data, error } = await db
.from("workflows")
.insert({
user_id: userId,
title: title.trim(),
type,
prompt_md: prompt_md ?? null,
columns_config: columns_config ?? null,
practice: practice ?? null,
is_system: false,
})
.select("*")
.single();
if (error) return void res.status(500).json({ detail: error.message });
res.status(201).json(data);
});
async function handleWorkflowUpdate(req: import("express").Request, res: import("express").Response) {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { workflowId } = req.params;
const updates: Record<string, unknown> = {};
if (req.body.title != null) updates.title = req.body.title;
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
const db = createServerSupabase();
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
if (!access || access.workflow.is_system || !access.allowEdit) {
return void res
.status(404)
.json({ detail: "Workflow not found or not editable" });
}
const { data, error } = await db
.from("workflows")
.update(updates)
.eq("id", workflowId)
.eq("is_system", false)
.select("*")
.single();
if (error || !data)
return void res
.status(404)
.json({ detail: "Workflow not found or not editable" });
res.json(
withWorkflowAccess(data, {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),
);
}
// PUT /workflows/:workflowId
workflowsRouter.put("/:workflowId", requireAuth, handleWorkflowUpdate);
// PATCH /workflows/:workflowId
workflowsRouter.patch("/:workflowId", requireAuth, handleWorkflowUpdate);
// DELETE /workflows/:workflowId
workflowsRouter.delete("/:workflowId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const db = createServerSupabase();
const { error } = await db
.from("workflows")
.delete()
.eq("id", workflowId)
.eq("user_id", userId)
.eq("is_system", false);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// GET /workflows/hidden
workflowsRouter.get("/hidden", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { data, error } = await db
.from("hidden_workflows")
.select("workflow_id")
.eq("user_id", userId);
if (error) return void res.status(500).json({ detail: error.message });
res.json((data ?? []).map((r) => r.workflow_id));
});
// POST /workflows/hidden
workflowsRouter.post("/hidden", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { workflow_id } = req.body as { workflow_id: string };
if (!workflow_id?.trim())
return void res.status(400).json({ detail: "workflow_id is required" });
const db = createServerSupabase();
const { error } = await db
.from("hidden_workflows")
.upsert({ user_id: userId, workflow_id }, { onConflict: "user_id,workflow_id" });
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// DELETE /workflows/hidden/:workflowId
workflowsRouter.delete("/hidden/:workflowId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const db = createServerSupabase();
const { error } = await db
.from("hidden_workflows")
.delete()
.eq("user_id", userId)
.eq("workflow_id", workflowId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// GET /workflows/:workflowId
workflowsRouter.get("/:workflowId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { workflowId } = req.params;
const db = createServerSupabase();
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
if (!access)
return void res.status(404).json({ detail: "Workflow not found" });
res.json(
withWorkflowAccess(access.workflow, {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),
);
});
// GET /workflows/:workflowId/shares
workflowsRouter.get("/:workflowId/shares", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const db = createServerSupabase();
const { data: wf } = await db
.from("workflows")
.select("id")
.eq("id", workflowId)
.eq("user_id", userId)
.eq("is_system", false)
.single();
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
const { data: shares, error } = await db
.from("workflow_shares")
.select("id, shared_with_email, allow_edit, created_at")
.eq("workflow_id", workflowId)
.order("created_at", { ascending: true });
if (error) return void res.status(500).json({ detail: error.message });
res.json(shares ?? []);
});
// DELETE /workflows/:workflowId/shares/:shareId
workflowsRouter.delete("/:workflowId/shares/:shareId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId, shareId } = req.params;
const db = createServerSupabase();
const { data: wf } = await db
.from("workflows")
.select("id")
.eq("id", workflowId)
.eq("user_id", userId)
.single();
if (!wf) return void res.status(404).json({ detail: "Workflow not found" });
await db.from("workflow_shares").delete().eq("id", shareId).eq("workflow_id", workflowId);
res.status(204).send();
});
// POST /workflows/:workflowId/share
workflowsRouter.post("/:workflowId/share", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const { emails, allow_edit } = req.body as { emails: string[]; allow_edit: boolean };
if (!emails?.length) return void res.status(400).json({ detail: "emails is required" });
const db = createServerSupabase();
// Verify ownership
const { data: wf } = await db
.from("workflows")
.select("id")
.eq("id", workflowId)
.eq("user_id", userId)
.eq("is_system", false)
.single();
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
const rows = emails.map((email: string) => ({
workflow_id: workflowId,
shared_by_user_id: userId,
shared_with_email: email.trim().toLowerCase(),
allow_edit: allow_edit ?? false,
}));
// Upsert on (workflow_id, shared_with_email) so re-sharing to the same
// person updates the existing row instead of stacking duplicates.
const { error } = await db
.from("workflow_shares")
.upsert(rows, { onConflict: "workflow_id,shared_with_email" });
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});