From 9888b1b5d7fb313c2e39881c784a2989b853e51e Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 1 May 2026 15:38:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(pitch-deck):=20data=20room=20=E2=80=94=20f?= =?UTF-8?q?ile=20sharing=20and=20investor=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/dataroom-storage.ts: local volume storage (DATAROOM_PATH env var, default /data/dataroom) replacing NextCloud WebDAV - Admin API: upload documents, rename, delete, manage per-investor releases - Investor API: list released documents, stream download with audit log, upload own documents (max DATAROOM_MAX_UPLOAD_MB, default 50MB) - /pitch-admin/dataroom: document list + release toggles + investor uploads tab - /dataroom: investor-facing document library + upload section - All reads and writes logged to pitch_audit_logs - Migration 005: dataroom_documents, dataroom_releases, dataroom_investor_uploads - AdminShell: Data Room nav link (FolderOpen icon) Co-Authored-By: Claude Sonnet 4.6 --- .../[id]/release/[investorId]/route.ts | 22 ++ .../dataroom/documents/[id]/release/route.ts | 52 +++ .../admin/dataroom/documents/[id]/route.ts | 40 ++ .../app/api/admin/dataroom/documents/route.ts | 52 +++ .../dataroom/investors/[id]/uploads/route.ts | 41 +++ pitch-deck/app/api/admin/migrate/route.ts | 33 ++ .../dataroom/documents/[id]/download/route.ts | 40 ++ .../app/api/dataroom/documents/route.ts | 19 + pitch-deck/app/api/dataroom/uploads/route.ts | 51 +++ pitch-deck/app/dataroom/page.tsx | 187 ++++++++++ .../pitch-admin/(authed)/dataroom/page.tsx | 343 ++++++++++++++++++ .../components/pitch-admin/AdminShell.tsx | 2 + pitch-deck/lib/dataroom-storage.ts | 48 +++ 13 files changed, 930 insertions(+) create mode 100644 pitch-deck/app/api/admin/dataroom/documents/[id]/release/[investorId]/route.ts create mode 100644 pitch-deck/app/api/admin/dataroom/documents/[id]/release/route.ts create mode 100644 pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts create mode 100644 pitch-deck/app/api/admin/dataroom/documents/route.ts create mode 100644 pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts create mode 100644 pitch-deck/app/api/dataroom/documents/[id]/download/route.ts create mode 100644 pitch-deck/app/api/dataroom/documents/route.ts create mode 100644 pitch-deck/app/api/dataroom/uploads/route.ts create mode 100644 pitch-deck/app/dataroom/page.tsx create mode 100644 pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx create mode 100644 pitch-deck/lib/dataroom-storage.ts diff --git a/pitch-deck/app/api/admin/dataroom/documents/[id]/release/[investorId]/route.ts b/pitch-deck/app/api/admin/dataroom/documents/[id]/release/[investorId]/route.ts new file mode 100644 index 0000000..49280ff --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/documents/[id]/release/[investorId]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth' +import { logAudit } from '@/lib/auth' + +interface Ctx { params: Promise<{ id: string; investorId: string }> } + +export async function DELETE(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id, investorId } = await ctx.params + const admin = await getAdminFromCookie() + + await pool.query( + `DELETE FROM dataroom_releases WHERE document_id = $1 AND investor_id = $2`, + [id, investorId], + ) + + await logAudit(null, 'dataroom_release_revoked', { document_id: id, investor_id: investorId }, request, undefined, undefined, admin?.id) + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/admin/dataroom/documents/[id]/release/route.ts b/pitch-deck/app/api/admin/dataroom/documents/[id]/release/route.ts new file mode 100644 index 0000000..f484eed --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/documents/[id]/release/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth' +import { logAudit } from '@/lib/auth' + +interface Ctx { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id } = await ctx.params + const { rows } = await pool.query( + `SELECT r.id, r.investor_id, r.released_by, r.released_at, + i.email, i.name, i.company, i.data_masked_at + FROM dataroom_releases r + JOIN pitch_investors i ON i.id = r.investor_id + WHERE r.document_id = $1 + ORDER BY r.released_at DESC`, + [id], + ) + return NextResponse.json({ releases: rows }) +} + +export async function POST(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id } = await ctx.params + const admin = await getAdminFromCookie() + const { investor_ids } = await request.json() as { investor_ids: string[] } + + if (!Array.isArray(investor_ids) || investor_ids.length === 0) { + return NextResponse.json({ error: 'investor_ids required' }, { status: 400 }) + } + + const inserted: string[] = [] + for (const investorId of investor_ids) { + const { rowCount } = await pool.query( + `INSERT INTO dataroom_releases (document_id, investor_id, released_by) + VALUES ($1, $2, $3) + ON CONFLICT (document_id, investor_id) DO NOTHING`, + [id, investorId, admin?.email ?? 'admin'], + ) + if (rowCount) inserted.push(investorId) + } + + if (inserted.length > 0) { + await logAudit(null, 'dataroom_document_released', { document_id: id, investor_ids: inserted }, request, undefined, undefined, admin?.id) + } + return NextResponse.json({ released: inserted.length }) +} diff --git a/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts new file mode 100644 index 0000000..46a50a7 --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth' +import { adminDocDir, removeDir } from '@/lib/dataroom-storage' +import { logAudit } from '@/lib/auth' + +interface Ctx { params: Promise<{ id: string }> } + +export async function PATCH(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id } = await ctx.params + const { display_name } = await request.json() + + const { rows } = await pool.query( + `UPDATE dataroom_documents SET display_name = $1, updated_at = NOW() + WHERE id = $2 RETURNING *`, + [display_name, id], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ document: rows[0] }) +} + +export async function DELETE(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id } = await ctx.params + const admin = await getAdminFromCookie() + + const { rows } = await pool.query(`SELECT * FROM dataroom_documents WHERE id = $1`, [id]) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + await pool.query(`DELETE FROM dataroom_documents WHERE id = $1`, [id]) + await removeDir(adminDocDir(id)) + + await logAudit(null, 'dataroom_document_deleted', { document_id: id, filename: rows[0].filename }, request, undefined, undefined, admin?.id) + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/admin/dataroom/documents/route.ts b/pitch-deck/app/api/admin/dataroom/documents/route.ts new file mode 100644 index 0000000..3c548ed --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/documents/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +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 { randomUUID } from 'crypto' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + const guard = await requireAdmin(request) + 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, + COUNT(r.id)::int AS release_count + FROM dataroom_documents d + LEFT JOIN dataroom_releases r ON r.document_id = d.id + GROUP BY d.id + ORDER BY d.created_at DESC`, + ) + return NextResponse.json({ documents: rows }) +} + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + 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 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'], + ) + + 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 }) +} 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 new file mode 100644 index 0000000..e2e8df9 --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin } from '@/lib/admin-auth' +import { streamFile } from '@/lib/dataroom-storage' +import path from 'path' + +interface Ctx { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id: investorId } = await ctx.params + const download = request.nextUrl.searchParams.get('download') + + if (download) { + const { rows } = await pool.query( + `SELECT file_path, filename, mime_type FROM dataroom_investor_uploads + WHERE id = $1 AND investor_id = $2`, + [download, investorId], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const { stream, size } = await streamFile(rows[0].file_path) + return new Response(stream, { + headers: { + 'Content-Type': rows[0].mime_type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${path.basename(rows[0].filename)}"`, + 'Content-Length': String(size), + }, + }) + } + + const { rows } = await pool.query( + `SELECT id, filename, display_name, mime_type, file_size, created_at + FROM dataroom_investor_uploads + WHERE investor_id = $1 + ORDER BY created_at DESC`, + [investorId], + ) + return NextResponse.json({ uploads: rows }) +} diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index b255ad7..73e6c94 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -122,6 +122,39 @@ export async function POST(request: NextRequest) { `CREATE INDEX IF NOT EXISTS idx_fp_liquid_scenario ON fp_liquiditaet(scenario_id)`, `CREATE INDEX IF NOT EXISTS idx_fp_guv_scenario ON fp_guv(scenario_id)`, `CREATE INDEX IF NOT EXISTS idx_fp_overrides_lookup ON fp_cell_overrides(scenario_id, sheet_name, row_id)`, + // 005 — data room + `CREATE TABLE IF NOT EXISTS dataroom_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + display_name TEXT, + mime_type TEXT, + file_size BIGINT, + uploaded_by TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS dataroom_releases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID REFERENCES dataroom_documents(id) ON DELETE CASCADE, + investor_id UUID REFERENCES pitch_investors(id) ON DELETE CASCADE, + released_by TEXT NOT NULL, + released_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(document_id, investor_id) + )`, + `CREATE TABLE IF NOT EXISTS dataroom_investor_uploads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + investor_id UUID REFERENCES pitch_investors(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + display_name TEXT, + mime_type TEXT, + file_size BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `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)`, ] for (const sql of statements) { diff --git a/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts b/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts new file mode 100644 index 0000000..166c2a8 --- /dev/null +++ b/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { streamFile } from '@/lib/dataroom-storage' +import { logAudit } from '@/lib/auth' +import path from 'path' + +interface Ctx { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, ctx: Ctx) { + const investorId = request.headers.get('x-investor-id') + const sessionId = request.headers.get('x-session-id') + if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { id } = await ctx.params + + // Verify investor has a release for this document + const { rows } = await pool.query( + `SELECT d.file_path, d.filename, d.mime_type, d.display_name + FROM dataroom_releases r + JOIN dataroom_documents d ON d.id = r.document_id + WHERE r.investor_id = $1 AND d.id = $2`, + [investorId, id], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const doc = rows[0] + + await logAudit(investorId, 'dataroom_document_downloaded', { document_id: id, filename: doc.filename }, request, undefined, sessionId ?? undefined) + + const { stream, size } = await streamFile(doc.file_path) + const disposition = request.nextUrl.searchParams.get('preview') === '1' ? 'inline' : 'attachment' + + return new Response(stream, { + headers: { + 'Content-Type': doc.mime_type || 'application/octet-stream', + 'Content-Disposition': `${disposition}; filename="${path.basename(doc.filename)}"`, + 'Content-Length': String(size), + }, + }) +} diff --git a/pitch-deck/app/api/dataroom/documents/route.ts b/pitch-deck/app/api/dataroom/documents/route.ts new file mode 100644 index 0000000..7777543 --- /dev/null +++ b/pitch-deck/app/api/dataroom/documents/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + const investorId = request.headers.get('x-investor-id') + if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { rows } = await pool.query( + `SELECT d.id, d.filename, d.display_name, 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 + ORDER BY r.released_at DESC`, + [investorId], + ) + return NextResponse.json({ documents: rows }) +} diff --git a/pitch-deck/app/api/dataroom/uploads/route.ts b/pitch-deck/app/api/dataroom/uploads/route.ts new file mode 100644 index 0000000..3c441e6 --- /dev/null +++ b/pitch-deck/app/api/dataroom/uploads/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { investorUploadDir, saveFile, safeName } from '@/lib/dataroom-storage' +import { logAudit } from '@/lib/auth' +import { randomUUID } from 'crypto' + +export const dynamic = 'force-dynamic' + +const MAX_BYTES = parseInt(process.env.DATAROOM_MAX_UPLOAD_MB || '50') * 1024 * 1024 + +export async function GET(request: NextRequest) { + const investorId = request.headers.get('x-investor-id') + if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { rows } = await pool.query( + `SELECT id, filename, display_name, mime_type, file_size, created_at + FROM dataroom_investor_uploads + WHERE investor_id = $1 + ORDER BY created_at DESC`, + [investorId], + ) + return NextResponse.json({ uploads: rows }) +} + +export async function POST(request: NextRequest) { + const investorId = request.headers.get('x-investor-id') + const sessionId = request.headers.get('x-session-id') + if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + 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 }) + if (file.size > MAX_BYTES) return NextResponse.json({ error: `File 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 { 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], + ) + + 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 }) +} diff --git a/pitch-deck/app/dataroom/page.tsx b/pitch-deck/app/dataroom/page.tsx new file mode 100644 index 0000000..991c15e --- /dev/null +++ b/pitch-deck/app/dataroom/page.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { FileText, Download, Upload, Eye, LogOut } from 'lucide-react' + +interface Doc { + id: string + filename: string + display_name: string | null + mime_type: string + file_size: number + released_at: string +} + +interface MyUpload { + id: string + filename: string + display_name: string | null + mime_type: string + file_size: number + created_at: string +} + +function fmt(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function isPDF(mime: string) { + return mime === 'application/pdf' +} + +export default function DataroomPage() { + const [docs, setDocs] = useState([]) + const [uploads, setUploads] = useState([]) + const [uploading, setUploading] = useState(false) + const [toast, setToast] = useState(null) + const fileRef = useRef(null) + + 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'), + ]) + 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 + setUploading(true) + const fd = new FormData() + fd.append('file', file) + const r = await fetch('/api/dataroom/uploads', { method: 'POST', body: fd }) + setUploading(false) + if (r.ok) { flash('File uploaded successfully'); loadAll() } + else { + const d = await r.json().catch(() => ({})) + flash(d.error || 'Upload failed') + } + e.target.value = '' + } + + return ( +
+ {/* Header */} +
+
+

+ Data Room +

+

BreakPilot ComplAI · Investor Portal

+
+ +
+ +
+ + {/* Released documents */} +
+

Documents

+ {docs.length === 0 ? ( +
+ +

No documents have been shared with you yet.

+
+ ) : ( +
+ {docs.map(doc => ( +
+
+ +
+
+
{doc.display_name || doc.filename}
+
+ {fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()} +
+
+
+ {isPDF(doc.mime_type) && ( + + Preview + + )} + + Download + +
+
+ ))} +
+ )} +
+ + {/* Upload section */} +
+
+

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()}
+
+ Received +
+ ))} +
+ )} +
+
+ + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx b/pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx new file mode 100644 index 0000000..c7ec07a --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx @@ -0,0 +1,343 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { Upload, FileText, Trash2, X, Share2, Users, ChevronDown, Check, Download } from 'lucide-react' + +interface Doc { + id: string + filename: string + display_name: string + mime_type: string + file_size: number + uploaded_by: string + created_at: string + release_count: number +} + +interface Release { + id: string + investor_id: string + email: string + name: string | null + company: string | null + released_at: string + data_masked_at: string | null +} + +interface Investor { + id: string + email: string + name: string | null + company: string | null + status: string + data_masked_at: string | null +} + +interface InvestorUpload { + id: string + filename: string + display_name: string + mime_type: string + file_size: number + created_at: string +} + +function fmt(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export default function DataroomPage() { + const [docs, setDocs] = useState([]) + const [investors, setInvestors] = useState([]) + const [selected, setSelected] = useState(null) + const [releases, setReleases] = useState([]) + const [uploading, setUploading] = useState(false) + const [busy, setBusy] = useState(false) + const [toast, setToast] = useState(null) + const [tab, setTab] = useState<'documents' | 'uploads'>('documents') + const [investorUploads, setInvestorUploads] = useState>({}) + const fileRef = useRef(null) + + function flash(msg: string) { + setToast(msg) + setTimeout(() => setToast(null), 3000) + } + + async function loadDocs() { + const r = await fetch('/api/admin/dataroom/documents') + if (r.ok) setDocs((await r.json()).documents) + } + + async function loadInvestors() { + const r = await fetch('/api/admin/investors') + if (r.ok) { + const d = await r.json() + setInvestors((d.investors || []).filter((i: Investor) => !i.data_masked_at && i.status !== 'revoked')) + } + } + + async function loadReleases(docId: string) { + const r = await fetch(`/api/admin/dataroom/documents/${docId}/release`) + if (r.ok) setReleases((await r.json()).releases) + } + + async function loadInvestorUploads() { + const results: Record = {} + for (const inv of investors) { + const r = await fetch(`/api/admin/dataroom/investors/${inv.id}/uploads`) + if (r.ok) results[inv.id] = (await r.json()).uploads + } + setInvestorUploads(results) + } + + useEffect(() => { loadDocs(); loadInvestors() }, []) + useEffect(() => { if (tab === 'uploads' && investors.length > 0) loadInvestorUploads() }, [tab, investors]) + + async function selectDoc(doc: Doc) { + setSelected(doc) + await loadReleases(doc.id) + } + + async function uploadFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + const fd = new FormData() + fd.append('file', file) + const r = await fetch('/api/admin/dataroom/documents', { method: 'POST', body: fd }) + setUploading(false) + if (r.ok) { flash('Uploaded'); loadDocs() } + else { const d = await r.json().catch(() => ({})); flash(d.error || 'Upload failed') } + e.target.value = '' + } + + async function deleteDoc(id: string) { + if (!confirm('Delete this document? All releases will be removed.')) return + setBusy(true) + const r = await fetch(`/api/admin/dataroom/documents/${id}`, { method: 'DELETE' }) + setBusy(false) + if (r.ok) { flash('Deleted'); setSelected(null); loadDocs() } + else flash('Delete failed') + } + + async function toggleRelease(investorId: string, hasRelease: boolean) { + if (!selected) return + setBusy(true) + if (hasRelease) { + await fetch(`/api/admin/dataroom/documents/${selected.id}/release/${investorId}`, { method: 'DELETE' }) + } else { + await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ investor_ids: [investorId] }), + }) + } + setBusy(false) + await loadReleases(selected.id) + loadDocs() + } + + async function releaseAll() { + if (!selected || investors.length === 0) return + setBusy(true) + await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ investor_ids: investors.map(i => i.id) }), + }) + setBusy(false) + await loadReleases(selected.id) + loadDocs() + } + + const releasedIds = new Set(releases.map(r => r.investor_id)) + const allInvestors = investors.filter(i => !i.data_masked_at) + const investorsWithUploads = allInvestors.filter(i => (investorUploads[i.id] || []).length > 0) + + return ( +
+
+

Data Room

+
+ + +
+
+ + {tab === 'documents' && ( +
+ {/* Document list */} +
+
+ {docs.length} document{docs.length !== 1 ? 's' : ''} + + +
+ + {docs.length === 0 && ( +
+ No documents yet. Upload the first one. +
+ )} + + {docs.map(doc => ( + + ))} +
+ + {/* Release panel */} + {selected ? ( +
+
+
+
{selected.display_name || selected.filename}
+
{fmt(selected.file_size)} · {selected.mime_type}
+
+
+ + + +
+
+ +
+
+ + Investor Access +
+ {allInvestors.length === 0 && ( +

No active investors yet.

+ )} +
+ {allInvestors.map(inv => { + const has = releasedIds.has(inv.id) + return ( + + ) + })} +
+
+
+ ) : ( +
+ Select a document to manage releases +
+ )} +
+ )} + + {tab === 'uploads' && ( +
+ {investorsWithUploads.length === 0 && ( +
+ No investor uploads yet. +
+ )} + {allInvestors.map(inv => { + const uploads = investorUploads[inv.id] || [] + if (uploads.length === 0) return null + return ( +
+
+ {inv.name || inv.email} + {inv.company && {inv.company}} + {uploads.length} file{uploads.length !== 1 ? 's' : ''} +
+
+ {uploads.map(u => ( +
+ +
+
{u.display_name || u.filename}
+
{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}
+
+ + Download + +
+ ))} +
+
+ ) + })} +
+ )} + + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/components/pitch-admin/AdminShell.tsx b/pitch-deck/components/pitch-admin/AdminShell.tsx index fbe9f9d..b1c4b49 100644 --- a/pitch-deck/components/pitch-admin/AdminShell.tsx +++ b/pitch-deck/components/pitch-admin/AdminShell.tsx @@ -10,6 +10,7 @@ import { TrendingUp, ShieldCheck, GitBranch, + FolderOpen, LogOut, Menu, X, @@ -26,6 +27,7 @@ const NAV = [ { href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch }, { href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText }, { href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp }, + { href: '/pitch-admin/dataroom', label: 'Data Room', icon: FolderOpen }, { href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck }, ] diff --git a/pitch-deck/lib/dataroom-storage.ts b/pitch-deck/lib/dataroom-storage.ts new file mode 100644 index 0000000..e7a9df8 --- /dev/null +++ b/pitch-deck/lib/dataroom-storage.ts @@ -0,0 +1,48 @@ +import 'server-only' +import fs from 'fs' +import path from 'path' + +function storageRoot(): string { + return process.env.DATAROOM_PATH || '/data/dataroom' +} + +export function adminDocDir(documentId: string): string { + return path.join(storageRoot(), 'admin', documentId) +} + +export function investorUploadDir(investorId: string, uploadId: string): string { + return path.join(storageRoot(), 'investors', investorId, uploadId) +} + +export async function ensureDir(dir: string): Promise { + await fs.promises.mkdir(dir, { recursive: true }) +} + +export async function saveFile(dir: string, filename: string, buffer: Buffer): Promise { + await ensureDir(dir) + const filePath = path.join(dir, filename) + await fs.promises.writeFile(filePath, buffer) + return filePath +} + +export async function removeDir(dir: string): Promise { + await fs.promises.rm(dir, { recursive: true, force: true }) +} + +export async function streamFile(filePath: string): Promise<{ stream: ReadableStream; size: number }> { + const stat = await fs.promises.stat(filePath) + const nodeStream = fs.createReadStream(filePath) + const stream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => controller.enqueue(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + nodeStream.on('end', () => controller.close()) + nodeStream.on('error', (err) => controller.error(err)) + }, + cancel() { nodeStream.destroy() }, + }) + return { stream, size: stat.size } +} + +export function safeName(original: string): string { + return original.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200) +}