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