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

39
backend/src/index.ts Normal file
View file

@ -0,0 +1,39 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";
const app = express();
const PORT = process.env.PORT ?? 3001;
app.use(
cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
credentials: true,
}),
);
app.use(express.json({ limit: "50mb" }));
app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`Mike backend running on port ${PORT}`);
});

146
backend/src/lib/access.ts Normal file
View file

@ -0,0 +1,146 @@
/**
* Project / document access helpers.
*
* Sharing makes the previous "scope by user_id" pattern incorrect a doc
* can belong to user A's project that A has shared with B's email, and B
* must still be able to read/edit it. These helpers centralize the
* "owner OR shared project member" check so every route uses the same
* logic instead of re-implementing the join.
*
* Returned `isOwner` lets callers gate operations that should stay
* owner-only (delete, rename, member management).
*/
import type { createServerSupabase } from "./supabase";
type Db = ReturnType<typeof createServerSupabase>;
export type ProjectAccess =
| {
ok: true;
isOwner: boolean;
project: {
id: string;
user_id: string;
shared_with: string[] | null;
};
}
| { ok: false };
export async function checkProjectAccess(
projectId: string,
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<ProjectAccess> {
const { data: project } = await db
.from("projects")
.select("id, user_id, shared_with")
.eq("id", projectId)
.single();
if (!project) return { ok: false };
const proj = project as {
id: string;
user_id: string;
shared_with: string[] | null;
};
if (proj.user_id === userId) {
return { ok: true, isOwner: true, project: proj };
}
const sharedWith = Array.isArray(proj.shared_with) ? proj.shared_with : [];
const email = (userEmail ?? "").toLowerCase();
if (
email &&
sharedWith.some((e) => (e ?? "").toLowerCase() === email)
) {
return { ok: true, isOwner: false, project: proj };
}
return { ok: false };
}
/**
* Check whether the current user can access a document the caller has
* already loaded (saves a round-trip vs. having the helper re-fetch).
* Owner-of-doc passes immediately; otherwise we fall through to a
* project-membership check via `shared_with`.
*/
export async function ensureDocAccess(
doc: { user_id: string; project_id: string | null },
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<{ ok: true; isOwner: boolean } | { ok: false }> {
if (doc.user_id === userId) return { ok: true, isOwner: true };
if (!doc.project_id) return { ok: false };
const access = await checkProjectAccess(
doc.project_id,
userId,
userEmail,
db,
);
if (access.ok) return { ok: true, isOwner: false };
return { ok: false };
}
/**
* Same shape as `ensureDocAccess`, for tabular_reviews. A review can be
* shared in two ways:
* 1. Indirectly if `project_id` is set, everyone with project access
* can read/operate on it.
* 2. Directly `tabular_reviews.shared_with` is a per-review email list
* so standalone reviews (project_id null) can also be shared.
* The owner (review.user_id) always has access.
*/
export async function ensureReviewAccess(
review: {
user_id: string;
project_id: string | null;
shared_with?: string[] | null;
},
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<{ ok: true; isOwner: boolean } | { ok: false }> {
if (review.user_id === userId) return { ok: true, isOwner: true };
const email = (userEmail ?? "").toLowerCase();
if (email && Array.isArray(review.shared_with)) {
if (review.shared_with.some((e) => (e ?? "").toLowerCase() === email)) {
return { ok: true, isOwner: false };
}
}
if (!review.project_id) return { ok: false };
const access = await checkProjectAccess(
review.project_id,
userId,
userEmail,
db,
);
if (access.ok) return { ok: true, isOwner: false };
return { ok: false };
}
/**
* Returns the set of project IDs the user can access own projects plus
* any project where their email is in `shared_with`. Used to scope chat
* lists and similar collection queries.
*/
export async function listAccessibleProjectIds(
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<string[]> {
const [{ data: own }, { data: shared }] = await Promise.all([
db.from("projects").select("id").eq("user_id", userId),
userEmail
? db
.from("projects")
.select("id")
.contains("shared_with", [userEmail])
.neq("user_id", userId)
: Promise.resolve({ data: [] as { id: string }[] }),
]);
const ids = new Set<string>();
for (const p of (own ?? []) as { id: string }[]) ids.add(p.id);
for (const p of (shared ?? []) as { id: string }[]) ids.add(p.id);
return [...ids];
}

View file

@ -0,0 +1,76 @@
export const BUILTIN_WORKFLOWS: { id: string; title: string; prompt_md: string }[] = [
{
id: "builtin-cp-checklist",
title: "Generate CP Checklist",
prompt_md:
"## Generate Conditions Precedent Checklist\n\n" +
"Review the uploaded credit agreement or financing document and generate a comprehensive " +
"Conditions Precedent (CP) checklist.\n\n" +
"You MUST use the generate_docx tool to produce the checklist as a downloadable Word document. " +
"You MUST pass landscape: true to the generate_docx tool — the document must be in landscape orientation. " +
"Do not display the checklist inline — generate the .docx file and provide the download link.\n\n" +
"Structure the document as follows:\n" +
"- For each category of conditions (e.g. Corporate, Financial, Legal, Security), add a section with a heading\n" +
"- Under each category heading, include a table with exactly these four columns in this order:\n" +
" 1. Index — sequential number within the category (1, 2, 3…)\n" +
" 2. Clause Number — the clause or schedule reference from the agreement\n" +
" 3. Clause — a concise description of the condition precedent\n" +
" 4. Status — leave blank (empty string) for the user to fill in\n\n" +
"Use the table field in the section object (not content) for each category's rows.\n\n" +
"Before finalizing, double-check that every table is formatted correctly: each table must have exactly the four columns above in the same order, headers must match exactly (Index, Clause Number, Clause, Status), every row must have the same number of cells as the headers, the Index column must be sequential starting from 1 within each category, and no cells should contain stray markdown, newlines, or placeholder text (use an empty string for Status).",
},
{
id: "builtin-credit-summary",
title: "Credit Agreement Summary",
prompt_md:
"## Credit Agreement Summary\n\n" +
"Review the uploaded credit agreement and produce a comprehensive legal summary covering the following topics. " +
"For each section, identify the key provisions, quote the relevant clause or schedule references, and flag any unusual, onerous, or non-market terms.\n\n" +
"1. **Lenders** — All lenders or members of the lender syndicate, including their full legal name and role (e.g. mandated lead arranger, original lender, agent bank)\n" +
"2. **Borrowers** — All borrowers, including their full legal name and jurisdiction of incorporation\n" +
"3. **Guarantors** — All guarantors, including their full legal name and the scope of their guarantee obligation\n" +
"4. **Other Parties** — Any other material parties (e.g. facility agent, security agent, hedge counterparties, issuing bank) and their roles\n" +
"5. **Date of Agreement** — Date of the credit agreement\n" +
"6. **Facilities** — Each facility available (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C), the facility type, tranche name, and any key structural features\n" +
"7. **Amount** — Total committed amount across all facilities, the currency, and breakdown by tranche if applicable\n" +
"8. **Purpose** — Stated purpose for which borrowings may be used and any restrictions on use of proceeds\n" +
"9. **Interest** — Applicable reference rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured\n" +
"10. **Commitment Fee** — Commitment or utilisation fees, the applicable rate, how they are calculated, and the basis (e.g. undrawn commitment, average utilisation)\n" +
"11. **Repayment Schedule** — Repayment profile for each facility, whether by scheduled instalments or bullet repayment, and the repayment dates and amounts\n" +
"12. **Maturity** — Final maturity date for each facility\n" +
"13. **Security** — Each class of security granted or required (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken\n" +
"14. **Guarantees** — Guarantee obligations, the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test)\n" +
"15. **Financial Covenants** — Each financial covenant, the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, testing frequency, and any equity cure rights\n" +
"16. **Events of Default** — Each event of default, noting any grace periods, materiality thresholds, or cross-default provisions\n" +
"17. **Assignment** — Restrictions or permissions on assignment or transfer (e.g. white/blacklists, borrower consent for lender transfers; restrictions on borrower assignment)\n" +
"18. **Change of Control** — What constitutes a change of control, what obligations it triggers (e.g. mandatory prepayment, cancellation, lender consent), and any cure period\n" +
"19. **Prepayment Fee** — Any prepayment fees, make-whole premiums, or soft-call protections, the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposals)\n" +
"20. **Governing Law** — Governing law of the agreement\n" +
"21. **Dispute Resolution** — Whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions\n\n" +
"Deliver the summary inline in your chat response — do NOT call generate_docx. Only produce a downloadable Word document if the user explicitly asks for one.",
},
{
id: "builtin-sha-summary",
title: "Shareholder Agreement Summary",
prompt_md:
"## Shareholder Agreement Summary\n\n" +
"Review the uploaded shareholder agreement and produce a comprehensive legal summary covering the following topics. " +
"For each section, identify the key provisions, quote the relevant clause references, and flag any unusual, onerous, or market-standard deviations.\n\n" +
"1. **Parties & Shareholdings** — Full legal names, roles, share classes held, and percentage interests (on a fully diluted basis if stated)\n" +
"2. **Share Classes & Rights** — For each class: voting rights, dividend rights, liquidation preference, conversion or redemption features\n" +
"3. **Board Composition & Governance** — Board size, director appointment rights (and the shareholding thresholds required to maintain them), quorum, and casting vote\n" +
"4. **Reserved Matters** — Decisions requiring a special majority, unanimity, or a specific shareholder's consent; note the threshold and whose consent is required for each\n" +
"5. **Pre-emption on New Shares** — Who holds pre-emption rights, procedure, timeline, and any carve-outs (e.g. employee option schemes)\n" +
"6. **Transfer Restrictions** — Lock-up periods, prohibited transfers, permitted transfers (e.g. to affiliates), and any board or shareholder approval requirements\n" +
"7. **Right of First Refusal / Pre-emption on Transfer** — Trigger, procedure, pricing mechanics, and any exceptions\n" +
"8. **Drag-Along Rights** — Who holds the right, threshold to trigger, conditions (e.g. minimum price, independent valuation), and minority protections\n" +
"9. **Tag-Along Rights** — Who holds the right, triggering threshold, exercise procedure, and price terms\n" +
"10. **Anti-Dilution Protections** — Type (full ratchet, weighted average), trigger events, calculation mechanics, and exceptions\n" +
"11. **Dividend Policy** — Any obligation or target to pay dividends, preferential dividend rights, and restrictions on distributions\n" +
"12. **Exit & Liquidity** — Agreed exit routes (trade sale, IPO, drag sale), timelines, and liquidation preferences on exit\n" +
"13. **Deadlock** — Deadlock definition, escalation and resolution mechanisms (e.g. Russian roulette, put/call options), and consequences if unresolved\n" +
"14. **Non-Compete & Non-Solicitation** — Who is bound, scope of activities and geography, duration, and carve-outs\n" +
"15. **Governing Law & Dispute Resolution** — Applicable law, forum, arbitration or litigation, and any mandatory escalation steps\n\n" +
"Generate the summary as a downloadable Word document.",
},
];

2838
backend/src/lib/chatTools.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
import { promisify } from "util";
import JSZip from "jszip";
let _convert:
| ((buf: Buffer, ext: string, filter: undefined) => Promise<Buffer>)
| null = null;
async function getConvert() {
if (!_convert) {
const libre = await import("libreoffice-convert");
_convert = promisify(libre.default.convert.bind(libre.default));
}
return _convert;
}
/**
* Some older Windows/Word archives store .docx entries with backslash
* separators (e.g. `word\document.xml`). Mammoth and LibreOffice both look
* up entries by exact string and miss those files, producing empty output
* or conversion failures. Rewrite any such entries to the canonical
* forward-slash form before handing the buffer off.
*/
export async function normalizeDocxZipPaths(buffer: Buffer): Promise<Buffer> {
let zip: JSZip;
try {
zip = await JSZip.loadAsync(buffer);
} catch {
return buffer;
}
const renames: [string, string][] = [];
zip.forEach((relativePath) => {
if (relativePath.includes("\\")) {
renames.push([relativePath, relativePath.replace(/\\/g, "/")]);
}
});
if (renames.length === 0) return buffer;
for (const [oldPath, newPath] of renames) {
const entry = zip.file(oldPath);
if (!entry) continue;
const content = await entry.async("nodebuffer");
zip.remove(oldPath);
zip.file(newPath, content);
}
return zip.generateAsync({ type: "nodebuffer" });
}
/**
* Convert a DOCX/DOC buffer to PDF using LibreOffice.
* Throws if LibreOffice is not installed or conversion fails.
*/
export async function docxToPdf(buffer: Buffer): Promise<Buffer> {
const convert = await getConvert();
const normalized = await normalizeDocxZipPaths(buffer);
return convert(normalized, ".pdf", undefined);
}
export function convertedPdfKey(userId: string, docId: string): string {
return `converted-pdfs/${userId}/${docId}.pdf`;
}

View file

@ -0,0 +1,159 @@
import type { createServerSupabase } from "./supabase";
type Supa = ReturnType<typeof createServerSupabase>;
interface DocRow {
id: string;
latest_version_number?: number | null;
[k: string]: unknown;
}
interface VersionPathRow extends DocRow {
/** Set from document_versions.storage_path of the active version. */
storage_path?: string | null;
/** Set from document_versions.pdf_storage_path of the active version. */
pdf_storage_path?: string | null;
current_version_id?: string | null;
/** Set from document_versions.version_number of the active version. */
active_version_number?: number | null;
}
export interface ActiveVersion {
id: string;
storage_path: string;
pdf_storage_path: string | null;
version_number: number | null;
display_name: string | null;
source: string | null;
}
/**
* Resolve storage paths for a document. Prefers the version pointed to by
* `versionId` (if it belongs to this document); else falls back to
* `documents.current_version_id`. Returns null if no usable version exists.
*
* After the storage_path/pdf_storage_path columns moved off `documents`,
* every read-from-storage path goes through here.
*/
export async function loadActiveVersion(
documentId: string,
db: Supa,
versionId?: string | null,
): Promise<ActiveVersion | null> {
const { data: doc } = await db
.from("documents")
.select("current_version_id")
.eq("id", documentId)
.single();
const targetVersionId =
(typeof versionId === "string" && versionId) ||
(doc?.current_version_id as string | undefined) ||
null;
if (!targetVersionId) return null;
const { data: v } = await db
.from("document_versions")
.select(
"id, document_id, storage_path, pdf_storage_path, version_number, display_name, source",
)
.eq("id", targetVersionId)
.single();
if (!v || v.document_id !== documentId || !v.storage_path) return null;
return {
id: v.id as string,
storage_path: v.storage_path as string,
pdf_storage_path: (v.pdf_storage_path as string | null) ?? null,
version_number: (v.version_number as number | null) ?? null,
display_name: (v.display_name as string | null) ?? null,
source: (v.source as string | null) ?? null,
};
}
/**
* For a list of documents, look up the active version for each and merge
* `storage_path` + `pdf_storage_path` onto the row. One round-trip total
* regardless of list size. Documents with no current_version_id retain
* null paths.
*/
export async function attachActiveVersionPaths<T extends VersionPathRow>(
db: Supa,
docs: T[],
): Promise<T[]> {
if (docs.length === 0) return docs;
const versionIds = docs
.map((d) => d.current_version_id)
.filter((id): id is string => typeof id === "string");
if (versionIds.length === 0) {
for (const d of docs) {
d.storage_path = null;
d.pdf_storage_path = null;
}
return docs;
}
const { data: rows } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, version_number")
.in("id", versionIds);
const byId = new Map<
string,
{
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
}
>();
for (const r of (rows ?? []) as {
id: string;
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
}[]) {
byId.set(r.id, {
storage_path: r.storage_path ?? null,
pdf_storage_path: r.pdf_storage_path ?? null,
version_number: r.version_number ?? null,
});
}
for (const d of docs) {
const v = d.current_version_id ? byId.get(d.current_version_id) : null;
d.storage_path = v?.storage_path ?? null;
d.pdf_storage_path = v?.pdf_storage_path ?? null;
d.active_version_number = v?.version_number ?? null;
}
return docs;
}
/**
* Given a list of document rows, attach `latest_version_number` the
* max `version_number` across all assistant_edit rows for that doc, or
* null if none. Mutates rows in place and returns the same reference.
* One extra query regardless of list size.
*/
export async function attachLatestVersionNumbers<T extends DocRow>(
db: Supa,
docs: T[],
): Promise<T[]> {
if (docs.length === 0) return docs;
const ids = docs.map((d) => d.id);
const { data: rows } = await db
.from("document_versions")
.select("document_id, version_number")
.in("document_id", ids)
.eq("source", "assistant_edit")
.not("version_number", "is", null);
const latestByDoc = new Map<string, number>();
for (const r of (rows ?? []) as {
document_id: string;
version_number: number | null;
}[]) {
if (r.version_number == null) continue;
const prev = latestByDoc.get(r.document_id) ?? 0;
if (r.version_number > prev)
latestByDoc.set(r.document_id, r.version_number);
}
for (const d of docs) {
d.latest_version_number = latestByDoc.get(d.id) ?? null;
}
return docs;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
import crypto from "crypto";
/**
* HMAC-signed, non-expiring download tokens.
*
* The token encodes the R2 storage path + filename; the backend route
* `/download/:token` validates the signature and streams the file. This
* gives persistent links safe to store in chat history without signed-URL
* expiry or R2 CORS headaches.
*/
function getSecret(): string {
return (
process.env.DOWNLOAD_SIGNING_SECRET ??
process.env.SUPABASE_SECRET_KEY ??
"dev-secret"
);
}
function b64urlEncode(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function b64urlDecode(s: string): Buffer {
let t = s.replace(/-/g, "+").replace(/_/g, "/");
while (t.length % 4) t += "=";
return Buffer.from(t, "base64");
}
function timingSafeEqStr(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export function signDownload(path: string, filename: string): string {
const payload = JSON.stringify({ p: path, f: filename });
const enc = b64urlEncode(Buffer.from(payload, "utf8"));
const sig = crypto
.createHmac("sha256", getSecret())
.update(enc)
.digest();
return `${enc}.${b64urlEncode(sig)}`;
}
export function verifyDownload(
token: string,
): { path: string; filename: string } | null {
const parts = token.split(".");
if (parts.length !== 2) return null;
const [enc, sigEnc] = parts;
const expected = crypto
.createHmac("sha256", getSecret())
.update(enc)
.digest();
if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null;
try {
const parsed = JSON.parse(b64urlDecode(enc).toString("utf8")) as {
p: string;
f: string;
};
if (!parsed?.p || !parsed?.f) return null;
return { path: parsed.p, filename: parsed.f };
} catch {
return null;
}
}
/**
* Returns a relative download URL (e.g. "/download/abc.def"). The frontend
* prefixes it with NEXT_PUBLIC_API_BASE_URL when rendering `<a href=…>`.
*/
export function buildDownloadUrl(path: string, filename: string): string {
return `/download/${signDownload(path, filename)}`;
}

View file

@ -0,0 +1,172 @@
import Anthropic from "@anthropic-ai/sdk";
import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages";
import * as fs from "fs";
import * as path from "path";
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
NormalizedToolResult,
} from "./types";
import { toClaudeTools } from "./tools";
const RAW_STREAM_LOG_PATH = path.resolve(
process.cwd(),
"claude-raw-stream.log",
);
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: string; [key: string]: unknown };
type NativeMessage = {
role: "user" | "assistant";
content: string | ContentBlock[];
};
const MAX_TOKENS = 16384;
function client(override?: string | null): Anthropic {
const apiKey = override?.trim() || process.env.ANTHROPIC_API_KEY || "";
return new Anthropic({ apiKey });
}
function toNativeMessages(
messages: StreamChatParams["messages"],
): NativeMessage[] {
return messages.map((m) => ({ role: m.role, content: m.content }));
}
export async function streamClaude(
params: StreamChatParams,
): Promise<StreamChatResult> {
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const anthropic = client(apiKeys?.claude);
const claudeTools = toClaudeTools(tools);
const messages: NativeMessage[] = toNativeMessages(params.messages);
let fullText = "";
for (let iter = 0; iter < maxIter; iter++) {
const stream = anthropic.messages.stream({
model,
system: systemPrompt,
messages: messages as Anthropic.MessageParam[],
tools: claudeTools.length
? (claudeTools as unknown as Tool[])
: undefined,
max_tokens: MAX_TOKENS,
// Claude 4.x models require `thinking.type: "adaptive"` and
// drive effort via `output_config.effort` rather than a fixed
// token budget. We only opt in when the caller requested it.
...(enableThinking
? ({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
} as unknown as Record<string, unknown>)
: {}),
// Extended thinking requires temperature to be default (omitted).
});
let sawThinking = false;
stream.on("streamEvent", (event) => {
const line = JSON.stringify(event);
console.log("[claude raw stream]", line);
fs.appendFile(RAW_STREAM_LOG_PATH, line + "\n", () => {});
});
stream.on("text", (delta) => {
callbacks.onContentDelta?.(delta);
});
if (enableThinking) {
stream.on("thinking", (delta) => {
sawThinking = true;
callbacks.onReasoningDelta?.(delta);
});
}
const final = await stream.finalMessage();
if (sawThinking) callbacks.onReasoningBlockEnd?.();
const stopReason = final.stop_reason;
const assistantBlocks = final.content as ContentBlock[];
// Extract text content and tool_use calls from the final assistant
// message so we can accumulate text and drive the tool-call loop.
const toolCalls: NormalizedToolCall[] = [];
for (const block of assistantBlocks) {
if (block.type === "text") {
const txt = (block as { text: string }).text;
if (typeof txt === "string") fullText += txt;
} else if (block.type === "tool_use") {
const tu = block as {
id: string;
name: string;
input: unknown;
};
const call: NormalizedToolCall = {
id: tu.id,
name: tu.name,
input: (tu.input as Record<string, unknown>) ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
if (stopReason !== "tool_use" || !toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
// Record the assistant turn (preserving the original content blocks,
// which Claude requires on the follow-up) and the user turn that
// carries the tool_result blocks.
messages.push({ role: "assistant", content: assistantBlocks });
messages.push({
role: "user",
content: results.map((r) => ({
type: "tool_result",
tool_use_id: r.tool_use_id,
content: r.content,
})),
});
}
return { fullText };
}
export async function completeClaudeText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { claude?: string | null };
}): Promise<string> {
const anthropic = client(params.apiKeys?.claude);
const resp = await anthropic.messages.create({
model: params.model,
max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt,
messages: [{ role: "user", content: params.user }],
});
const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return text;
}
// Helper re-export for callers wanting to hand normalized results back in.
export type { NormalizedToolResult };

View file

@ -0,0 +1,162 @@
import { GoogleGenAI } from "@google/genai";
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
} from "./types";
import { toGeminiTools } from "./tools";
type GeminiPart = {
text?: string;
// Set by Gemini when the text content is a thought summary rather than
// final-answer prose. Requires `thinkingConfig.includeThoughts: true`.
thought?: boolean;
functionCall?: { id?: string; name: string; args?: Record<string, unknown> };
functionResponse?: {
id?: string;
name: string;
response: Record<string, unknown>;
};
// Gemini 3 returns a thoughtSignature on parts that contain reasoning or
// a functionCall. It must be echoed back verbatim on the same part when
// we replay the model's turn, or the API rejects the next call.
thoughtSignature?: string;
};
type GeminiContent = {
role: "user" | "model";
parts: GeminiPart[];
};
function client(override?: string | null): GoogleGenAI {
const apiKey = override?.trim() || process.env.GEMINI_API_KEY || "";
return new GoogleGenAI({ apiKey });
}
function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent[] {
return messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
}
export async function streamGemini(
params: StreamChatParams,
): Promise<StreamChatResult> {
const { model, systemPrompt, tools = [], callbacks = {}, runTools, apiKeys, enableThinking } = params;
const maxIter = params.maxIterations ?? 10;
const ai = client(apiKeys?.gemini);
const functionDeclarations = toGeminiTools(tools);
const contents: GeminiContent[] = toNativeContents(params.messages);
let fullText = "";
for (let iter = 0; iter < maxIter; iter++) {
const stream = await ai.models.generateContentStream({
model,
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
// When enabled, ask Gemini to surface thought summaries.
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
},
});
// Per-iteration accumulators.
const textParts: string[] = [];
const callParts: GeminiPart[] = [];
const toolCalls: NormalizedToolCall[] = [];
let sawThinking = false;
for await (const chunk of stream) {
console.log("[gemini stream chunk]", JSON.stringify(chunk, null, 2));
const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if (part.thought) {
sawThinking = true;
callbacks.onReasoningDelta?.(part.text);
} else {
textParts.push(part.text);
callbacks.onContentDelta?.(part.text);
}
}
if (part.functionCall) {
// Preserve the whole part (including thoughtSignature)
// so it can be echoed verbatim in the replay turn.
callParts.push(part);
const call: NormalizedToolCall = {
id: part.functionCall.id ?? `${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name,
input: part.functionCall.args ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
}
if (sawThinking) callbacks.onReasoningBlockEnd?.();
fullText += textParts.join("");
if (!toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
// Append the model's turn (text + functionCall parts, in that order)
// and the matching functionResponse turn.
const modelParts: GeminiPart[] = [];
if (textParts.length) modelParts.push({ text: textParts.join("") });
for (const cp of callParts) modelParts.push(cp);
contents.push({ role: "model", parts: modelParts });
contents.push({
role: "user",
parts: results.map((r) => {
const match = toolCalls.find((c) => c.id === r.tool_use_id);
return {
functionResponse: {
...(r.tool_use_id && !r.tool_use_id.startsWith(match?.name ?? "")
? { id: r.tool_use_id }
: {}),
name: match?.name ?? "tool",
response: { output: r.content },
},
};
}),
});
}
return { fullText };
}
export async function completeGeminiText(params: {
model: string;
systemPrompt?: string;
user: string;
apiKeys?: { gemini?: string | null };
}): Promise<string> {
const ai = client(params.apiKeys?.gemini);
const resp = await ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
});
return resp.text ?? "";
}

View file

@ -0,0 +1,27 @@
import { streamClaude, completeClaudeText } from "./claude";
import { streamGemini, completeGeminiText } from "./gemini";
import { providerForModel } from "./models";
import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types";
export * from "./types";
export * from "./models";
export async function streamChatWithTools(
params: StreamChatParams,
): Promise<StreamChatResult> {
const provider = providerForModel(params.model);
if (provider === "claude") return streamClaude(params);
return streamGemini(params);
}
export async function completeText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: UserApiKeys;
}): Promise<string> {
const provider = providerForModel(params.model);
if (provider === "claude") return completeClaudeText(params);
return completeGeminiText(params);
}

View file

@ -0,0 +1,48 @@
import type { Provider } from "./types";
// ---------------------------------------------------------------------------
// Canonical model IDs
// ---------------------------------------------------------------------------
// Main-chat tier (top-end) — user picks one of these per message.
export const CLAUDE_MAIN_MODELS = ["claude-opus-4-7", "claude-sonnet-4-6"] as const;
export const GEMINI_MAIN_MODELS = [
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
] as const;
// Mid-tier (used for tabular review) — user picks one in account settings.
export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const;
export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const;
// Low-tier (used for title generation, lightweight extractions) — user picks
// one in account settings.
export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const;
export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const;
export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview";
export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview";
export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview";
const ALL_MODELS = new Set<string>([
...CLAUDE_MAIN_MODELS,
...GEMINI_MAIN_MODELS,
...CLAUDE_MID_MODELS,
...GEMINI_MID_MODELS,
...CLAUDE_LOW_MODELS,
...GEMINI_LOW_MODELS,
]);
// ---------------------------------------------------------------------------
// Provider inference
// ---------------------------------------------------------------------------
export function providerForModel(model: string): Provider {
if (model.startsWith("claude")) return "claude";
if (model.startsWith("gemini")) return "gemini";
throw new Error(`Unknown model id: ${model}`);
}
export function resolveModel(id: string | null | undefined, fallback: string): string {
if (id && ALL_MODELS.has(id)) return id;
return fallback;
}

View file

@ -0,0 +1,74 @@
import type { OpenAIToolSchema } from "./types";
// ---------------------------------------------------------------------------
// Tool-schema adapters
// ---------------------------------------------------------------------------
// Callers hand us OpenAI-style tool definitions. Provider-specific converters
// live here so the rest of the code never has to think about it.
export type ClaudeTool = {
name: string;
description: string;
input_schema: Record<string, unknown>;
};
export function toClaudeTools(tools: OpenAIToolSchema[]): ClaudeTool[] {
return tools.map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: normalizeSchema(t.function.parameters),
}));
}
export type GeminiFunctionDeclaration = {
name: string;
description: string;
parameters?: Record<string, unknown>;
};
export function toGeminiTools(tools: OpenAIToolSchema[]): GeminiFunctionDeclaration[] {
return tools.map((t) => {
const params = normalizeSchema(t.function.parameters);
// Gemini rejects `{ type: "object", properties: {} }` with no fields
// present; omit the parameters key entirely when empty.
const hasProps =
params &&
typeof params === "object" &&
Object.keys((params as { properties?: Record<string, unknown> }).properties ?? {}).length > 0;
return {
name: t.function.name,
description: t.function.description,
...(hasProps ? { parameters: params } : {}),
};
});
}
// ---------------------------------------------------------------------------
// Schema normalization
// ---------------------------------------------------------------------------
// The OpenAI tool schemas in the codebase already use plain JSON-Schema-lite
// shape. Both Claude and Gemini accept that shape. We only sanitise a couple
// of edge cases: `integer` is accepted by both, but we make sure arrays have
// `items` and objects have `properties` so Gemini doesn't error.
function normalizeSchema(schema: unknown): Record<string, unknown> {
if (!schema || typeof schema !== "object") {
return { type: "object", properties: {} };
}
const s = schema as Record<string, unknown>;
const type = s.type;
const out: Record<string, unknown> = { ...s };
if (type === "object") {
const props = (s.properties as Record<string, unknown>) ?? {};
const normProps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) {
normProps[k] = normalizeSchema(v);
}
out.properties = normProps;
}
if (type === "array" && s.items) {
out.items = normalizeSchema(s.items);
}
return out;
}

View file

@ -0,0 +1,64 @@
// Shared types for the LLM provider adapter.
// Callers always speak OpenAI-style tools + { role, content } messages; each
// provider translates internally.
export type Provider = "claude" | "gemini";
export type OpenAIToolSchema = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type LlmMessage = {
role: "user" | "assistant";
content: string;
};
export type NormalizedToolCall = {
id: string;
name: string;
input: Record<string, unknown>;
};
export type NormalizedToolResult = {
tool_use_id: string;
content: string;
};
export type StreamCallbacks = {
onReasoningDelta?: (text: string) => void;
onReasoningBlockEnd?: () => void;
onContentDelta?: (text: string) => void;
onToolCallStart?: (call: NormalizedToolCall) => void;
};
export type UserApiKeys = {
claude?: string | null;
gemini?: string | null;
};
export type StreamChatParams = {
model: string;
systemPrompt: string;
messages: LlmMessage[];
tools?: OpenAIToolSchema[];
maxIterations?: number;
callbacks?: StreamCallbacks;
runTools?: (calls: NormalizedToolCall[]) => Promise<NormalizedToolResult[]>;
apiKeys?: UserApiKeys;
/**
* Enable provider-side reasoning/thinking. Off by default should only
* be turned on for interactive chat surfaces where the user actually
* benefits from seeing the thought stream. Bulk extraction jobs and
* one-shot completions should leave this off to save tokens and latency.
*/
enableThinking?: boolean;
};
export type StreamChatResult = {
fullText: string;
};

185
backend/src/lib/storage.ts Normal file
View file

@ -0,0 +1,185 @@
/**
* Cloudflare R2 storage utilities for Mike document management.
* R2 is S3-compatible uses @aws-sdk/client-s3.
*
* Required env vars:
* R2_ENDPOINT_URL https://<account-id>.r2.cloudflarestorage.com
* R2_ACCESS_KEY_ID R2 API token (Access Key ID)
* R2_SECRET_ACCESS_KEY R2 API token (Secret Access Key)
* R2_BUCKET_NAME bucket name (default: "mike")
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
function getClient(): S3Client {
return new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT_URL!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
export const storageEnabled = Boolean(
process.env.R2_ENDPOINT_URL &&
process.env.R2_ACCESS_KEY_ID &&
process.env.R2_SECRET_ACCESS_KEY,
);
// ---------------------------------------------------------------------------
// Upload
// ---------------------------------------------------------------------------
export async function uploadFile(
key: string,
content: ArrayBuffer,
contentType: string,
): Promise<void> {
const client = getClient();
await client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(content),
ContentType: contentType,
}),
);
}
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
const response = await client.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
);
if (!response.Body) return null;
const bytes = await response.Body.transformToByteArray();
return bytes.buffer as ArrayBuffer;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
export async function deleteFile(key: string): Promise<void> {
if (!storageEnabled) return;
const client = getClient();
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
// ---------------------------------------------------------------------------
// Signed URL (pre-signed for temporary direct access)
// ---------------------------------------------------------------------------
export async function getSignedUrl(
key: string,
expiresIn = 3600,
downloadFilename?: string,
): Promise<string | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
// Override the response Content-Disposition so the browser uses this
// filename on download, instead of the last path segment of the R2 key
// (which includes the document UUID). The `download` attribute on <a>
// is ignored for cross-origin URLs, so we have to set it server-side.
const responseContentDisposition = downloadFilename
? buildContentDisposition("attachment", downloadFilename)
: undefined;
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: key,
ResponseContentDisposition: responseContentDisposition,
});
return await awsGetSignedUrl(client, command, { expiresIn });
} catch {
return null;
}
}
export function normalizeDownloadFilename(name: string): string {
const trimmed = name.trim();
const base = trimmed || "download";
return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_");
}
export function sanitizeDispositionFilename(name: string): string {
return normalizeDownloadFilename(name).replace(/["\\]/g, "_");
}
export function encodeRFC5987(str: string): string {
return encodeURIComponent(str).replace(
/['()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
export function buildContentDisposition(
kind: "inline" | "attachment",
filename: string,
): string {
const normalized = normalizeDownloadFilename(filename);
return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`;
}
// ---------------------------------------------------------------------------
// Storage key helpers
// ---------------------------------------------------------------------------
export function storageKey(
userId: string,
docId: string,
filename: string,
): string {
return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`;
}
export function pdfStorageKey(
userId: string,
docId: string,
stem: string,
): string {
return `documents/${userId}/${docId}/${stem}.pdf`;
}
export function generatedDocKey(
userId: string,
docId: string,
filename: string,
): string {
return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`;
}
export function versionStorageKey(
userId: string,
docId: string,
versionSlug: string,
filename: string,
): string {
return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`;
}
function storageExtension(filename: string, fallback: string): string {
const lastDot = filename.lastIndexOf(".");
if (lastDot < 0) return fallback;
const ext = filename.slice(lastDot).toLowerCase();
return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback;
}

View file

@ -0,0 +1,41 @@
import { createClient } from "@supabase/supabase-js";
/**
* Server-side Supabase client using the service role key.
* Bypasses RLS only use in API routes after verifying the user.
*/
export function createServerSupabase() {
const url = process.env.SUPABASE_URL || "";
const key = process.env.SUPABASE_SECRET_KEY || "";
return createClient(url, key, { auth: { persistSession: false } });
}
/**
* Extract and verify the Supabase JWT from the Authorization header.
* Returns the user's UUID string, or throws a Response with 401.
*/
export async function getUserIdFromRequest(req: Request): Promise<string> {
const auth = req.headers.get("authorization") ?? "";
if (!auth.startsWith("Bearer ")) {
throw new Response("Missing or invalid Authorization header", {
status: 401,
});
}
const token = auth.slice(7).trim();
const supabaseUrl = process.env.SUPABASE_URL || "";
const serviceKey = process.env.SUPABASE_SECRET_KEY || "";
if (!supabaseUrl || !serviceKey) {
throw new Response("Server auth is not configured", { status: 500 });
}
const admin = createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false },
});
const { data } = await admin.auth.getUser(token);
if (!data.user) {
throw new Response("Invalid or expired token", { status: 401 });
}
return data.user.id;
}

36
backend/src/lib/upload.ts Normal file
View file

@ -0,0 +1,36 @@
import type { RequestHandler } from "express";
import multer from "multer";
export const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024;
export const MAX_UPLOAD_SIZE_MB = Math.round(
MAX_UPLOAD_SIZE_BYTES / (1024 * 1024),
);
const memoryUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_SIZE_BYTES,
files: 1,
},
});
export function singleFileUpload(fieldName: string): RequestHandler {
return (req, res, next) => {
memoryUpload.single(fieldName)(req, res, (err) => {
if (!err) return next();
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return void res.status(413).json({
detail: `File too large. Maximum size is ${MAX_UPLOAD_SIZE_MB} MB.`,
});
}
return void res.status(400).json({
detail: `Upload failed: ${err.message}`,
});
}
return next(err);
});
};
}

View file

@ -0,0 +1,62 @@
import { createServerSupabase } from "./supabase";
import {
resolveModel,
DEFAULT_TITLE_MODEL,
DEFAULT_TABULAR_MODEL,
type UserApiKeys,
} from "./llm";
export type UserModelSettings = {
title_model: string;
tabular_model: string;
api_keys: UserApiKeys;
};
// Title generation is a lightweight task — always routed to the cheapest model
// of whichever provider the user has keys for: Gemini Flash Lite if Gemini is
// available, otherwise Claude Haiku. With no user keys set, defaults to Gemini
// (the dev-mode env fallback).
function resolveTitleModel(apiKeys: UserApiKeys): string {
if (apiKeys.gemini?.trim()) return DEFAULT_TITLE_MODEL;
if (apiKeys.claude?.trim()) return "claude-haiku-4-5";
return DEFAULT_TITLE_MODEL;
}
export async function getUserModelSettings(
userId: string,
db?: ReturnType<typeof createServerSupabase>,
): Promise<UserModelSettings> {
const client = db ?? createServerSupabase();
const { data } = await client
.from("user_profiles")
.select("tabular_model, claude_api_key, gemini_api_key")
.eq("user_id", userId)
.single();
const api_keys: UserApiKeys = {
claude: data?.claude_api_key ?? null,
gemini: data?.gemini_api_key ?? null,
};
return {
title_model: resolveTitleModel(api_keys),
tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL),
api_keys,
};
}
export async function getUserApiKeys(
userId: string,
db?: ReturnType<typeof createServerSupabase>,
): Promise<UserApiKeys> {
const client = db ?? createServerSupabase();
const { data } = await client
.from("user_profiles")
.select("claude_api_key, gemini_api_key")
.eq("user_id", userId)
.single();
return {
claude: data?.claude_api_key ?? null,
gemini: data?.gemini_api_key ?? null,
};
}

View file

@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from "express";
import { createClient } from "@supabase/supabase-js";
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const auth = req.headers.authorization ?? "";
if (!auth.startsWith("Bearer ")) {
res.status(401).json({ detail: "Missing or invalid Authorization header" });
return;
}
const token = auth.slice(7).trim();
const supabaseUrl = process.env.SUPABASE_URL ?? "";
const serviceKey = process.env.SUPABASE_SECRET_KEY ?? "";
if (!supabaseUrl || !serviceKey) {
res.status(500).json({ detail: "Server auth is not configured" });
return;
}
const admin = createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false },
});
const { data } = await admin.auth.getUser(token);
if (!data.user) {
res.status(401).json({ detail: "Invalid or expired token" });
return;
}
res.locals.userId = data.user.id;
res.locals.userEmail = data.user.email?.toLowerCase() ?? "";
res.locals.token = token;
next();
}

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();
});