From f130c45ca804190f613f75ba85f84ae7026b185a Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 1 May 2026 21:00:36 +0200 Subject: [PATCH] feat(dataroom): bilingual descriptions, drag-drop multi-file upload, edit existing upload descriptions - lib/translate.ts: LiteLLM DE<>EN translation utility - Migration 006: description_de/description_en on both dataroom tables - Admin + investor upload APIs: accept description+lang, auto-translate the other language on save - PATCH /api/admin/dataroom/documents/[id]: description path in addition to display_name path - PATCH /api/dataroom/uploads/[id]: investor can edit their own upload descriptions - PATCH /api/admin/dataroom/investors/[id]/uploads: admin can edit investor upload descriptions - All GET queries updated to return description fields - Admin dataroom: drop zone replaces upload button, multi-file, inline description editor per doc and per investor upload - Investor dataroom: drop zone, multi-file, description+lang textarea before upload, inline description editing on existing uploads Co-Authored-By: Claude Sonnet 4.6 --- .../admin/dataroom/documents/[id]/route.ts | 20 +- .../app/api/admin/dataroom/documents/route.ts | 51 ++- .../dataroom/investors/[id]/uploads/route.ts | 25 +- pitch-deck/app/api/admin/migrate/route.ts | 5 + .../app/api/dataroom/documents/route.ts | 5 +- .../app/api/dataroom/uploads/[id]/route.ts | 28 ++ pitch-deck/app/api/dataroom/uploads/route.ts | 53 +++- pitch-deck/app/dataroom/page.tsx | 214 ++++++++----- .../pitch-admin/(authed)/dataroom/page.tsx | 290 ++++++++++++------ pitch-deck/lib/translate.ts | 28 ++ 10 files changed, 521 insertions(+), 198 deletions(-) create mode 100644 pitch-deck/app/api/dataroom/uploads/[id]/route.ts create mode 100644 pitch-deck/lib/translate.ts diff --git a/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts index 46a50a7..971e255 100644 --- a/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts +++ b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts @@ -3,6 +3,7 @@ import pool from '@/lib/db' import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth' import { adminDocDir, removeDir } from '@/lib/dataroom-storage' import { logAudit } from '@/lib/auth' +import { translateText } from '@/lib/translate' interface Ctx { params: Promise<{ id: string }> } @@ -11,8 +12,25 @@ export async function PATCH(request: NextRequest, ctx: Ctx) { if (guard.kind === 'response') return guard.response const { id } = await ctx.params - const { display_name } = await request.json() + const body = await request.json() + if ('description' in body) { + const { description, description_lang } = body as { description: string | null; description_lang: 'de' | 'en' } + const lang = description_lang || 'en' + const translated = description ? await translateText(description, lang) : null + const desc_de = lang === 'de' ? (description || null) : translated + const desc_en = lang === 'en' ? (description || null) : translated + + const { rows } = await pool.query( + `UPDATE dataroom_documents SET description_de = $1, description_en = $2, updated_at = NOW() + WHERE id = $3 RETURNING *`, + [desc_de, desc_en, id], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ document: rows[0] }) + } + + const { display_name } = body const { rows } = await pool.query( `UPDATE dataroom_documents SET display_name = $1, updated_at = NOW() WHERE id = $2 RETURNING *`, diff --git a/pitch-deck/app/api/admin/dataroom/documents/route.ts b/pitch-deck/app/api/admin/dataroom/documents/route.ts index 3c548ed..d01b78f 100644 --- a/pitch-deck/app/api/admin/dataroom/documents/route.ts +++ b/pitch-deck/app/api/admin/dataroom/documents/route.ts @@ -3,6 +3,7 @@ import pool from '@/lib/db' import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth' import { adminDocDir, saveFile, safeName } from '@/lib/dataroom-storage' import { logAudit } from '@/lib/auth' +import { translateText } from '@/lib/translate' import { randomUUID } from 'crypto' export const dynamic = 'force-dynamic' @@ -12,7 +13,8 @@ export async function GET(request: NextRequest) { if (guard.kind === 'response') return guard.response const { rows } = await pool.query( - `SELECT d.id, d.filename, d.display_name, d.mime_type, d.file_size, d.uploaded_by, d.created_at, + `SELECT d.id, d.filename, d.display_name, d.description_de, d.description_en, + d.mime_type, d.file_size, d.uploaded_by, d.created_at, COUNT(r.id)::int AS release_count FROM dataroom_documents d LEFT JOIN dataroom_releases r ON r.document_id = d.id @@ -28,25 +30,40 @@ export async function POST(request: NextRequest) { const admin = await getAdminFromCookie() const formData = await request.formData() - const file = formData.get('file') as File | null - const displayName = (formData.get('display_name') as string | null) || null - if (!file || file.size === 0) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + const files = formData.getAll('file') as File[] + const validFiles = files.filter(f => f && f.size > 0) + if (validFiles.length === 0) return NextResponse.json({ error: 'No files provided' }, { status: 400 }) + + const description = (formData.get('description') as string | null) || null + const descLang = (formData.get('description_lang') as 'de' | 'en' | null) || 'en' + + let desc_de: string | null = null + let desc_en: string | null = null + if (description) { + const translated = await translateText(description, descLang) + desc_de = descLang === 'de' ? description : (translated || null) + desc_en = descLang === 'en' ? description : (translated || null) } - const documentId = randomUUID() - const filename = safeName(file.name) - const buffer = Buffer.from(await file.arrayBuffer()) - const filePath = await saveFile(adminDocDir(documentId), filename, buffer) + const inserted = [] + for (const file of validFiles) { + const documentId = randomUUID() + const filename = safeName(file.name) + const buffer = Buffer.from(await file.arrayBuffer()) + const filePath = await saveFile(adminDocDir(documentId), filename, buffer) - const { rows } = await pool.query( - `INSERT INTO dataroom_documents (id, filename, file_path, display_name, mime_type, file_size, uploaded_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [documentId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size, admin?.email ?? 'admin'], - ) + const { rows } = await pool.query( + `INSERT INTO dataroom_documents + (id, filename, file_path, display_name, description_de, description_en, mime_type, file_size, uploaded_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [documentId, filename, filePath, file.name, desc_de, desc_en, + file.type || 'application/octet-stream', file.size, admin?.email ?? 'admin'], + ) + await logAudit(null, 'dataroom_document_uploaded', { document_id: documentId, filename, file_size: file.size }, request, undefined, undefined, admin?.id) + inserted.push(rows[0]) + } - await logAudit(null, 'dataroom_document_uploaded', { document_id: documentId, filename, file_size: file.size }, request, undefined, undefined, admin?.id) - return NextResponse.json({ document: rows[0] }, { status: 201 }) + return NextResponse.json({ documents: inserted }, { status: 201 }) } diff --git a/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts b/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts index e2e8df9..6584d20 100644 --- a/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts +++ b/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin } from '@/lib/admin-auth' import { streamFile } from '@/lib/dataroom-storage' +import { translateText } from '@/lib/translate' import path from 'path' interface Ctx { params: Promise<{ id: string }> } @@ -31,7 +32,7 @@ export async function GET(request: NextRequest, ctx: Ctx) { } const { rows } = await pool.query( - `SELECT id, filename, display_name, mime_type, file_size, created_at + `SELECT id, filename, display_name, description_de, description_en, mime_type, file_size, created_at FROM dataroom_investor_uploads WHERE investor_id = $1 ORDER BY created_at DESC`, @@ -39,3 +40,25 @@ export async function GET(request: NextRequest, ctx: Ctx) { ) return NextResponse.json({ uploads: rows }) } + +export async function PATCH(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id: investorId } = await ctx.params + const { upload_id, description, description_lang } = await request.json() + const lang: 'de' | 'en' = description_lang || 'en' + + const translated = description ? await translateText(description, lang) : null + const desc_de = lang === 'de' ? (description || null) : translated + const desc_en = lang === 'en' ? (description || null) : translated + + const { rows } = await pool.query( + `UPDATE dataroom_investor_uploads SET description_de = $1, description_en = $2 + WHERE id = $3 AND investor_id = $4 + RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`, + [desc_de, desc_en, upload_id, investorId], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ upload: rows[0] }) +} diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index 73e6c94..8299282 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -155,6 +155,11 @@ export async function POST(request: NextRequest) { `CREATE INDEX IF NOT EXISTS idx_dataroom_releases_investor ON dataroom_releases(investor_id)`, `CREATE INDEX IF NOT EXISTS idx_dataroom_releases_document ON dataroom_releases(document_id)`, `CREATE INDEX IF NOT EXISTS idx_dataroom_uploads_investor ON dataroom_investor_uploads(investor_id)`, + // 006 — dataroom bilingual descriptions + `ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_de TEXT`, + `ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_en TEXT`, + `ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_de TEXT`, + `ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`, ] for (const sql of statements) { diff --git a/pitch-deck/app/api/dataroom/documents/route.ts b/pitch-deck/app/api/dataroom/documents/route.ts index 70d7aa4..63c0f76 100644 --- a/pitch-deck/app/api/dataroom/documents/route.ts +++ b/pitch-deck/app/api/dataroom/documents/route.ts @@ -4,13 +4,14 @@ import { getSessionFromCookie } from '@/lib/auth' export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function GET(_request: NextRequest) { const session = await getSessionFromCookie() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const investorId = session.sub const { rows } = await pool.query( - `SELECT d.id, d.filename, d.display_name, d.mime_type, d.file_size, r.released_at + `SELECT d.id, d.filename, d.display_name, d.description_de, d.description_en, + d.mime_type, d.file_size, r.released_at FROM dataroom_releases r JOIN dataroom_documents d ON d.id = r.document_id WHERE r.investor_id = $1 diff --git a/pitch-deck/app/api/dataroom/uploads/[id]/route.ts b/pitch-deck/app/api/dataroom/uploads/[id]/route.ts new file mode 100644 index 0000000..f045314 --- /dev/null +++ b/pitch-deck/app/api/dataroom/uploads/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' +import { translateText } from '@/lib/translate' + +interface Ctx { params: Promise<{ id: string }> } + +export async function PATCH(request: NextRequest, ctx: Ctx) { + const session = await getSessionFromCookie() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { id } = await ctx.params + const { description, description_lang } = await request.json() + const lang: 'de' | 'en' = description_lang || 'en' + + const translated = description ? await translateText(description, lang) : null + const desc_de = lang === 'de' ? (description || null) : translated + const desc_en = lang === 'en' ? (description || null) : translated + + const { rows } = await pool.query( + `UPDATE dataroom_investor_uploads SET description_de = $1, description_en = $2 + WHERE id = $3 AND investor_id = $4 + RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`, + [desc_de, desc_en, id, session.sub], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ upload: rows[0] }) +} diff --git a/pitch-deck/app/api/dataroom/uploads/route.ts b/pitch-deck/app/api/dataroom/uploads/route.ts index 12fc132..1f09eac 100644 --- a/pitch-deck/app/api/dataroom/uploads/route.ts +++ b/pitch-deck/app/api/dataroom/uploads/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { investorUploadDir, saveFile, safeName } from '@/lib/dataroom-storage' import { logAudit, getSessionFromCookie } from '@/lib/auth' +import { translateText } from '@/lib/translate' import { randomUUID } from 'crypto' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ export async function GET(_request: NextRequest) { const investorId = session.sub const { rows } = await pool.query( - `SELECT id, filename, display_name, mime_type, file_size, created_at + `SELECT id, filename, display_name, description_de, description_en, mime_type, file_size, created_at FROM dataroom_investor_uploads WHERE investor_id = $1 ORDER BY created_at DESC`, @@ -30,24 +31,42 @@ export async function POST(request: NextRequest) { const sessionId = session.sessionId const formData = await request.formData() - const file = formData.get('file') as File | null - const displayName = (formData.get('display_name') as string | null) || null + const files = formData.getAll('file') as File[] + const validFiles = files.filter(f => f && f.size > 0) + if (validFiles.length === 0) return NextResponse.json({ error: 'No files provided' }, { status: 400 }) - if (!file || file.size === 0) return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - if (file.size > MAX_BYTES) return NextResponse.json({ error: `File exceeds ${process.env.DATAROOM_MAX_UPLOAD_MB || 50}MB limit` }, { status: 413 }) + const oversized = validFiles.find(f => f.size > MAX_BYTES) + if (oversized) return NextResponse.json({ error: `File "${oversized.name}" exceeds ${process.env.DATAROOM_MAX_UPLOAD_MB || 50}MB limit` }, { status: 413 }) - const uploadId = randomUUID() - const filename = safeName(file.name) - const buffer = Buffer.from(await file.arrayBuffer()) - const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer) + const description = (formData.get('description') as string | null) || null + const descLang = (formData.get('description_lang') as 'de' | 'en' | null) || 'en' - const { rows } = await pool.query( - `INSERT INTO dataroom_investor_uploads (id, investor_id, filename, file_path, display_name, mime_type, file_size) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, filename, display_name, mime_type, file_size, created_at`, - [uploadId, investorId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size], - ) + let desc_de: string | null = null + let desc_en: string | null = null + if (description) { + const translated = await translateText(description, descLang) + desc_de = descLang === 'de' ? description : (translated || null) + desc_en = descLang === 'en' ? description : (translated || null) + } - await logAudit(investorId, 'dataroom_investor_uploaded', { upload_id: uploadId, filename, file_size: file.size }, request, undefined, sessionId ?? undefined) - return NextResponse.json({ upload: rows[0] }, { status: 201 }) + const inserted = [] + for (const file of validFiles) { + const uploadId = randomUUID() + const filename = safeName(file.name) + const buffer = Buffer.from(await file.arrayBuffer()) + const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer) + + const { rows } = await pool.query( + `INSERT INTO dataroom_investor_uploads + (id, investor_id, filename, file_path, display_name, description_de, description_en, mime_type, file_size) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`, + [uploadId, investorId, filename, filePath, file.name, desc_de, desc_en, + file.type || 'application/octet-stream', file.size], + ) + await logAudit(investorId, 'dataroom_investor_uploaded', { upload_id: uploadId, filename, file_size: file.size }, request, undefined, sessionId) + inserted.push(rows[0]) + } + + return NextResponse.json({ uploads: inserted }, { status: 201 }) } diff --git a/pitch-deck/app/dataroom/page.tsx b/pitch-deck/app/dataroom/page.tsx index 991c15e..6776a11 100644 --- a/pitch-deck/app/dataroom/page.tsx +++ b/pitch-deck/app/dataroom/page.tsx @@ -1,12 +1,14 @@ 'use client' -import { useEffect, useState, useRef } from 'react' -import { FileText, Download, Upload, Eye, LogOut } from 'lucide-react' +import { useEffect, useState, useRef, useCallback } from 'react' +import { FileText, Download, Upload, Eye, LogOut, Pencil, Globe } from 'lucide-react' interface Doc { id: string filename: string display_name: string | null + description_de: string | null + description_en: string | null mime_type: string file_size: number released_at: string @@ -16,6 +18,8 @@ interface MyUpload { id: string filename: string display_name: string | null + description_de: string | null + description_en: string | null mime_type: string file_size: number created_at: string @@ -27,57 +31,83 @@ function fmt(bytes: number) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } -function isPDF(mime: string) { - return mime === 'application/pdf' +function isPDF(mime: string) { return mime === 'application/pdf' } + +function LangToggle({ lang, onChange }: { lang: 'de' | 'en'; onChange: (l: 'de' | 'en') => void }) { + return ( +
+ {(['de', 'en'] as const).map(l => ( + + ))} +
+ ) } export default function DataroomPage() { const [docs, setDocs] = useState([]) const [uploads, setUploads] = useState([]) + const [dragging, setDragging] = useState(false) const [uploading, setUploading] = useState(false) const [toast, setToast] = useState(null) + const [description, setDescription] = useState('') + const [descLang, setDescLang] = useState<'de' | 'en'>('en') + const [editingUpload, setEditingUpload] = useState<{ id: string; text: string; lang: 'de' | 'en' } | null>(null) const fileRef = useRef(null) - function flash(msg: string) { - setToast(msg) - setTimeout(() => setToast(null), 3500) - } + function flash(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3500) } async function loadAll() { - const [dr, ur] = await Promise.all([ - fetch('/api/dataroom/documents'), - fetch('/api/dataroom/uploads'), - ]) + const [dr, ur] = await Promise.all([fetch('/api/dataroom/documents'), fetch('/api/dataroom/uploads')]) if (dr.ok) setDocs((await dr.json()).documents) if (ur.ok) setUploads((await ur.json()).uploads) } useEffect(() => { loadAll() }, []) - async function handleUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return + async function uploadFiles(files: FileList | File[]) { + const list = Array.from(files).filter(f => f.size > 0) + if (!list.length) return setUploading(true) const fd = new FormData() - fd.append('file', file) + list.forEach(f => fd.append('file', f)) + if (description.trim()) { fd.append('description', description.trim()); fd.append('description_lang', descLang) } const r = await fetch('/api/dataroom/uploads', { method: 'POST', body: fd }) setUploading(false) - if (r.ok) { flash('File uploaded successfully'); loadAll() } - else { + if (r.ok) { + flash(`${list.length} file${list.length > 1 ? 's' : ''} uploaded`) + setDescription(''); loadAll() + } else { const d = await r.json().catch(() => ({})) flash(d.error || 'Upload failed') } - e.target.value = '' + } + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); setDragging(false) + if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) + }, [description, descLang]) + + async function saveEditDescription() { + if (!editingUpload) return + const r = await fetch(`/api/dataroom/uploads/${editingUpload.id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: editingUpload.text || null, description_lang: editingUpload.lang }), + }) + if (r.ok) { + const updated = (await r.json()).upload as MyUpload + setUploads(prev => prev.map(u => u.id === updated.id ? updated : u)) + setEditingUpload(null); flash('Description saved & translated') + } else flash('Save failed') } return (
- {/* Header */}
-

- Data Room -

+

Data Room

BreakPilot ComplAI · Investor Portal

@@ -101,32 +131,30 @@ export default function DataroomPage() { ) : (
{docs.map(doc => ( -
-
+
+
-
{doc.display_name || doc.filename}
-
- {fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()} -
+
{doc.display_name || doc.filename}
+
{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()}
+ {(doc.description_en || doc.description_de) && ( +
+ {doc.description_en &&

{doc.description_en}

} + {doc.description_de && !doc.description_en &&

{doc.description_de}

} + {doc.description_de && doc.description_en &&

{doc.description_de}

} +
+ )}
{isPDF(doc.mime_type) && ( - + Preview )} - + Download
@@ -138,40 +166,88 @@ export default function DataroomPage() { {/* Upload section */}
-
-

Your Documents

- - -
- +

Your Documents

Upload documents you want to share with us — NDAs, term sheets, financial statements, or any other relevant files.

- {uploads.length === 0 ? ( -
- -

No files uploaded yet.

-
- ) : ( -
- {uploads.map(u => ( -
- -
-
{u.display_name || u.filename}
-
{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}
+ {/* Description field */} +
+