diff --git a/.gitea/workflows/pitch-cleanup.yml b/.gitea/workflows/pitch-cleanup.yml new file mode 100644 index 0000000..6458f29 --- /dev/null +++ b/.gitea/workflows/pitch-cleanup.yml @@ -0,0 +1,36 @@ +# Daily GDPR data cleanup for the pitch deck. +# Calls /api/admin/cleanup which runs runDataCleanup(): +# - anonymizes investors inactive 30+ days +# - anonymizes never-activated invites after 90 days +# - deletes sessions + magic links older than 30 days +# - anonymizes IPs in audit logs older than 30 days +# +# Requires Gitea Actions secret: PITCH_ADMIN_SECRET + +name: Pitch deck — GDPR cleanup + +on: + schedule: + - cron: '0 2 * * *' + +jobs: + cleanup: + runs-on: docker + container: + image: alpine:3.19 + steps: + - name: Run data cleanup + env: + PITCH_ADMIN_SECRET: ${{ secrets.PITCH_ADMIN_SECRET }} + run: | + apk add --no-cache curl + RESPONSE=$(curl -sSf -w "\n%{http_code}" -X POST \ + -H "Authorization: Bearer $PITCH_ADMIN_SECRET" \ + -H "Content-Type: application/json" \ + https://pitch.breakpilot.com/api/admin/cleanup) \ + || { echo "Cleanup request failed"; exit 1; } + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + echo "Response: $BODY" + [ "$HTTP_CODE" = "200" ] || { echo "Unexpected status $HTTP_CODE"; exit 1; } + echo "GDPR cleanup completed successfully" diff --git a/pitch-deck/Dockerfile b/pitch-deck/Dockerfile index 3c986ae..903b834 100644 --- a/pitch-deck/Dockerfile +++ b/pitch-deck/Dockerfile @@ -31,6 +31,10 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +# Create dataroom storage directory owned by nextjs so mounted volumes +# inherit the correct ownership on first initialisation +RUN mkdir -p /data/dataroom && chown -R nextjs:nodejs /data/dataroom + # Copy built assets COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ diff --git a/pitch-deck/app/api/admin/cleanup/route.ts b/pitch-deck/app/api/admin/cleanup/route.ts new file mode 100644 index 0000000..a765875 --- /dev/null +++ b/pitch-deck/app/api/admin/cleanup/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/admin-auth' +import { runDataCleanup } from '@/lib/masking' + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const stats = await runDataCleanup() + return NextResponse.json({ success: true, stats }) +} 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..971e255 --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts @@ -0,0 +1,58 @@ +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' +import { translateText } from '@/lib/translate' + +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 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 *`, + [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..d01b78f --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/documents/route.ts @@ -0,0 +1,69 @@ +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 { translateText } from '@/lib/translate' +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.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 + 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 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 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, 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]) + } + + 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 new file mode 100644 index 0000000..6584d20 --- /dev/null +++ b/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts @@ -0,0 +1,64 @@ +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 }> } + +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, description_de, description_en, 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 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/investors/[id]/generate-link/route.ts b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts new file mode 100644 index 0000000..d0586fc --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { generateToken } from '@/lib/auth' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function POST(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + + const { rows } = await pool.query( + `SELECT id, email, name, status, data_masked_at FROM pitch_investors WHERE id = $1`, + [id], + ) + if (rows.length === 0) { + return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) + } + + const investor = rows[0] + if (investor.data_masked_at) { + return NextResponse.json( + { error: 'Investor data has been anonymized after the 72h window. Cannot generate a new link.' }, + { status: 410 }, + ) + } + if (investor.status === 'revoked') { + return NextResponse.json( + { error: 'Investor is revoked. Re-invite to reactivate.' }, + { status: 400 }, + ) + } + + const token = generateToken() + const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72') + const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000) + + await pool.query( + `INSERT INTO pitch_magic_links (investor_id, token, expires_at) VALUES ($1, $2, $3)`, + [investor.id, token, expiresAt], + ) + + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + const url = `${baseUrl}/auth/verify?token=${token}` + + await logAdminAudit( + adminId, + 'magic_link_generated', + { email: investor.email, expires_at: expiresAt.toISOString(), channel: 'manual_copy' }, + request, + investor.id, + ) + + return NextResponse.json({ url, expires_at: expiresAt.toISOString() }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts index 56a7ff1..15ef3cf 100644 --- a/pitch-deck/app/api/admin/investors/[id]/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' +import { runDataCleanup } from '@/lib/masking' interface RouteContext { params: Promise<{ id: string }> @@ -12,10 +13,13 @@ export async function GET(request: NextRequest, ctx: RouteContext) { const { id } = await ctx.params + await runDataCleanup() + const [investor, sessions, snapshots, audit] = await Promise.all([ pool.query( `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, - i.created_at, i.updated_at, i.assigned_version_id, + i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at, + i.assigned_version_id, v.name AS version_name, v.status AS version_status FROM pitch_investors i LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts index 0ba4ccf..d9e645f 100644 --- a/pitch-deck/app/api/admin/investors/route.ts +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin } from '@/lib/admin-auth' +import { runDataCleanup } from '@/lib/masking' export async function GET(request: NextRequest) { const guard = await requireAdmin(request) if (guard.kind === 'response') return guard.response + await runDataCleanup() + const { rows } = await pool.query( `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at, + i.first_activity_at, i.data_masked_at, i.assigned_version_id, v.name AS version_name, (SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed, (SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index 149cdd1..8299282 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -10,6 +10,17 @@ export async function POST(request: NextRequest) { // Finanzplan tables — the ones missing on production const statements = [ + // 004 — investor data masking columns + `ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS first_activity_at TIMESTAMPTZ`, + `ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS data_masked_at TIMESTAMPTZ`, + `CREATE INDEX IF NOT EXISTS idx_pitch_investors_mask_check + ON pitch_investors (first_activity_at) + WHERE first_activity_at IS NOT NULL AND data_masked_at IS NULL`, + // 004b — backfill first_activity_at for existing investors from last_login_at + `UPDATE pitch_investors + SET first_activity_at = last_login_at + WHERE last_login_at IS NOT NULL + AND first_activity_at IS NULL`, `CREATE TABLE IF NOT EXISTS fp_scenarios ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL DEFAULT 'Base Case', @@ -111,6 +122,44 @@ 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)`, + // 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/auth/verify/route.ts b/pitch-deck/app/api/auth/verify/route.ts index 2b29e6c..8e1e545 100644 --- a/pitch-deck/app/api/auth/verify/route.ts +++ b/pitch-deck/app/api/auth/verify/route.ts @@ -21,7 +21,9 @@ export async function POST(request: NextRequest) { // Find the magic link const { rows } = await pool.query( - `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status + `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, + i.email, i.status as investor_status, + i.first_activity_at, i.data_masked_at FROM pitch_magic_links ml JOIN pitch_investors i ON i.id = ml.investor_id WHERE ml.token = $1`, @@ -45,6 +47,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 }) } + if (link.data_masked_at) { + return NextResponse.json({ error: 'This access period has ended and data has been anonymized.' }, { status: 410 }) + } + if (link.investor_status === 'revoked') { await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request) return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 }) @@ -58,9 +64,14 @@ export async function POST(request: NextRequest) { [ip, ua, link.id] ) - // Activate investor if first login + // Activate investor if first login; record first_activity_at once await pool.query( - `UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW() + `UPDATE pitch_investors + SET status = 'active', + last_login_at = NOW(), + login_count = login_count + 1, + first_activity_at = COALESCE(first_activity_at, NOW()), + updated_at = NOW() WHERE id = $1`, [link.investor_id] ) 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..6982905 --- /dev/null +++ b/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { streamFile } from '@/lib/dataroom-storage' +import { logAudit, getSessionFromCookie } from '@/lib/auth' +import path from 'path' + +interface Ctx { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, ctx: Ctx) { + const session = await getSessionFromCookie() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const investorId = session.sub + const sessionId = session.sessionId + + 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..63c0f76 --- /dev/null +++ b/pitch-deck/app/api/dataroom/documents/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +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.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 + ORDER BY r.released_at DESC`, + [investorId], + ) + return NextResponse.json({ documents: rows }) +} 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 new file mode 100644 index 0000000..1f09eac --- /dev/null +++ b/pitch-deck/app/api/dataroom/uploads/route.ts @@ -0,0 +1,72 @@ +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' + +const MAX_BYTES = parseInt(process.env.DATAROOM_MAX_UPLOAD_MB || '50') * 1024 * 1024 + +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 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`, + [investorId], + ) + return NextResponse.json({ uploads: rows }) +} + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const investorId = session.sub + const sessionId = session.sessionId + + const formData = await request.formData() + 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 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 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 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/auth/page.tsx b/pitch-deck/app/auth/page.tsx index cd39c96..93a156d 100644 --- a/pitch-deck/app/auth/page.tsx +++ b/pitch-deck/app/auth/page.tsx @@ -39,15 +39,16 @@ export default function AuthPage() { } return ( -
+
{/* Background gradient */} -
+
+

@@ -122,13 +123,13 @@ export default function AuthPage() { We are an AI-first company. No PDFs. No slide decks. Just code.

+

{/* Privacy Notice Footer */} -
+

- Datenschutzhinweis: Beim Zugriff auf diese Seite werden technische Zugriffsdaten (insbesondere IP-Adresse und Zeitpunkt) verarbeitet, um die sichere Nutzung des Zugangs zu gewährleisten und Missbrauch zu verhindern. Die Speicherung erfolgt für maximal 72 Stunden. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). -

+ Datenschutzhinweis (Art. 13 DSGVO): Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie – soweit eingeladen – personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 15–21 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).

Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com

diff --git a/pitch-deck/app/auth/verify/page.tsx b/pitch-deck/app/auth/verify/page.tsx index f3a4d4e..d363ffb 100644 --- a/pitch-deck/app/auth/verify/page.tsx +++ b/pitch-deck/app/auth/verify/page.tsx @@ -119,8 +119,7 @@ export default function VerifyPage() {

- Datenschutzhinweis: Beim Zugriff auf diese Seite werden technische Zugriffsdaten (insbesondere IP-Adresse und Zeitpunkt) verarbeitet, um die sichere Nutzung des Zugangs zu gewährleisten und Missbrauch zu verhindern. Die Speicherung erfolgt für maximal 72 Stunden. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Weitere Informationen zum Datenschutz erhalten Sie auf Anfrage. -

+ Datenschutzhinweis (Art. 13 DSGVO): Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie – soweit eingeladen – personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 15–21 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).

Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com

diff --git a/pitch-deck/app/dataroom/page.tsx b/pitch-deck/app/dataroom/page.tsx new file mode 100644 index 0000000..6776a11 --- /dev/null +++ b/pitch-deck/app/dataroom/page.tsx @@ -0,0 +1,263 @@ +'use client' + +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 +} + +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 +} + +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' } + +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) } + + 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 uploadFiles(files: FileList | File[]) { + const list = Array.from(files).filter(f => f.size > 0) + if (!list.length) return + setUploading(true) + const fd = new FormData() + 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(`${list.length} file${list.length > 1 ? 's' : ''} uploaded`) + setDescription(''); loadAll() + } else { + const d = await r.json().catch(() => ({})) + flash(d.error || 'Upload failed') + } + } + + 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 ( +
+
+
+

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()}
+ {(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 + +
+
+ ))} +
+ )} +
+ + {/* Upload section */} +
+

Your Documents

+

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

+ + {/* Description field */} +
+