feat(pitch-deck): data room — file sharing and investor uploads
Build pitch-deck / build-push-deploy (push) Successful in 1m21s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 32s

- 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 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-01 15:38:21 +02:00
parent 1bf1411c66
commit 9888b1b5d7
13 changed files with 930 additions and 0 deletions
@@ -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 })
}
@@ -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 })
}
@@ -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 })
}
@@ -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 })
}
@@ -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 })
}
+33
View File
@@ -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_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_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)`, `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) { for (const sql of statements) {
@@ -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),
},
})
}
@@ -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 })
}
@@ -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 })
}
+187
View File
@@ -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<Doc[]>([])
const [uploads, setUploads] = useState<MyUpload[]>([])
const [uploading, setUploading] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<div className="min-h-screen bg-[#0a0a1a] text-white">
{/* Header */}
<div className="border-b border-white/[0.06] px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
Data Room
</h1>
<p className="text-xs text-white/30 mt-0.5">BreakPilot ComplAI · Investor Portal</p>
</div>
<div className="flex items-center gap-3">
<a href="/" className="text-xs text-white/40 hover:text-white/70 transition-colors"> Back to pitch</a>
<a href="/api/auth/logout" className="text-xs text-white/40 hover:text-white/70 transition-colors flex items-center gap-1.5">
<LogOut className="w-3.5 h-3.5" /> Sign out
</a>
</div>
</div>
<div className="max-w-4xl mx-auto px-6 py-10 space-y-10">
{/* Released documents */}
<section>
<h2 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-4">Documents</h2>
{docs.length === 0 ? (
<div className="bg-white/[0.02] border border-dashed border-white/[0.08] rounded-2xl p-12 text-center">
<FileText className="w-8 h-8 text-white/20 mx-auto mb-3" />
<p className="text-white/30 text-sm">No documents have been shared with you yet.</p>
</div>
) : (
<div className="space-y-3">
{docs.map(doc => (
<div key={doc.id} className="bg-white/[0.04] border border-white/[0.06] rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-indigo-500/10 flex items-center justify-center shrink-0">
<FileText className="w-5 h-5 text-indigo-400" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white truncate">{doc.display_name || doc.filename}</div>
<div className="text-xs text-white/40 mt-0.5">
{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{isPDF(doc.mime_type) && (
<a
href={`/api/dataroom/documents/${doc.id}/download?preview=1`}
target="_blank"
rel="noopener noreferrer"
className="bg-white/[0.06] hover:bg-white/[0.1] text-white/70 text-xs px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors"
>
<Eye className="w-3.5 h-3.5" /> Preview
</a>
)}
<a
href={`/api/dataroom/documents/${doc.id}/download`}
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-xs px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors"
download
>
<Download className="w-3.5 h-3.5" /> Download
</a>
</div>
</div>
))}
</div>
)}
</section>
{/* Upload section */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-white/60 uppercase tracking-wider">Your Documents</h2>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Upload className="w-4 h-4" />
{uploading ? 'Uploading…' : 'Send document'}
</button>
<input ref={fileRef} type="file" className="hidden" onChange={handleUpload} />
</div>
<p className="text-xs text-white/30 mb-4">
Upload documents you want to share with us NDAs, term sheets, financial statements, or any other relevant files.
</p>
{uploads.length === 0 ? (
<div className="bg-white/[0.02] border border-dashed border-white/[0.08] rounded-2xl p-10 text-center">
<Upload className="w-7 h-7 text-white/20 mx-auto mb-3" />
<p className="text-white/30 text-sm">No files uploaded yet.</p>
</div>
) : (
<div className="space-y-2">
{uploads.map(u => (
<div key={u.id} className="bg-white/[0.03] border border-white/[0.05] rounded-xl p-4 flex items-center gap-3">
<FileText className="w-4 h-4 text-white/30 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm text-white/80 truncate">{u.display_name || u.filename}</div>
<div className="text-xs text-white/30 mt-0.5">{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}</div>
</div>
<span className="text-xs text-emerald-400/70 shrink-0">Received</span>
</div>
))}
</div>
)}
</section>
</div>
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}
@@ -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<Doc[]>([])
const [investors, setInvestors] = useState<Investor[]>([])
const [selected, setSelected] = useState<Doc | null>(null)
const [releases, setReleases] = useState<Release[]>([])
const [uploading, setUploading] = useState(false)
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [tab, setTab] = useState<'documents' | 'uploads'>('documents')
const [investorUploads, setInvestorUploads] = useState<Record<string, InvestorUpload[]>>({})
const fileRef = useRef<HTMLInputElement>(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<string, InvestorUpload[]> = {}
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<HTMLInputElement>) {
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">Data Room</h1>
<div className="flex gap-2">
<button
onClick={() => setTab('documents')}
className={`text-sm px-4 py-2 rounded-lg transition-colors ${tab === 'documents' ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/50 hover:text-white/80'}`}
>
Documents
</button>
<button
onClick={() => setTab('uploads')}
className={`text-sm px-4 py-2 rounded-lg transition-colors ${tab === 'uploads' ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/50 hover:text-white/80'}`}
>
Investor Uploads
</button>
</div>
</div>
{tab === 'documents' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Document list */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-white/50">{docs.length} document{docs.length !== 1 ? 's' : ''}</span>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{uploading ? 'Uploading…' : 'Upload'}
</button>
<input ref={fileRef} type="file" className="hidden" onChange={uploadFile} />
</div>
{docs.length === 0 && (
<div className="bg-white/[0.03] border border-dashed border-white/10 rounded-xl p-10 text-center text-white/30 text-sm">
No documents yet. Upload the first one.
</div>
)}
{docs.map(doc => (
<button
key={doc.id}
onClick={() => selectDoc(doc)}
className={`w-full text-left bg-white/[0.03] border rounded-xl p-4 transition-colors ${selected?.id === doc.id ? 'border-indigo-500/40 bg-indigo-500/5' : 'border-white/[0.06] hover:border-white/[0.12]'}`}
>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-indigo-400 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white truncate">{doc.display_name || doc.filename}</div>
<div className="text-xs text-white/40 mt-0.5">{fmt(doc.file_size)} · {new Date(doc.created_at).toLocaleDateString()}</div>
</div>
<span className="text-xs text-white/40 shrink-0">
{doc.release_count > 0 ? <span className="text-emerald-400">{doc.release_count} released</span> : 'not released'}
</span>
</div>
</button>
))}
</div>
{/* Release panel */}
{selected ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{selected.display_name || selected.filename}</div>
<div className="text-xs text-white/40 mt-0.5">{fmt(selected.file_size)} · {selected.mime_type}</div>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={releaseAll}
disabled={busy || allInvestors.length === 0}
className="text-xs bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 px-3 py-1.5 rounded-lg flex items-center gap-1.5 disabled:opacity-40"
>
<Share2 className="w-3.5 h-3.5" /> Release all
</button>
<button
onClick={() => deleteDoc(selected.id)}
disabled={busy}
className="text-xs bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-3 py-1.5 rounded-lg flex items-center gap-1.5 disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
<button onClick={() => setSelected(null)} className="text-white/40 hover:text-white/80">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="border-t border-white/[0.06] pt-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-white/40" />
<span className="text-xs font-semibold text-white/60 uppercase tracking-wider">Investor Access</span>
</div>
{allInvestors.length === 0 && (
<p className="text-xs text-white/30">No active investors yet.</p>
)}
<div className="space-y-2">
{allInvestors.map(inv => {
const has = releasedIds.has(inv.id)
return (
<button
key={inv.id}
onClick={() => toggleRelease(inv.id, has)}
disabled={busy}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-white/[0.04] transition-colors disabled:opacity-50"
>
<div className={`w-5 h-5 rounded border flex items-center justify-center shrink-0 ${has ? 'bg-emerald-500 border-emerald-500' : 'border-white/20'}`}>
{has && <Check className="w-3 h-3 text-white" />}
</div>
<div className="text-left min-w-0 flex-1">
<div className="text-sm text-white truncate">{inv.name || inv.email}</div>
{inv.company && <div className="text-xs text-white/40 truncate">{inv.company}</div>}
</div>
{has && (
<span className="text-[10px] text-emerald-400 shrink-0">
{releases.find(r => r.investor_id === inv.id) ? new Date(releases.find(r => r.investor_id === inv.id)!.released_at).toLocaleDateString() : ''}
</span>
)}
</button>
)
})}
</div>
</div>
</div>
) : (
<div className="bg-white/[0.02] border border-dashed border-white/[0.06] rounded-2xl p-10 flex items-center justify-center text-white/20 text-sm">
Select a document to manage releases
</div>
)}
</div>
)}
{tab === 'uploads' && (
<div className="space-y-4">
{investorsWithUploads.length === 0 && (
<div className="bg-white/[0.03] border border-dashed border-white/10 rounded-xl p-10 text-center text-white/30 text-sm">
No investor uploads yet.
</div>
)}
{allInvestors.map(inv => {
const uploads = investorUploads[inv.id] || []
if (uploads.length === 0) return null
return (
<div key={inv.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold text-white">{inv.name || inv.email}</span>
{inv.company && <span className="text-xs text-white/40">{inv.company}</span>}
<span className="ml-auto text-xs text-white/40">{uploads.length} file{uploads.length !== 1 ? 's' : ''}</span>
</div>
<div className="space-y-2">
{uploads.map(u => (
<div key={u.id} className="flex items-center gap-3 p-2.5 bg-white/[0.03] rounded-lg">
<FileText className="w-4 h-4 text-white/40 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm text-white truncate">{u.display_name || u.filename}</div>
<div className="text-xs text-white/40">{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}</div>
</div>
<a
href={`/api/admin/dataroom/investors/${inv.id}/uploads?download=${u.id}`}
className="text-xs bg-white/[0.06] hover:bg-white/[0.1] text-white/70 px-3 py-1.5 rounded-lg flex items-center gap-1.5"
download
>
<Download className="w-3.5 h-3.5" /> Download
</a>
</div>
))}
</div>
</div>
)
})}
</div>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}
@@ -10,6 +10,7 @@ import {
TrendingUp, TrendingUp,
ShieldCheck, ShieldCheck,
GitBranch, GitBranch,
FolderOpen,
LogOut, LogOut,
Menu, Menu,
X, X,
@@ -26,6 +27,7 @@ const NAV = [
{ href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch }, { href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch },
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText }, { href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp }, { 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 }, { href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
] ]
+48
View File
@@ -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<void> {
await fs.promises.mkdir(dir, { recursive: true })
}
export async function saveFile(dir: string, filename: string, buffer: Buffer): Promise<string> {
await ensureDir(dir)
const filePath = path.join(dir, filename)
await fs.promises.writeFile(filePath, buffer)
return filePath
}
export async function removeDir(dir: string): Promise<void> {
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)
}