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
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:
@@ -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 })
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user