Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
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 47s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 33s

This commit is contained in:
Benjamin Admin
2026-05-03 23:14:34 +02:00
32 changed files with 2225 additions and 40 deletions
+36
View File
@@ -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"
+4
View File
@@ -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 ./
+11
View File
@@ -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 })
}
@@ -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,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 })
}
@@ -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 })
}
@@ -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] })
}
@@ -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() })
}
@@ -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
@@ -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
+49
View File
@@ -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) {
+14 -3
View File
@@ -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]
)
@@ -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),
},
})
}
@@ -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 })
}
@@ -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] })
}
@@ -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 })
}
+7 -6
View File
@@ -39,15 +39,16 @@ export default function AuthPage() {
}
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
<div className="min-h-screen flex flex-col bg-[#0a0a1a] relative overflow-x-hidden">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20" />
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20 pointer-events-none" />
<div className="flex-1 flex items-center justify-center py-12 px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="relative z-10 text-center max-w-md mx-auto px-6"
className="relative z-10 text-center max-w-md w-full mx-auto"
>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent mb-2">
@@ -122,13 +123,13 @@ export default function AuthPage() {
We are an AI-first company. No PDFs. No slide decks. Just code.
</p>
</motion.div>
</div>
{/* Privacy Notice Footer */}
<div className="absolute bottom-0 left-0 right-0 px-8 py-4 border-t border-white/5">
<div className="relative z-10 px-8 py-5 border-t border-white/5">
<div className="max-w-2xl mx-auto">
<p className="text-[10px] text-white/20 leading-relaxed text-center">
<strong className="text-white/25">Datenschutzhinweis:</strong> 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).
</p>
<strong className="text-white/25">Datenschutzhinweis (Art. 13 DSGVO):</strong> 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. 1521 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).</p>
<p className="text-[10px] text-white/15 text-center mt-1">
Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com
</p>
+1 -2
View File
@@ -119,8 +119,7 @@ export default function VerifyPage() {
<div className="absolute bottom-0 left-0 right-0 z-10 px-8 py-4 border-t border-white/5">
<div className="max-w-2xl mx-auto">
<p className="text-[10px] text-white/20 leading-relaxed text-center">
<strong className="text-white/25">Datenschutzhinweis:</strong> 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.
</p>
<strong className="text-white/25">Datenschutzhinweis (Art. 13 DSGVO):</strong> 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. 1521 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).</p>
<p className="text-[10px] text-white/15 text-center mt-1">
Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com
</p>
+263
View File
@@ -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 (
<div className="flex rounded-lg overflow-hidden border border-white/10 text-xs shrink-0">
{(['de', 'en'] as const).map(l => (
<button key={l} onClick={() => onChange(l)}
className={`px-2 py-1 uppercase transition-colors ${lang === l ? 'bg-indigo-500/30 text-indigo-300' : 'text-white/40 hover:text-white/70'}`}>
{l}
</button>
))}
</div>
)
}
export default function DataroomPage() {
const [docs, setDocs] = useState<Doc[]>([])
const [uploads, setUploads] = useState<MyUpload[]>([])
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [toast, setToast] = useState<string | null>(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<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 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 (
<div className="min-h-screen bg-[#0a0a1a] text-white">
<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-start gap-4">
<div className="w-10 h-10 rounded-lg bg-indigo-500/10 flex items-center justify-center shrink-0 mt-0.5">
<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">{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>
{(doc.description_en || doc.description_de) && (
<div className="mt-1.5 space-y-0.5">
{doc.description_en && <p className="text-xs text-white/40 leading-relaxed">{doc.description_en}</p>}
{doc.description_de && !doc.description_en && <p className="text-xs text-white/40 leading-relaxed">{doc.description_de}</p>}
{doc.description_de && doc.description_en && <p className="text-xs text-white/20 leading-relaxed flex gap-1"><Globe className="w-3 h-3 shrink-0 mt-0.5" />{doc.description_de}</p>}
</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`} 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 className="w-3.5 h-3.5" /> Download
</a>
</div>
</div>
))}
</div>
)}
</section>
{/* Upload section */}
<section>
<h2 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-4">Your Documents</h2>
<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>
{/* Description field */}
<div className="flex gap-2 items-start mb-3">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Optional description for uploaded files…"
rows={2}
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/30"
/>
<LangToggle lang={descLang} onChange={setDescLang} />
</div>
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
className={`border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-all select-none ${dragging ? 'border-indigo-400/60 bg-indigo-500/10' : 'border-white/[0.08] bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.03]'}`}
>
{uploading
? <p className="text-white/40 text-sm">Uploading</p>
: <>
<Upload className="w-7 h-7 text-white/20 mx-auto mb-3" />
<p className="text-white/50 text-sm font-medium">Drop files here</p>
<p className="text-white/25 text-xs mt-1">or click to browse · multiple files supported</p>
</>}
<input ref={fileRef} type="file" multiple className="hidden" onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} />
</div>
{/* Uploaded files list */}
{uploads.length > 0 && (
<div className="mt-4 space-y-2">
{uploads.map(u => {
const isEditing = editingUpload?.id === u.id
return (
<div key={u.id} className="bg-white/[0.03] border border-white/[0.05] rounded-xl p-4 space-y-2">
<div className="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>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => isEditing ? setEditingUpload(null) : setEditingUpload({ id: u.id, text: u.description_en || u.description_de || '', lang: u.description_en ? 'en' : 'de' })}
className="text-white/25 hover:text-white/60 transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-emerald-400/70">Received</span>
</div>
</div>
{(u.description_en || u.description_de) && !isEditing && (
<div className="pl-7 space-y-0.5">
{u.description_en && <p className="text-xs text-white/30">{u.description_en}</p>}
{u.description_de && !u.description_en && <p className="text-xs text-white/30">{u.description_de}</p>}
</div>
)}
{isEditing && editingUpload && (
<div className="pl-7 space-y-2">
<div className="flex gap-2 items-start">
<textarea value={editingUpload.text} onChange={e => setEditingUpload(d => d ? { ...d, text: e.target.value } : d)}
rows={2} placeholder="Description…"
className="flex-1 bg-white/[0.04] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/30" />
<LangToggle lang={editingUpload.lang} onChange={l => setEditingUpload(d => d ? { ...d, lang: l } : d)} />
</div>
<div className="flex gap-2">
<button onClick={saveEditDescription}
className="text-xs bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 px-3 py-1.5 rounded-lg">
Save & translate
</button>
<button onClick={() => setEditingUpload(null)} className="text-xs text-white/30 hover:text-white/60 px-2">Cancel</button>
</div>
</div>
)}
</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,451 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { FileText, Trash2, X, Share2, Users, Check, Download, Pencil, Globe } from 'lucide-react'
interface Doc {
id: string
filename: string
display_name: string
description_de: string | null
description_en: string | null
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
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 LangToggle({ lang, onChange }: { lang: 'de' | 'en'; onChange: (l: 'de' | 'en') => void }) {
return (
<div className="flex rounded-lg overflow-hidden border border-white/10 text-xs shrink-0">
{(['de', 'en'] as const).map(l => (
<button key={l} onClick={() => onChange(l)}
className={`px-2 py-1 uppercase transition-colors ${lang === l ? 'bg-indigo-500/30 text-indigo-300' : 'text-white/40 hover:text-white/70'}`}>
{l}
</button>
))}
</div>
)
}
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 [dragging, setDragging] = useState(false)
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 [descEdit, setDescEdit] = useState<{ text: string; lang: 'de' | 'en' } | null>(null)
const [editingUpload, setEditingUpload] = useState<{ invId: string; uploadId: string; text: string; lang: 'de' | 'en' } | null>(null)
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); setDescEdit(null)
await loadReleases(doc.id)
}
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))
const r = await fetch('/api/admin/dataroom/documents', { method: 'POST', body: fd })
setUploading(false)
if (r.ok) { flash(`Uploaded ${list.length} file${list.length > 1 ? 's' : ''}`); loadDocs() }
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)
}, [])
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()
}
async function saveDescription() {
if (!selected || !descEdit) return
setBusy(true)
const r = await fetch(`/api/admin/dataroom/documents/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: descEdit.text || null, description_lang: descEdit.lang }),
})
setBusy(false)
if (r.ok) {
const updated = (await r.json()).document as Doc
setDocs(prev => prev.map(d => d.id === updated.id ? { ...d, ...updated } : d))
setSelected(prev => prev ? { ...prev, ...updated } : prev)
setDescEdit(null); flash('Description saved & translated')
} else flash('Save failed')
}
async function saveUploadDescription() {
if (!editingUpload) return
setBusy(true)
const r = await fetch(`/api/admin/dataroom/investors/${editingUpload.invId}/uploads`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_id: editingUpload.uploadId, description: editingUpload.text || null, description_lang: editingUpload.lang }),
})
setBusy(false)
if (r.ok) {
const updated = (await r.json()).upload as InvestorUpload
setInvestorUploads(prev => ({
...prev,
[editingUpload.invId]: (prev[editingUpload.invId] || []).map(u => u.id === updated.id ? updated : u),
}))
setEditingUpload(null); flash('Description saved & translated')
} else flash('Save failed')
}
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">
{(['documents', 'uploads'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`text-sm px-4 py-2 rounded-lg transition-colors ${tab === t ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/50 hover:text-white/80'}`}>
{t === 'documents' ? 'Documents' : 'Investor Uploads'}
</button>
))}
</div>
</div>
{tab === 'documents' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Drop zone + document list */}
<div className="space-y-3">
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all select-none ${dragging ? 'border-indigo-400/60 bg-indigo-500/10' : 'border-white/[0.08] bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.03]'}`}
>
{uploading
? <p className="text-white/40 text-sm">Uploading</p>
: <>
<p className="text-white/50 text-sm font-medium">Drop files here</p>
<p className="text-white/25 text-xs mt-1">or click to browse · multiple files supported</p>
</>}
<input ref={fileRef} type="file" multiple className="hidden" onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} />
</div>
{docs.length > 0 && (
<p className="text-xs text-white/30">{docs.length} document{docs.length !== 1 ? 's' : ''}</p>
)}
{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>
{(doc.description_en || doc.description_de) && (
<div className="text-xs text-white/30 mt-1 line-clamp-1">{doc.description_en || doc.description_de}</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>
{/* Detail 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" />
</button>
<button onClick={() => setSelected(null)} className="text-white/40 hover:text-white/80">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Description editor */}
<div className="border-t border-white/[0.06] pt-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Globe className="w-3.5 h-3.5 text-white/30" />
<span className="text-xs font-semibold text-white/50 uppercase tracking-wider">Description</span>
</div>
{!descEdit && (
<button onClick={() => setDescEdit({ text: selected.description_en || selected.description_de || '', lang: selected.description_en ? 'en' : 'de' })}
className="text-xs text-white/30 hover:text-white/60 flex items-center gap-1">
<Pencil className="w-3 h-3" /> Edit
</button>
)}
</div>
{descEdit ? (
<div className="space-y-2">
<div className="flex gap-2 items-start">
<textarea
value={descEdit.text}
onChange={e => setDescEdit(d => d ? { ...d, text: e.target.value } : d)}
rows={3}
placeholder="Enter description…"
className="flex-1 bg-white/[0.04] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/40"
/>
<LangToggle lang={descEdit.lang} onChange={l => setDescEdit(d => d ? { ...d, lang: l } : d)} />
</div>
<div className="flex gap-2">
<button onClick={saveDescription} disabled={busy}
className="text-xs bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 px-3 py-1.5 rounded-lg disabled:opacity-40">
Save & translate
</button>
<button onClick={() => setDescEdit(null)} className="text-xs text-white/30 hover:text-white/60 px-3 py-1.5">Cancel</button>
</div>
</div>
) : (selected.description_en || selected.description_de) ? (
<div className="space-y-1.5">
{selected.description_de && <p className="text-xs text-white/40 leading-relaxed"><span className="text-white/20 uppercase text-[10px] mr-1">DE</span>{selected.description_de}</p>}
{selected.description_en && <p className="text-xs text-white/40 leading-relaxed"><span className="text-white/20 uppercase text-[10px] mr-1">EN</span>{selected.description_en}</p>}
</div>
) : (
<p className="text-xs text-white/20">No description yet.</p>
)}
</div>
{/* Investor access */}
<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 => {
const isEditing = editingUpload?.uploadId === u.id
return (
<div key={u.id} className="bg-white/[0.03] rounded-lg p-3 space-y-2">
<div className="flex items-center gap-3">
<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>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => isEditing ? setEditingUpload(null) : setEditingUpload({ invId: inv.id, uploadId: u.id, text: u.description_en || u.description_de || '', lang: u.description_en ? 'en' : 'de' })}
className="text-xs text-white/30 hover:text-white/60 flex items-center gap-1">
<Pencil className="w-3 h-3" />
</button>
<a href={`/api/admin/dataroom/investors/${inv.id}/uploads?download=${u.id}`} download
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 className="w-3.5 h-3.5" />
</a>
</div>
</div>
{(u.description_de || u.description_en) && !isEditing && (
<div className="pl-7 space-y-1">
{u.description_de && <p className="text-xs text-white/30"><span className="text-white/15 uppercase text-[10px] mr-1">DE</span>{u.description_de}</p>}
{u.description_en && <p className="text-xs text-white/30"><span className="text-white/15 uppercase text-[10px] mr-1">EN</span>{u.description_en}</p>}
</div>
)}
{isEditing && editingUpload && (
<div className="pl-7 space-y-2">
<div className="flex gap-2 items-start">
<textarea value={editingUpload.text} onChange={e => setEditingUpload(d => d ? { ...d, text: e.target.value } : d)}
rows={2} placeholder="Description…"
className="flex-1 bg-white/[0.04] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/40" />
<LangToggle lang={editingUpload.lang} onChange={l => setEditingUpload(d => d ? { ...d, lang: l } : d)} />
</div>
<div className="flex gap-2">
<button onClick={saveUploadDescription} disabled={busy}
className="text-xs bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 px-3 py-1.5 rounded-lg disabled:opacity-40">
Save & translate
</button>
<button onClick={() => setEditingUpload(null)} className="text-xs text-white/30 hover:text-white/60 px-2">Cancel</button>
</div>
</div>
)}
</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>
)
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Mail, Ban, Save } from 'lucide-react'
import { ArrowLeft, Mail, Ban, Save, Link2, RefreshCw } from 'lucide-react'
import AuditLogTable from '@/components/pitch-admin/AuditLogTable'
interface InvestorDetail {
@@ -16,6 +16,8 @@ interface InvestorDetail {
last_login_at: string | null
login_count: number
created_at: string
first_activity_at: string | null
data_masked_at: string | null
assigned_version_id: string | null
version_name: string | null
version_status: string | null
@@ -51,6 +53,7 @@ const STATUS_STYLES: Record<string, string> = {
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
active: 'bg-green-500/15 text-green-300 border-green-500/30',
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
}
export default function InvestorDetailPage() {
@@ -105,12 +108,30 @@ export default function InvestorDetailPage() {
}
}
async function generateLink() {
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}/generate-link`, { method: 'POST' })
setBusy(false)
if (res.ok) {
const d = await res.json()
try {
await navigator.clipboard.writeText(d.url)
flashToast('Magic link copied to clipboard')
} catch {
flashToast(`Link (copy manually): ${d.url}`)
}
} else {
const err = await res.json().catch(() => ({}))
flashToast(err.error || 'Failed to generate link')
}
}
async function resend() {
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
setBusy(false)
if (res.ok) {
flashToast('Magic link resent')
flashToast('Magic link resent via email')
load()
} else {
const err = await res.json().catch(() => ({}))
@@ -168,13 +189,28 @@ export default function InvestorDetailPage() {
) : (
<>
<div className="flex items-center gap-3 mb-1 flex-wrap">
<h1 className="text-2xl font-semibold text-white">{inv.name || inv.email}</h1>
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status]}`}>
{inv.status}
<h1 className="text-2xl font-semibold text-white">
{inv.data_masked_at ? <span className="text-zinc-500 italic">[data protected]</span> : (inv.name || inv.email)}
</h1>
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${inv.data_masked_at ? STATUS_STYLES.anonymized : STATUS_STYLES[inv.status]}`}>
{inv.data_masked_at ? 'anonymized' : inv.status}
</span>
</div>
{inv.data_masked_at ? (
<div className="text-xs text-zinc-500 mt-1">
Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 30-day inactivity window elapsed
</div>
) : (
<>
<div className="text-sm text-white/60">{inv.company || '—'}</div>
<div className="text-xs text-white/40 mt-1">{inv.email}</div>
{inv.last_login_at && (
<div className="text-xs text-amber-400/70 mt-1">
Data window: 30 days after last login · auto-anonymizes {new Date(new Date(inv.last_login_at).getTime() + 30 * 24 * 60 * 60 * 1000).toLocaleString()}
</div>
)}
</>
)}
</>
)}
</div>
@@ -197,18 +233,28 @@ export default function InvestorDetailPage() {
</>
) : (
<>
{!inv.data_masked_at && (
<button
onClick={() => setEditing(true)}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
>
Edit
</button>
)}
<button
onClick={generateLink}
disabled={busy || !!inv.data_masked_at || inv.status === 'revoked'}
className="bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
title="Generate link without sending email — copies to clipboard"
>
{busy ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />} Copy Link
</button>
<button
onClick={resend}
disabled={busy || inv.status === 'revoked'}
disabled={busy || !!inv.data_masked_at || inv.status === 'revoked'}
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Mail className="w-4 h-4" /> Resend Link
<Mail className="w-4 h-4" /> Resend Email
</button>
<button
onClick={revoke}
@@ -228,9 +274,9 @@ export default function InvestorDetailPage() {
<div className="text-xl text-white font-semibold mt-1">{inv.login_count}</div>
</div>
<div>
<div className="text-xs text-white/40 uppercase tracking-wider">Last login</div>
<div className="text-xs text-white/40 uppercase tracking-wider">First activity</div>
<div className="text-sm text-white/80 mt-1">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'}
{inv.first_activity_at ? new Date(inv.first_activity_at).toLocaleString() : '—'}
</div>
</div>
<div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Search, Mail, Ban, Eye, RefreshCw } from 'lucide-react'
import { Search, Mail, Ban, Eye, RefreshCw, Link2 } from 'lucide-react'
interface Investor {
id: string
@@ -17,12 +17,15 @@ interface Investor {
last_activity: string | null
assigned_version_id: string | null
version_name: string | null
first_activity_at: string | null
data_masked_at: string | null
}
const STATUS_STYLES: Record<string, string> = {
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
active: 'bg-green-500/15 text-green-300 border-green-500/30',
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
}
export default function InvestorsPage() {
@@ -50,6 +53,24 @@ export default function InvestorsPage() {
setTimeout(() => setToast(null), 3000)
}
async function generateLink(id: string) {
setBusy(id)
const res = await fetch(`/api/admin/investors/${id}/generate-link`, { method: 'POST' })
setBusy(null)
if (res.ok) {
const data = await res.json()
try {
await navigator.clipboard.writeText(data.url)
flashToast('Magic link copied to clipboard')
} catch {
flashToast(`Link (copy manually): ${data.url}`)
}
} else {
const err = await res.json().catch(() => ({}))
flashToast(err.error || 'Failed to generate link')
}
}
async function resend(id: string) {
setBusy(id)
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
@@ -156,15 +177,19 @@ export default function InvestorsPage() {
<tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
<td className="py-3 px-4">
<Link href={`/pitch-admin/investors/${inv.id}`} className="block min-w-0 hover:text-indigo-300">
<div className="text-white/90 font-medium truncate">{inv.name || inv.email}</div>
<div className="text-white/90 font-medium truncate">
{inv.data_masked_at ? <span className="text-zinc-500 italic">[data protected]</span> : (inv.name || inv.email)}
</div>
<div className="text-xs text-white/40 truncate">
{inv.company ? `${inv.company} · ` : ''}{inv.email}
{inv.data_masked_at
? `Anonymized ${new Date(inv.data_masked_at).toLocaleDateString()}`
: `${inv.company ? `${inv.company} · ` : ''}${inv.email}`}
</div>
</Link>
</td>
<td className="py-3 px-4">
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status] || ''}`}>
{inv.status}
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${inv.data_masked_at ? STATUS_STYLES.anonymized : (STATUS_STYLES[inv.status] || '')}`}>
{inv.data_masked_at ? 'anonymized' : inv.status}
</span>
</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
@@ -189,12 +214,20 @@ export default function InvestorsPage() {
<Eye className="w-4 h-4" />
</Link>
<button
onClick={() => resend(inv.id)}
disabled={busy === inv.id || inv.status === 'revoked'}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Resend magic link"
onClick={() => generateLink(inv.id)}
disabled={busy === inv.id || inv.status === 'revoked' || !!inv.data_masked_at}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-emerald-500/15 hover:text-emerald-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Generate & copy magic link (no email)"
>
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Mail className="w-4 h-4" />}
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}
</button>
<button
onClick={() => resend(inv.id)}
disabled={busy === inv.id || inv.status === 'revoked' || !!inv.data_masked_at}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Resend magic link via email"
>
<Mail className="w-4 h-4" />
</button>
<button
onClick={() => revoke(inv.id, inv.email)}
+13
View File
@@ -10,6 +10,8 @@ import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
import { Language, PitchData } from '@/lib/types'
import { Investor } from '@/lib/hooks/useAuth'
import Link from 'next/link'
import { FolderOpen } from 'lucide-react'
import ParticleBackground from './ParticleBackground'
import ProgressBar from './ProgressBar'
import NavigationControls from './NavigationControls'
@@ -237,6 +239,17 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
{/* Investor watermark */}
{investor && <Watermark text={investor.email} />}
{/* Data Room link — only for real investor sessions, not preview */}
{investor && !previewData && (
<Link
href="/dataroom"
className="fixed top-4 right-4 z-40 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 hover:text-white/80 hover:bg-white/[0.1] backdrop-blur-sm transition-all text-xs"
>
<FolderOpen className="w-3.5 h-3.5" />
Data Room
</Link>
)}
<SlideContainer slideKey={nav.currentSlide} direction={nav.direction}>
{renderSlide()}
</SlideContainer>
@@ -10,6 +10,7 @@ import {
TrendingUp,
ShieldCheck,
GitBranch,
FolderOpen,
LogOut,
Menu,
X,
@@ -26,6 +27,7 @@ const NAV = [
{ href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch },
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp },
{ href: '/pitch-admin/dataroom', label: 'Data Room', icon: FolderOpen },
{ href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
]
+589 -1
View File
@@ -1,9 +1,11 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Language } from '@/lib/types'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import { X } from 'lucide-react'
interface USPSlideProps { lang: Language }
@@ -30,6 +32,592 @@ const CSS_KF = `
`
// ── Light mode hook ───────────────────────────────────────────────────────────
function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
// ── Ticker ────────────────────────────────────────────────────────────────────
function useTicker(fn: () => void, min = 180, max = 420, skip = 0.1) {
const ref = useRef(fn)
ref.current = fn
useEffect(() => {
let t: ReturnType<typeof setTimeout>
const loop = () => {
if (Math.random() > skip) ref.current()
t = setTimeout(loop, min + Math.random() * (max - min))
}
loop()
return () => clearTimeout(t)
}, [min, max, skip])
}
function TickerShell({ tint, isLight, children }: { tint: string; isLight: boolean; children: React.ReactNode }) {
return (
<div style={{
...MONO, marginTop: 10, padding: '5px 9px',
background: isLight ? '#f1f5f9' : 'rgba(0,0,0,.38)',
border: `1px solid ${tint}55`,
borderRadius: 6, fontSize: 10.5,
color: isLight ? '#475569' : 'rgba(236,233,247,.88)',
display: 'flex', alignItems: 'center', gap: 7,
whiteSpace: 'nowrap', overflow: 'hidden', height: 22,
}}>{children}</div>
)
}
function TickTrace({ tint, isLight }: { tint: string; isLight: boolean }) {
const [n, setN] = useState(12748)
useTicker(() => setN(v => v + 1 + Math.floor(Math.random() * 3)), 250, 500)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>trace</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{n.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.45)' }}>evidence-chain</span>
</TickerShell>
)
}
function TickEngine({ tint, isLight }: { tint: string; isLight: boolean }) {
const [v, setV] = useState(428)
const [rate, setRate] = useState(99.4)
useTicker(() => {
setV(x => x + 1 + Math.floor(Math.random() * 4))
setRate(r => Math.max(97, Math.min(99.9, r + (Math.random() - 0.5) * 0.3)))
}, 220, 420)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>validate</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{v.toLocaleString()}</span>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>{rate.toFixed(1)}%</span>
</TickerShell>
)
}
function TickOptimizer({ tint, isLight }: { tint: string; isLight: boolean }) {
const ops = ['ROI: 2.418 € / dev', 'gap → policy §4.2', 'dedup 128 tickets', 'sweet-spot: 22 KLOC', 'tradeoff: speed↔risk']
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % ops.length), 900, 1600, 0.05)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: '#fbbf24' }}></span>
<span style={{ color: tint, opacity: .85 }}>optimize</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{ops[i]}</span>
</TickerShell>
)
}
function TickStack({ tint, isLight }: { tint: string; isLight: boolean }) {
const regs = ['DSGVO', 'NIS-2', 'DORA', 'EU AI Act', 'ISO 27001', 'BSI C5']
const [i, setI] = useState(0)
const [c, setC] = useState(1208)
useTicker(() => { setI(x => (x + 1) % regs.length); setC(v => v + Math.floor(Math.random() * 3)) }, 800, 1400, 0.05)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>check</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{regs[i]}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>·</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc' }}>{c.toLocaleString()}</span>
</TickerShell>
)
}
// ── Data ──────────────────────────────────────────────────────────────────────
interface DetailItem {
tint: string
icon: string
kicker: string
title: string
body: string
bullets?: string[]
stat?: { k: string; v: string }
}
function getDetails(de: boolean): Record<string, DetailItem> {
return {
rfq: {
tint: '#a78bfa', icon: '⇄',
kicker: de ? 'Säule · Compliance' : 'Pillar · Compliance',
title: de ? 'RFQ-Prüfung' : 'RFQ Verification',
body: de
? 'Kunden-Anforderungsdokumente werden automatisch gegen den aktuellen Source-Code geprüft. Abweichungen werden erkannt, Änderungen vorgeschlagen und auf Wunsch direkt im Code umgesetzt — ohne manuelles Nacharbeiten.'
: 'Customer requirement documents are automatically verified against current source code. Deviations are detected, changes proposed and implemented directly in code on request — no manual rework needed.',
bullets: de
? ['Klauseln automatisch gegen SBOM, SAST-Findings und Policy-Docs abgeglichen', 'Lücken mit konkreten Implementierungsvorschlägen markiert', 'RFQ-Antworten in Stunden statt Wochen']
: ['Auto-match clauses against SBOM, SAST findings and policy docs', 'Flag gaps with concrete implementation proposals', 'Win-ready RFQ replies in hours, not weeks'],
stat: { k: de ? 'Ø Antwortzeit' : 'avg response time', v: de ? '4,2h (war 12 Tage)' : '4.2h (was 12 days)' },
},
process: {
tint: '#c084fc', icon: '⟲',
kicker: de ? 'Säule · Compliance' : 'Pillar · Compliance',
title: de ? 'Prozess-Compliance' : 'Process Compliance',
body: de
? 'Vom Audit-Finding über das Ticket bis zur Code-Änderung läuft der gesamte Prozess automatisiert durch. Rollen, Fristen und Eskalation werden End-to-End verwaltet. Nachweise werden automatisch generiert und archiviert.'
: 'From audit finding to ticket to code change, the entire process runs automatically. Roles, deadlines and escalation are managed end-to-end. Evidence is automatically generated and archived.',
bullets: de
? ['Finding → Ticket → PR → Nachweis in einem Thread', 'SLA-Tracking pro Control mit Auto-Eskalation', 'Unveränderliches Audit-Log, pro Änderung signiert']
: ['Finding → ticket → PR → evidence in one thread', 'SLA tracking per control with auto-escalation', 'Immutable audit log signed per change'],
stat: { k: de ? 'automatisierte Prozessschritte' : 'process steps automated', v: '87%' },
},
bidir: {
tint: '#fbbf24', icon: '⟷',
kicker: de ? 'Säule · Code' : 'Pillar · Code',
title: de ? 'Bidirektional' : 'Bidirectional Sync',
body: de
? 'Compliance-Anforderungen fliessen direkt in den Code. Umgekehrt aktualisieren Code-Änderungen automatisch die Compliance-Dokumentation. Beide Seiten sind immer synchron — kein Informationsverlust zwischen Audit und Entwicklung.'
: 'Compliance requirements flow directly into code. Conversely, code changes automatically update compliance documentation. Both sides always stay in sync — no information loss between audit and development.',
bullets: de
? ['Policy ↔ Code-Mapping via semantischem Diff', 'Git-nativ: jede Änderung als PR', 'Zero Drift zwischen Audit-Artefakten und Realität']
: ['Policy ↔ code mapping via semantic diff', 'Git-native: every change shipped as a PR', 'Zero drift between audit artefacts and reality'],
stat: { k: de ? 'Drift-Vorfälle' : 'drift incidents', v: de ? '0 seit März 2024' : '0 since Mar-2024' },
},
cont: {
tint: '#f59e0b', icon: '◎',
kicker: de ? 'Säule · Code' : 'Pillar · Code',
title: de ? 'Kontinuierlich' : 'Continuous, Not Yearly',
body: de
? 'Klassische Compliance prüft einmal im Jahr und hofft auf das Beste. Unsere Plattform prüft bei jeder Code-Änderung. Findings werden sofort zu Tickets mit konkreten Implementierungsvorschlägen im Issue-Tracker der Wahl.'
: 'Traditional compliance checks once a year and hopes for the best. Our platform checks on every code change. Findings immediately become tickets with concrete implementation proposals in the issue tracker of choice.',
bullets: de
? ['CI-integrierte Validierung bei jedem Push', 'Fix-Vorschläge generiert, nicht nur gemeldet', 'Compliance-Frische: Minuten statt Monate']
: ['CI-integrated validation on each push', 'Fix suggestions generated, not just reported', 'Compliance freshness: minutes, not months'],
stat: { k: de ? 'Validierungen / Tag' : 'validations / day', v: '~2.400 / repo' },
},
trace: {
tint: '#a78bfa', icon: '⇄',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'End-to-End Rückverfolgbarkeit' : 'End-to-End Traceability',
body: de
? 'Regulatorische Anforderungen (Gesetz → Obligation → Control) deterministisch mit realem Systemzustand und Code verknüpft — inklusive revisionssicherem Evidence-Layer.'
: 'Regulatory requirements (law → obligation → control) deterministically linked to real system state and code — including audit-proof evidence layer.',
bullets: de
? ['Versionierter Evidence-Chain, unveränderlich gespeichert', 'Ein Klick von Klausel bis Codezeile', 'Signierte Attestierungen pro Build']
: ['Versioned evidence chain stored immutably', 'One-click drill from clause to line of code', 'Signed attestations per build'],
},
engine: {
tint: '#c084fc', icon: '◉',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'Continuous Compliance Engine' : 'Continuous Compliance Engine',
body: de
? 'Statt punktueller Audits: Validierung bei jeder Änderung (Code, Infrastruktur, Prozesse) mit auditierbaren Nachweisen in Echtzeit.'
: 'Instead of point-in-time audits: validation on every change (code, infrastructure, processes) with auditable evidence in real time.',
bullets: de
? ['Rule-Packs pro Framework (NIS-2, DORA, …)', 'Verarbeitet Code, IaC und Prozess-Events', 'Findings automatisch ans richtige Team geroutet']
: ['Rule packs per framework (NIS-2, DORA, …)', 'Handles code, infra-as-code, and process events', 'Findings routed to the right team automatically'],
},
opt: {
tint: '#fbbf24', icon: '✦',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'Compliance Optimizer' : 'Compliance Optimizer',
body: de
? 'Nicht nur „erlaubt/verboten", sondern die maximal zulässige Ausgestaltung jedes KI-Use-Cases. Deterministische Constraint-Optimierung zeigt den Sweet Spot zwischen Regulierung und Innovation — ersetzt 20200k EUR Anwaltskosten.'
: 'Not just "allowed/forbidden" but the maximum permissible configuration of every AI use case. Deterministic constraint optimization shows the sweet spot between regulation and innovation — replaces EUR 20200k in legal fees.',
bullets: de
? ['ROI-Ranking jedes offenen Findings', 'Abwägung zwischen Liefergeschwindigkeit und Restrisiko', 'Low-Hanging-Wins zuerst']
: ['ROI-ranks every open finding', 'Balances speed of delivery with residual risk', 'Highlights low-hanging wins first'],
},
stack: {
tint: '#f59e0b', icon: '◎',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'EU-Trust & Governance Stack' : 'EU Trust & Governance Stack',
body: de
? 'Souveräne, DSGVO-/AI-Act-konforme Architektur (EU-Hosting, Isolation, Betriebsrat-Fähigkeit) — Marktzugang, den US-Lösungen strukturell nicht erreichen.'
: 'Sovereign, GDPR/AI Act compliant architecture (EU hosting, isolation, works council capability) — market access that US solutions structurally cannot achieve.',
bullets: de
? ['DSGVO · NIS-2 · DORA · EU AI Act · ISO 27001 · BSI C5', 'EU-souveränes Hosting und Key-Management', 'Eine Plattform, ein Audit, eine Rechnung']
: ['DSGVO · NIS-2 · DORA · EU AI Act · ISO 27001 · BSI C5', 'EU-sovereign hosting and key-management', 'One platform, one audit, one bill'],
},
hub: {
tint: '#a78bfa', icon: '∞',
kicker: de ? 'Die Schleife' : 'The Loop',
title: de ? 'Compliance ↔ Code · Immer in Sync' : 'Compliance ↔ Code · Always in sync',
body: de
? 'Die Plattform ist eine einzige geschlossene Schleife. Jede Policy-Änderung fliesst in den Code; jede Code-Änderung fliesst in die Policy zurück.'
: 'The platform is a single closed loop. Every policy change ripples into code; every code change ripples back into policy. That\'s the USP in one diagram.',
bullets: de
? ['Single Source of Truth, zwei Oberflächen', 'Echtzeit-Sync, kein Batch-Abgleich', 'Auditoren, Entwickler und Sales fragen denselben Graphen ab']
: ['Single source of truth, two surfaces', 'Real-time sync, not batch reconciliation', 'Auditors, engineers and sales all query the same graph'],
},
}
}
// ── Pillar row ────────────────────────────────────────────────────────────────
function PillarRow({ side, title, body, tint, onClick, active, isLight }: {
side: 'left' | 'right'
title: string; body: string; tint: string
onClick: () => void; active: boolean; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
const isLeft = side === 'left'
return (
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
flexDirection: isLeft ? 'row-reverse' : 'row',
textAlign: isLeft ? 'right' : 'left',
padding: '10px 14px', borderRadius: 10, cursor: 'pointer',
transition: 'transform .25s, background .25s, box-shadow .25s',
background: lit
? `linear-gradient(${isLeft ? '270deg' : '90deg'}, ${tint}24 0%, ${tint}0a 70%, transparent 100%)`
: 'transparent',
boxShadow: lit
? `0 10px 30px ${tint}26, inset 0 0 0 1px ${tint}44`
: 'inset 0 0 0 1px transparent',
transform: lit ? (isLeft ? 'translateX(-3px)' : 'translateX(3px)') : 'translateX(0)',
}}
>
<div style={{
flex: '0 0 30px', width: 30, height: 30, borderRadius: 9,
background: lit ? `${tint}3a` : `${tint}22`,
border: `1px solid ${lit ? tint : tint + '66'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: lit ? (isLight ? tint : '#fff') : tint, fontSize: 13, fontWeight: 700, marginTop: 2,
boxShadow: lit ? `0 0 14px ${tint}88, inset 0 1px 0 ${tint}80` : `inset 0 1px 0 ${tint}50`,
transition: 'all .25s',
}}></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700,
color: isLight ? '#1a1a2e' : '#f7f5fc',
letterSpacing: -0.15, marginBottom: 3,
display: 'flex', alignItems: 'center', gap: 6,
justifyContent: isLeft ? 'flex-end' : 'flex-start',
}}>
<span>{title}</span>
<span style={{
fontSize: 10, color: tint, opacity: lit ? 1 : 0,
transform: `translateX(${lit ? 0 : (isLeft ? 4 : -4)}px)`,
transition: 'all .25s',
}}>{isLeft ? '' : ''}</span>
</div>
<div style={{
fontSize: 11, lineHeight: 1.55,
color: isLight
? `rgba(71,85,105,${lit ? 1 : .78})`
: `rgba(236,233,247,${lit ? .82 : .62})`,
transition: 'color .25s',
}}>{body}</div>
</div>
</div>
)
}
// ── Column header ─────────────────────────────────────────────────────────────
function ColHeader({ side, label, color, icon, sub, isLight }: {
side: 'left' | 'right'; label: string; color: string; icon: string; sub: string; isLight: boolean
}) {
const isLeft = side === 'left'
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
flexDirection: isLeft ? 'row-reverse' : 'row',
paddingBottom: 10, borderBottom: `1px solid ${color}35`,
}}>
<div style={{
width: 34, height: 34, borderRadius: 9,
background: `linear-gradient(135deg, ${color}55, ${color}20)`,
border: `1px solid ${color}88`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? color : '#fff', fontSize: 15, fontWeight: 700,
boxShadow: `0 0 18px ${color}55, inset 0 1px 0 ${color}aa`,
}}>{icon}</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.3, lineHeight: 1 }}>{label}</div>
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color, opacity: .75, marginTop: 3, textTransform: 'uppercase' as const }}>{sub}</div>
</div>
</div>
)
}
// ── Central hub ───────────────────────────────────────────────────────────────
function CentralHub({ caption, isLight }: { caption: string; isLight: boolean }) {
return (
<div style={{ position: 'relative', width: 260, height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
position: 'relative', width: 120, height: 120, borderRadius: '50%',
background: 'radial-gradient(circle at 32% 28%, #f0e9ff 0%, #c4aaff 26%, #7b5cd6 58%, #2a1560 100%)',
border: '1.5px solid rgba(216,202,255,.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: isLight
? '0 0 30px rgba(167,139,250,.4), 0 0 60px rgba(167,139,250,.15), inset 0 3px 0 rgba(255,255,255,.5), inset 0 -8px 14px rgba(0,0,0,.2)'
: '0 0 50px rgba(167,139,250,.65), 0 0 100px rgba(167,139,250,.25), inset 0 3px 0 rgba(255,255,255,.35), inset 0 -8px 14px rgba(0,0,0,.35)',
animation: isLight ? 'uspPulseLight 2.6s ease-in-out infinite' : 'uspPulse 2.6s ease-in-out infinite',
zIndex: 3,
}}>
<div style={{ position: 'absolute', inset: -14, borderRadius: '50%', border: `1px dashed ${isLight ? 'rgba(167,139,250,.5)' : 'rgba(216,202,255,.42)'}`, animation: 'uspSpin 14s linear infinite' }} />
<div style={{ position: 'absolute', inset: -30, borderRadius: '50%', border: `1px dashed ${isLight ? 'rgba(167,139,250,.3)' : 'rgba(216,202,255,.2)'}`, animation: 'uspSpin 22s linear infinite reverse' }} />
<svg width="54" height="26" viewBox="0 0 54 26" fill="none" stroke="#fff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round"
style={{ filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.5))' }}>
<path d="M 10 13 C 10 5, 22 5, 27 13 C 32 21, 44 21, 44 13 C 44 5, 32 5, 27 13 C 22 21, 10 21, 10 13 Z" />
</svg>
</div>
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 24, textAlign: 'center',
...MONO, fontSize: 9.5, letterSpacing: 2.5,
color: isLight ? 'rgba(109,77,194,.75)' : 'rgba(216,202,255,.75)',
textTransform: 'uppercase' as const, fontWeight: 600,
}}>{caption}</div>
</div>
)
}
// ── Bridge SVG connectors ─────────────────────────────────────────────────────
function BridgeConnectors({ isLight }: { isLight: boolean }) {
const rfpY = 130
const sub2Y = 250
const hubCx = 500
const hubR = 72
return (
<svg viewBox="0 0 1000 400" preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
<defs>
<linearGradient id="uspFromL" x1="0" x2="1">
<stop offset="0" stopColor="#a78bfa" stopOpacity="0" />
<stop offset=".3" stopColor="#a78bfa" stopOpacity={isLight ? '.6' : '.85'} />
<stop offset="1" stopColor="#c084fc" stopOpacity={isLight ? '.2' : '.3'} />
</linearGradient>
<linearGradient id="uspToR" x1="0" x2="1">
<stop offset="0" stopColor="#c084fc" stopOpacity={isLight ? '.2' : '.3'} />
<stop offset=".7" stopColor="#fbbf24" stopOpacity={isLight ? '.6' : '.85'} />
<stop offset="1" stopColor="#fbbf24" stopOpacity="0" />
</linearGradient>
</defs>
<line x1="40" y1={rfpY} x2={hubCx - hubR} y2={rfpY}
stroke="url(#uspFromL)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1={hubCx + hubR} y1={rfpY} x2="960" y2={rfpY}
stroke="url(#uspToR)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1="40" y1={sub2Y} x2={hubCx - hubR} y2={sub2Y}
stroke="url(#uspFromL)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1={hubCx + hubR} y1={sub2Y} x2="960" y2={sub2Y}
stroke="url(#uspToR)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
{([rfpY, sub2Y] as number[]).map(y => (
<g key={y}>
<circle cx={hubCx - hubR} cy={y} r="4" fill={isLight ? '#eef2ff' : '#1a0f34'} stroke="#a78bfa" strokeWidth="1.2" />
<circle cx={hubCx - hubR} cy={y} r="1.5" fill="#a78bfa" />
<circle cx={hubCx + hubR} cy={y} r="4" fill={isLight ? '#eef2ff' : '#1a0f34'} stroke="#fbbf24" strokeWidth="1.2" />
<circle cx={hubCx + hubR} cy={y} r="1.5" fill="#fbbf24" />
</g>
))}
<circle r="3" fill="#c4aaff" style={{ filter: 'drop-shadow(0 0 6px #a78bfa)' }}>
<animate attributeName="cx" from="40" to="960" dur="3.5s" repeatCount="indefinite" />
<animate attributeName="cy" values={`${rfpY};${rfpY}`} dur="3.5s" repeatCount="indefinite" />
</circle>
<circle r="3" fill="#fde68a" style={{ filter: 'drop-shadow(0 0 6px #fbbf24)' }}>
<animate attributeName="cx" from="960" to="40" dur="3.5s" repeatCount="indefinite" />
<animate attributeName="cy" values={`${sub2Y};${sub2Y}`} dur="3.5s" repeatCount="indefinite" />
</circle>
</svg>
)
}
// ── Under-the-hood feature card ───────────────────────────────────────────────
function FeatureCard({ icon, title, body, tint, Ticker, onClick, active, isLight }: {
icon: string; title: string; body: string; tint: string
Ticker: React.ComponentType<{ tint: string; isLight: boolean }>
onClick: () => void; active: boolean; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
return (
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'relative', padding: '13px 15px',
background: isLight
? lit
? `linear-gradient(180deg, ${tint}18 0%, ${tint}08 55%, rgba(248,250,252,.95) 100%)`
: 'linear-gradient(180deg, #ffffff, #f8fafc)'
: `linear-gradient(180deg, ${tint}${lit ? '2a' : '1a'} 0%, ${tint}07 55%, rgba(14,8,28,.85) 100%)`,
border: `1px solid ${lit ? tint : isLight ? 'rgba(0,0,0,.1)' : tint + '4a'}`,
borderRadius: 12,
boxShadow: lit
? `0 18px 40px ${tint}33, 0 0 0 1px ${tint}66, inset 0 1px 0 ${tint}60`
: isLight
? '0 2px 8px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.8)'
: `0 10px 24px rgba(0,0,0,.4), inset 0 1px 0 ${tint}35`,
minWidth: 0, cursor: 'pointer',
transform: lit ? 'translateY(-3px)' : 'translateY(0)',
transition: 'transform .25s, box-shadow .25s, background .25s, border-color .25s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: lit ? `${tint}44` : `${tint}22`,
border: `1px solid ${lit ? tint : tint + '66'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: lit ? (isLight ? tint : '#fff') : tint, fontSize: 12,
boxShadow: lit ? `0 0 12px ${tint}88` : 'none',
transition: 'all .25s',
}}>{icon}</span>
<span style={{ fontSize: 12.5, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.15, flex: 1 }}>{title}</span>
<span style={{ fontSize: 10, color: tint, opacity: lit ? 1 : 0.5, transform: `translateX(${lit ? 0 : -3}px)`, transition: 'all .25s' }}></span>
</div>
<div style={{
fontSize: 11, lineHeight: 1.45,
color: isLight
? `rgba(71,85,105,${lit ? 1 : .78})`
: `rgba(236,233,247,${lit ? .82 : .65})`,
transition: 'color .25s',
}}>{body}</div>
<Ticker tint={tint} isLight={isLight} />
</div>
)
}
// ── Detail modal ──────────────────────────────────────────────────────────────
function DetailModal({ item, onClose, isLight }: { item: DetailItem | null; onClose: () => void; isLight: boolean }) {
useEffect(() => {
if (!item) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [item, onClose])
return (
<AnimatePresence>
{item && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{
position: 'absolute', inset: 0, zIndex: 50,
background: isLight ? 'rgba(240,244,255,.72)' : 'rgba(5,2,16,.72)',
backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.94 }}
transition={{ duration: 0.22 }}
onClick={e => e.stopPropagation()}
style={{
width: 560, maxWidth: '88%',
background: isLight
? `linear-gradient(180deg, ${item.tint}10 0%, rgba(255,255,255,.98) 50%, rgba(248,250,252,.99) 100%)`
: `linear-gradient(180deg, ${item.tint}18 0%, rgba(20,10,40,.96) 50%, rgba(14,8,28,.98) 100%)`,
border: `1px solid ${item.tint}${isLight ? '44' : '66'}`,
borderRadius: 16,
boxShadow: isLight
? `0 20px 60px rgba(0,0,0,.12), 0 0 40px ${item.tint}18, inset 0 1px 0 rgba(255,255,255,.9)`
: `0 30px 80px rgba(0,0,0,.6), 0 0 60px ${item.tint}33, inset 0 1px 0 ${item.tint}55`,
padding: '22px 26px',
color: isLight ? '#1a1a2e' : '#ece9f7',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{
width: 38, height: 38, borderRadius: 10,
background: `linear-gradient(135deg, ${item.tint}66, ${item.tint}22)`,
border: `1px solid ${item.tint}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? item.tint : '#fff', fontSize: 16, fontWeight: 700,
boxShadow: `0 0 18px ${item.tint}66`,
}}>{item.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2.5, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 600, marginBottom: 2 }}>
{item.kicker}
</div>
<div style={{ fontSize: 19, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.3 }}>{item.title}</div>
</div>
<button onClick={onClose} style={{
background: 'transparent', border: `1px solid ${item.tint}55`,
borderRadius: 8, cursor: 'pointer', width: 30, height: 30,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? '#64748b' : 'rgba(236,233,247,.6)',
}}>
<X style={{ width: 14, height: 14 }} />
</button>
</div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: isLight ? '#475569' : 'rgba(236,233,247,.82)', marginBottom: 16 }}>
{item.body}
</div>
{item.bullets && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{item.bullets.map((b, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '8px 12px', borderRadius: 8,
background: isLight ? 'rgba(0,0,0,.04)' : 'rgba(0,0,0,.3)',
border: `1px solid ${item.tint}${isLight ? '22' : '33'}`,
}}>
<span style={{ color: item.tint, fontSize: 12, marginTop: 1 }}></span>
<span style={{ fontSize: 12, lineHeight: 1.5, color: isLight ? '#475569' : 'rgba(236,233,247,.78)' }}>{b}</span>
</div>
))}
</div>
)}
{item.stat && (
<div style={{
...MONO, padding: '10px 14px', borderRadius: 8,
background: isLight ? 'rgba(0,0,0,.04)' : 'rgba(0,0,0,.45)',
border: `1px solid ${item.tint}${isLight ? '33' : '55'}`,
fontSize: 12, color: isLight ? '#475569' : 'rgba(236,233,247,.9)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: item.tint }}>{item.stat.k}</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{item.stat.v}</span>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// ── Star field ────────────────────────────────────────────────────────────────
function StarField({ isLight }: { isLight: boolean }) {
const stars = useMemo(() => {
let s = 41
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
return Array.from({ length: 90 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
}, [])
if (isLight) return null
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{stars.map((st, i) => (
<div key={i} style={{
position: 'absolute', left: `${st.x}%`, top: `${st.y}%`,
width: st.size, height: st.size, borderRadius: '50%',
background: '#fff', opacity: st.op,
boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)`,
}} />
))}
</div>
)
}
// ── Main slide ────────────────────────────────────────────────────────────────
export default function USPSlide({ lang }: USPSlideProps) {
const de = lang === 'de'
const isLight = useIsLight()
+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)
}
+2 -2
View File
@@ -160,10 +160,10 @@ export async function sendMagicLinkEmail(
Datenschutzhinweis / Privacy Notice
</p>
<p style="margin:0 0 6px;font-size:10px;color:rgba(255,255,255,0.15);line-height:1.5;">
Beim Aufruf des obenstehenden Links werden aus Sicherheitsgründen technische Zugriffsdaten (insbesondere Ihre IP-Adresse sowie Zeitpunkt des Zugriffs) verarbeitet. Dies dient ausschließlich der sicheren Bereitstellung des Zugangs und der Verhinderung von Missbrauch. Die Daten werden nach spätestens 72 Stunden gelöscht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der IT-Sicherheit). Weitere Informationen zum Datenschutz erhalten Sie auf Anfrage.
Beim Aufruf des obenstehenden Links werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie Ihre personenbezogenen Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Speicherdauer: max. 30 Tage nach letztem Zugriff, danach automatische Anonymisierung. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO. Ihre Rechte gem. Art. 1521 DSGVO (Auskunft, Berichtigung, Löschung, Widerspruch): pitch@breakpilot.ai. Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).
</p>
<p style="margin:0;font-size:10px;color:rgba(255,255,255,0.12);line-height:1.5;">
When accessing the link above, technical access data (in particular your IP address and time of access) is processed for security purposes. This serves exclusively to ensure secure access and prevent misuse. Data is deleted after 72 hours at the latest. Legal basis: Art. 6(1)(f) GDPR (legitimate interest in IT security). Further information on data protection is available on request.
When you access the link above, technical access data (IP address, timestamp, browser) and your personal contact data (email, name, company) are processed. Purpose: access management and misuse prevention. Retention: max. 30 days after last access, then automatic anonymisation. Legal basis: Art. 6(1)(f) GDPR. Your rights under Art. 1521 GDPR (access, rectification, erasure, objection): pitch@breakpilot.ai. Supervisory authority: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).
</p>
<p style="margin:8px 0 0;font-size:10px;color:rgba(255,255,255,0.15);line-height:1.5;">
Verantwortlich / Controller: Benjamin Bönisch &amp; Sharang Parnerkar · Kontakt / Contact: info@breakpilot.com
+97
View File
@@ -0,0 +1,97 @@
import pool from '@/lib/db'
const MASKING_DAYS = parseInt(process.env.DATA_MASKING_DAYS || '30')
const NEVER_ACTIVATED_DAYS = parseInt(process.env.NEVER_ACTIVATED_DAYS || '90')
export interface CleanupStats {
investors_masked: number
sessions_deleted: number
audit_ips_anonymized: number
audit_details_redacted: number
magic_links_deleted: number
}
export async function runDataCleanup(): Promise<CleanupStats> {
const stats: CleanupStats = {
investors_masked: 0,
sessions_deleted: 0,
audit_ips_anonymized: 0,
audit_details_redacted: 0,
magic_links_deleted: 0,
}
const activeCutoff = new Date(Date.now() - MASKING_DAYS * 24 * 60 * 60 * 1000)
const neverActivatedCutoff = new Date(Date.now() - NEVER_ACTIVATED_DAYS * 24 * 60 * 60 * 1000)
// 1. Mask investors inactive for MASKING_DAYS
const { rows: maskedActive } = await pool.query<{ id: string }>(
`UPDATE pitch_investors
SET email = 'anon.' || id::text,
name = NULL, company = NULL,
status = 'revoked', data_masked_at = NOW(), updated_at = NOW()
WHERE last_login_at IS NOT NULL
AND last_login_at < $1
AND data_masked_at IS NULL
RETURNING id`,
[activeCutoff],
)
// 2. Mask investors invited but never activated after NEVER_ACTIVATED_DAYS
const { rows: maskedNever } = await pool.query<{ id: string }>(
`UPDATE pitch_investors
SET email = 'anon.' || id::text,
name = NULL, company = NULL,
status = 'revoked', data_masked_at = NOW(), updated_at = NOW()
WHERE last_login_at IS NULL
AND created_at < $1
AND data_masked_at IS NULL
RETURNING id`,
[neverActivatedCutoff],
)
const allMaskedIds = [...maskedActive, ...maskedNever].map((r) => r.id)
stats.investors_masked = allMaskedIds.length
if (allMaskedIds.length > 0) {
// 3. Revoke sessions for newly masked investors
await pool.query(
`UPDATE pitch_sessions SET revoked = true
WHERE investor_id = ANY($1::uuid[]) AND NOT revoked`,
[allMaskedIds],
)
// 4. Redact email field from audit log details for masked investors
const { rowCount } = await pool.query(
`UPDATE pitch_audit_logs
SET details = details - 'email'
WHERE investor_id = ANY($1::uuid[]) AND details ? 'email'`,
[allMaskedIds],
)
stats.audit_details_redacted = rowCount ?? 0
}
// 5. Delete sessions older than MASKING_DAYS (expired, contain IP/UA)
const { rowCount: sessionsDeleted } = await pool.query(
`DELETE FROM pitch_sessions WHERE created_at < $1`,
[activeCutoff],
)
stats.sessions_deleted = sessionsDeleted ?? 0
// 6. Anonymize IP addresses in audit logs older than MASKING_DAYS
const { rowCount: logsAnonymized } = await pool.query(
`UPDATE pitch_audit_logs
SET ip_address = NULL
WHERE created_at < $1 AND ip_address IS NOT NULL`,
[activeCutoff],
)
stats.audit_ips_anonymized = logsAnonymized ?? 0
// 7. Delete magic links older than MASKING_DAYS
const { rowCount: linksDeleted } = await pool.query(
`DELETE FROM pitch_magic_links WHERE created_at < $1`,
[activeCutoff],
)
stats.magic_links_deleted = linksDeleted ?? 0
return stats
}
+28
View File
@@ -0,0 +1,28 @@
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
const LITELLM_API_KEY = process.env.LITELLM_API_KEY || ''
export async function translateText(text: string, from: 'de' | 'en'): Promise<string | null> {
if (!text.trim()) return null
const toLang = from === 'de' ? 'English' : 'German'
try {
const r = await fetch(`${LITELLM_URL}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${LITELLM_API_KEY}` },
body: JSON.stringify({
model: LITELLM_MODEL,
messages: [
{ role: 'system', content: `Translate the following text to ${toLang}. Output only the translated text, nothing else.` },
{ role: 'user', content: text },
],
max_tokens: 1000,
temperature: 0.1,
}),
})
if (!r.ok) return null
const data = await r.json()
return (data.choices?.[0]?.message?.content as string | undefined)?.trim() || null
} catch {
return null
}
}
@@ -0,0 +1,9 @@
-- Investor data masking: track first activity and enforce 72h anonymization window
ALTER TABLE pitch_investors
ADD COLUMN IF NOT EXISTS first_activity_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS data_masked_at TIMESTAMPTZ;
-- Partial index for fast masking-eligibility scans
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;
+1 -1
View File
@@ -17,5 +17,5 @@
"target": "ES2018"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "mcp-server"]
}