From 269464943e55817f768f4524d14c31438446edf2 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:05:42 +0200 Subject: [PATCH 01/11] fix(pitch-deck): restore complete USPSlide with all helper functions The previously committed version was missing useIsLight hook, all sub-components (PillarRow, ColHeader, CentralHub, BridgeConnectors, FeatureCard, DetailModal, StarField, ticker components) and their data/types. Only the main component shell was present, causing a CI build failure on type-check. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/components/slides/USPSlide.tsx | 590 +++++++++++++++++++++- 1 file changed, 589 insertions(+), 1 deletion(-) diff --git a/pitch-deck/components/slides/USPSlide.tsx b/pitch-deck/components/slides/USPSlide.tsx index 8e7ae49..1e1e6be 100644 --- a/pitch-deck/components/slides/USPSlide.tsx +++ b/pitch-deck/components/slides/USPSlide.tsx @@ -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 + 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 ( +
{children}
+ ) +} + +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 ( + + + trace + {n.toLocaleString()} + evidence-chain + + ) +} + +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 ( + + + validate + {v.toLocaleString()} + {rate.toFixed(1)}% + + ) +} + +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 ( + + + optimize + {ops[i]} + + ) +} + +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 ( + + + check + {regs[i]} + · + {c.toLocaleString()} + + ) +} + +// ── 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 { + 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 20–200k 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 20–200k 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 ( +
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)', + }} + > +
+
+
+ {title} + {isLeft ? '‹' : '›'} +
+
{body}
+
+
+ ) +} + +// ── 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 ( +
+
{icon}
+
+
{label}
+
{sub}
+
+
+ ) +} + +// ── Central hub ─────────────────────────────────────────────────────────────── +function CentralHub({ caption, isLight }: { caption: string; isLight: boolean }) { + return ( +
+
+
+
+ + + +
+
{caption}
+
+ ) +} + +// ── Bridge SVG connectors ───────────────────────────────────────────────────── +function BridgeConnectors({ isLight }: { isLight: boolean }) { + const rfpY = 130 + const sub2Y = 250 + const hubCx = 500 + const hubR = 72 + return ( + + + + + + + + + + + + + + + + + + + + {([rfpY, sub2Y] as number[]).map(y => ( + + + + + + + ))} + + + + + + + + + + + ) +} + +// ── 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 ( +
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', + }} + > +
+ {icon} + {title} + +
+
{body}
+ +
+ ) +} + +// ── 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 ( + + {item && ( + + 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', + }} + > +
+
{item.icon}
+
+
+ {item.kicker} +
+
{item.title}
+
+ +
+
+ {item.body} +
+ {item.bullets && ( +
+ {item.bullets.map((b, i) => ( +
+ + {b} +
+ ))} +
+ )} + {item.stat && ( +
+ + {item.stat.k} + {item.stat.v} +
+ )} +
+
+ )} +
+ ) +} + +// ── 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 ( +
+ {stars.map((st, i) => ( +
+ ))} +
+ ) +} + +// ── Main slide ──────────────────────────────────────────────────────────────── export default function USPSlide({ lang }: USPSlideProps) { const de = lang === 'de' const isLight = useIsLight() From adfff6cfe4dbea748f9d77bd3122169cc336de46 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:11:40 +0200 Subject: [PATCH 02/11] fix(pitch-deck): exclude mcp-server from Next.js tsconfig + resolve FinanzplanSlide conflict - tsconfig.json: add mcp-server to exclude list so the standalone MCP package's imports don't break the Next.js type-check build - FinanzplanSlide.tsx: resolve merge conflict, keep MonthlyGrid refactor from upstream (discards superseded inline table from stash) Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pitch-deck/tsconfig.json b/pitch-deck/tsconfig.json index c446e16..0e73cc0 100644 --- a/pitch-deck/tsconfig.json +++ b/pitch-deck/tsconfig.json @@ -17,5 +17,5 @@ "target": "ES2018" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "mcp-server"] } From 23b233bda352ad427d465a05f5c6afe4779dbbfb Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:55:29 +0200 Subject: [PATCH 03/11] feat(pitch-admin): generate magic link + 72h investor data masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /api/admin/investors/[id]/generate-link endpoint: creates a magic link without sending email, returns the URL for the admin to copy and share manually (for when email is filtered) - Adds 'Copy Link' button (emerald) to investor list and detail pages; link is copied to clipboard on click - New lib/masking.ts: maskOverdueInvestors() UPDATE that anonymizes email/name/company → revokes sessions 72h after first investor login - first_activity_at recorded on first verify (COALESCE, set once only) - migration 004 adds first_activity_at + data_masked_at columns with partial index; also wired into /api/admin/migrate for one-shot apply - Admin UI shows 'anonymized' badge, expiry countdown, and masked state; Copy Link + Resend are disabled for anonymized investors - verify route returns 410 if data_masked_at is set (belt-and-suspenders alongside the revoked status check) Co-Authored-By: Claude Sonnet 4.6 --- .../investors/[id]/generate-link/route.ts | 60 +++++++++++++++ .../app/api/admin/investors/[id]/route.ts | 6 +- pitch-deck/app/api/admin/investors/route.ts | 4 + pitch-deck/app/api/admin/migrate/route.ts | 6 ++ pitch-deck/app/api/auth/verify/route.ts | 17 ++++- .../(authed)/investors/[id]/page.tsx | 74 +++++++++++++++---- .../pitch-admin/(authed)/investors/page.tsx | 53 ++++++++++--- pitch-deck/lib/masking.ts | 30 ++++++++ .../migrations/004_investor_masking.sql | 9 +++ 9 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts create mode 100644 pitch-deck/lib/masking.ts create mode 100644 pitch-deck/migrations/004_investor_masking.sql diff --git a/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts new file mode 100644 index 0000000..d0586fc --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { generateToken } from '@/lib/auth' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function POST(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + + const { rows } = await pool.query( + `SELECT id, email, name, status, data_masked_at FROM pitch_investors WHERE id = $1`, + [id], + ) + if (rows.length === 0) { + return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) + } + + const investor = rows[0] + if (investor.data_masked_at) { + return NextResponse.json( + { error: 'Investor data has been anonymized after the 72h window. Cannot generate a new link.' }, + { status: 410 }, + ) + } + if (investor.status === 'revoked') { + return NextResponse.json( + { error: 'Investor is revoked. Re-invite to reactivate.' }, + { status: 400 }, + ) + } + + const token = generateToken() + const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72') + const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000) + + await pool.query( + `INSERT INTO pitch_magic_links (investor_id, token, expires_at) VALUES ($1, $2, $3)`, + [investor.id, token, expiresAt], + ) + + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + const url = `${baseUrl}/auth/verify?token=${token}` + + await logAdminAudit( + adminId, + 'magic_link_generated', + { email: investor.email, expires_at: expiresAt.toISOString(), channel: 'manual_copy' }, + request, + investor.id, + ) + + return NextResponse.json({ url, expires_at: expiresAt.toISOString() }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts index 56a7ff1..3998a25 100644 --- a/pitch-deck/app/api/admin/investors/[id]/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' +import { maskOverdueInvestors } 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 maskOverdueInvestors() + const [investor, sessions, snapshots, audit] = await Promise.all([ pool.query( `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, - i.created_at, i.updated_at, i.assigned_version_id, + i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at, + i.assigned_version_id, v.name AS version_name, v.status AS version_status FROM pitch_investors i LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts index 0ba4ccf..85d8715 100644 --- a/pitch-deck/app/api/admin/investors/route.ts +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin } from '@/lib/admin-auth' +import { maskOverdueInvestors } from '@/lib/masking' export async function GET(request: NextRequest) { const guard = await requireAdmin(request) if (guard.kind === 'response') return guard.response + await maskOverdueInvestors() + const { rows } = await pool.query( `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at, + i.first_activity_at, i.data_masked_at, i.assigned_version_id, v.name AS version_name, (SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed, (SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index 149cdd1..63169a5 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -10,6 +10,12 @@ 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`, `CREATE TABLE IF NOT EXISTS fp_scenarios ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL DEFAULT 'Base Case', diff --git a/pitch-deck/app/api/auth/verify/route.ts b/pitch-deck/app/api/auth/verify/route.ts index 2b29e6c..8e1e545 100644 --- a/pitch-deck/app/api/auth/verify/route.ts +++ b/pitch-deck/app/api/auth/verify/route.ts @@ -21,7 +21,9 @@ export async function POST(request: NextRequest) { // Find the magic link const { rows } = await pool.query( - `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status + `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, + i.email, i.status as investor_status, + i.first_activity_at, i.data_masked_at FROM pitch_magic_links ml JOIN pitch_investors i ON i.id = ml.investor_id WHERE ml.token = $1`, @@ -45,6 +47,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 }) } + if (link.data_masked_at) { + return NextResponse.json({ error: 'This access period has ended and data has been anonymized.' }, { status: 410 }) + } + if (link.investor_status === 'revoked') { await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request) return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 }) @@ -58,9 +64,14 @@ export async function POST(request: NextRequest) { [ip, ua, link.id] ) - // Activate investor if first login + // Activate investor if first login; record first_activity_at once await pool.query( - `UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW() + `UPDATE pitch_investors + SET status = 'active', + last_login_at = NOW(), + login_count = login_count + 1, + first_activity_at = COALESCE(first_activity_at, NOW()), + updated_at = NOW() WHERE id = $1`, [link.investor_id] ) diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx index f1cd6ac..d642dce 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -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 = { 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() { ) : ( <>
-

{inv.name || inv.email}

- - {inv.status} +

+ {inv.data_masked_at ? [data protected] : (inv.name || inv.email)} +

+ + {inv.data_masked_at ? 'anonymized' : inv.status}
-
{inv.company || '—'}
-
{inv.email}
+ {inv.data_masked_at ? ( +
+ Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 72h window elapsed after first activity +
+ ) : ( + <> +
{inv.company || '—'}
+
{inv.email}
+ {inv.first_activity_at && ( +
+ ⏱ Data window: 72h from first login · expires {new Date(new Date(inv.first_activity_at).getTime() + 72 * 60 * 60 * 1000).toLocaleString()} +
+ )} + + )} )}
@@ -197,18 +233,28 @@ export default function InvestorDetailPage() { ) : ( <> + {!inv.data_masked_at && ( + + )}
-
Last login
+
First activity
- {inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'} + {inv.first_activity_at ? new Date(inv.first_activity_at).toLocaleString() : '—'}
diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx index 082abde..7725c5e 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx @@ -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 = { 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() { -
{inv.name || inv.email}
+
+ {inv.data_masked_at ? [data protected] : (inv.name || inv.email)} +
- {inv.company ? `${inv.company} · ` : ''}{inv.email} + {inv.data_masked_at + ? `Anonymized ${new Date(inv.data_masked_at).toLocaleDateString()}` + : `${inv.company ? `${inv.company} · ` : ''}${inv.email}`}
- - {inv.status} + + {inv.data_masked_at ? 'anonymized' : inv.status} {inv.login_count} @@ -189,12 +214,20 @@ export default function InvestorsPage() { + + +
+ +

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

+ + {uploads.length === 0 ? ( +
+ +

No files uploaded yet.

+
+ ) : ( +
+ {uploads.map(u => ( +
+ +
+
{u.display_name || u.filename}
+
{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}
+
+ Received +
+ ))} +
+ )} + + + + {toast && ( +
+ {toast} +
+ )} + + ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx b/pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx new file mode 100644 index 0000000..c7ec07a --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/dataroom/page.tsx @@ -0,0 +1,343 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { Upload, FileText, Trash2, X, Share2, Users, ChevronDown, Check, Download } from 'lucide-react' + +interface Doc { + id: string + filename: string + display_name: string + mime_type: string + file_size: number + uploaded_by: string + created_at: string + release_count: number +} + +interface Release { + id: string + investor_id: string + email: string + name: string | null + company: string | null + released_at: string + data_masked_at: string | null +} + +interface Investor { + id: string + email: string + name: string | null + company: string | null + status: string + data_masked_at: string | null +} + +interface InvestorUpload { + id: string + filename: string + display_name: string + mime_type: string + file_size: number + created_at: string +} + +function fmt(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export default function DataroomPage() { + const [docs, setDocs] = useState([]) + const [investors, setInvestors] = useState([]) + const [selected, setSelected] = useState(null) + const [releases, setReleases] = useState([]) + const [uploading, setUploading] = useState(false) + const [busy, setBusy] = useState(false) + const [toast, setToast] = useState(null) + const [tab, setTab] = useState<'documents' | 'uploads'>('documents') + const [investorUploads, setInvestorUploads] = useState>({}) + const fileRef = useRef(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 = {} + for (const inv of investors) { + const r = await fetch(`/api/admin/dataroom/investors/${inv.id}/uploads`) + if (r.ok) results[inv.id] = (await r.json()).uploads + } + setInvestorUploads(results) + } + + useEffect(() => { loadDocs(); loadInvestors() }, []) + useEffect(() => { if (tab === 'uploads' && investors.length > 0) loadInvestorUploads() }, [tab, investors]) + + async function selectDoc(doc: Doc) { + setSelected(doc) + await loadReleases(doc.id) + } + + async function uploadFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + const fd = new FormData() + fd.append('file', file) + const r = await fetch('/api/admin/dataroom/documents', { method: 'POST', body: fd }) + setUploading(false) + if (r.ok) { flash('Uploaded'); loadDocs() } + else { const d = await r.json().catch(() => ({})); flash(d.error || 'Upload failed') } + e.target.value = '' + } + + async function deleteDoc(id: string) { + if (!confirm('Delete this document? All releases will be removed.')) return + setBusy(true) + const r = await fetch(`/api/admin/dataroom/documents/${id}`, { method: 'DELETE' }) + setBusy(false) + if (r.ok) { flash('Deleted'); setSelected(null); loadDocs() } + else flash('Delete failed') + } + + async function toggleRelease(investorId: string, hasRelease: boolean) { + if (!selected) return + setBusy(true) + if (hasRelease) { + await fetch(`/api/admin/dataroom/documents/${selected.id}/release/${investorId}`, { method: 'DELETE' }) + } else { + await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ investor_ids: [investorId] }), + }) + } + setBusy(false) + await loadReleases(selected.id) + loadDocs() + } + + async function releaseAll() { + if (!selected || investors.length === 0) return + setBusy(true) + await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ investor_ids: investors.map(i => i.id) }), + }) + setBusy(false) + await loadReleases(selected.id) + loadDocs() + } + + const releasedIds = new Set(releases.map(r => r.investor_id)) + const allInvestors = investors.filter(i => !i.data_masked_at) + const investorsWithUploads = allInvestors.filter(i => (investorUploads[i.id] || []).length > 0) + + return ( +
+
+

Data Room

+
+ + +
+
+ + {tab === 'documents' && ( +
+ {/* Document list */} +
+
+ {docs.length} document{docs.length !== 1 ? 's' : ''} + + +
+ + {docs.length === 0 && ( +
+ No documents yet. Upload the first one. +
+ )} + + {docs.map(doc => ( + + ))} +
+ + {/* Release panel */} + {selected ? ( +
+
+
+
{selected.display_name || selected.filename}
+
{fmt(selected.file_size)} · {selected.mime_type}
+
+
+ + + +
+
+ +
+
+ + Investor Access +
+ {allInvestors.length === 0 && ( +

No active investors yet.

+ )} +
+ {allInvestors.map(inv => { + const has = releasedIds.has(inv.id) + return ( + + ) + })} +
+
+
+ ) : ( +
+ Select a document to manage releases +
+ )} +
+ )} + + {tab === 'uploads' && ( +
+ {investorsWithUploads.length === 0 && ( +
+ No investor uploads yet. +
+ )} + {allInvestors.map(inv => { + const uploads = investorUploads[inv.id] || [] + if (uploads.length === 0) return null + return ( +
+
+ {inv.name || inv.email} + {inv.company && {inv.company}} + {uploads.length} file{uploads.length !== 1 ? 's' : ''} +
+
+ {uploads.map(u => ( +
+ +
+
{u.display_name || u.filename}
+
{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}
+
+ + Download + +
+ ))} +
+
+ ) + })} +
+ )} + + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/components/pitch-admin/AdminShell.tsx b/pitch-deck/components/pitch-admin/AdminShell.tsx index fbe9f9d..b1c4b49 100644 --- a/pitch-deck/components/pitch-admin/AdminShell.tsx +++ b/pitch-deck/components/pitch-admin/AdminShell.tsx @@ -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 }, ] diff --git a/pitch-deck/lib/dataroom-storage.ts b/pitch-deck/lib/dataroom-storage.ts new file mode 100644 index 0000000..e7a9df8 --- /dev/null +++ b/pitch-deck/lib/dataroom-storage.ts @@ -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 { + await fs.promises.mkdir(dir, { recursive: true }) +} + +export async function saveFile(dir: string, filename: string, buffer: Buffer): Promise { + await ensureDir(dir) + const filePath = path.join(dir, filename) + await fs.promises.writeFile(filePath, buffer) + return filePath +} + +export async function removeDir(dir: string): Promise { + 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) +} From af83e4149455c6595546ed6ad290e59a5e951c39 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 1 May 2026 15:47:14 +0200 Subject: [PATCH 08/11] feat(pitch-deck): add Data Room link for investors in top-right corner Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/components/PitchDeck.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pitch-deck/components/PitchDeck.tsx b/pitch-deck/components/PitchDeck.tsx index 430a495..f744a3e 100644 --- a/pitch-deck/components/PitchDeck.tsx +++ b/pitch-deck/components/PitchDeck.tsx @@ -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 && } + {/* Data Room link — only for real investor sessions, not preview */} + {investor && !previewData && ( + + + Data Room + + )} + {renderSlide()} From 07039cc4088829c8fd3733c5f3369e8844b02787 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 1 May 2026 15:51:50 +0200 Subject: [PATCH 09/11] fix(pitch-deck): pre-create /data/dataroom owned by nextjs in Dockerfile Docker volume inherits directory ownership from the image on first mount. Without this, the volume mounts as root and the nextjs (uid 1001) process gets EACCES when trying to write dataroom uploads. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pitch-deck/Dockerfile b/pitch-deck/Dockerfile index 3c986ae..903b834 100644 --- a/pitch-deck/Dockerfile +++ b/pitch-deck/Dockerfile @@ -31,6 +31,10 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +# Create dataroom storage directory owned by nextjs so mounted volumes +# inherit the correct ownership on first initialisation +RUN mkdir -p /data/dataroom && chown -R nextjs:nodejs /data/dataroom + # Copy built assets COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ From 370143b6431d529e5cfbc403543ce787ecb2ef0a Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 1 May 2026 16:03:21 +0200 Subject: [PATCH 10/11] fix(dataroom): use getSessionFromCookie() instead of middleware headers; fix auth page overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dataroom routes were reading x-investor-id from request headers which the middleware sets as response headers — these don't reach route handlers when the admin fallback path runs (NextResponse.next() without header). Switch to getSessionFromCookie() consistent with all other investor routes. Auth page DSGVO footer switched from absolute bottom-0 to normal flow so the expanded Art. 13 notice doesn't overlap the login card. Co-Authored-By: Claude Sonnet 4.6 --- .../dataroom/documents/[id]/download/route.ts | 9 +++++---- pitch-deck/app/api/dataroom/documents/route.ts | 6 ++++-- pitch-deck/app/api/dataroom/uploads/route.ts | 16 +++++++++------- pitch-deck/app/auth/page.tsx | 10 ++++++---- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts b/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts index 166c2a8..6982905 100644 --- a/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts +++ b/pitch-deck/app/api/dataroom/documents/[id]/download/route.ts @@ -1,15 +1,16 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { streamFile } from '@/lib/dataroom-storage' -import { logAudit } from '@/lib/auth' +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 investorId = request.headers.get('x-investor-id') - const sessionId = request.headers.get('x-session-id') - if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const 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 diff --git a/pitch-deck/app/api/dataroom/documents/route.ts b/pitch-deck/app/api/dataroom/documents/route.ts index 7777543..70d7aa4 100644 --- a/pitch-deck/app/api/dataroom/documents/route.ts +++ b/pitch-deck/app/api/dataroom/documents/route.ts @@ -1,11 +1,13 @@ 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 investorId = request.headers.get('x-investor-id') - if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + 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.mime_type, d.file_size, r.released_at diff --git a/pitch-deck/app/api/dataroom/uploads/route.ts b/pitch-deck/app/api/dataroom/uploads/route.ts index 3c441e6..12fc132 100644 --- a/pitch-deck/app/api/dataroom/uploads/route.ts +++ b/pitch-deck/app/api/dataroom/uploads/route.ts @@ -1,16 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { investorUploadDir, saveFile, safeName } from '@/lib/dataroom-storage' -import { logAudit } from '@/lib/auth' +import { logAudit, getSessionFromCookie } from '@/lib/auth' import { randomUUID } from 'crypto' export const dynamic = 'force-dynamic' const MAX_BYTES = parseInt(process.env.DATAROOM_MAX_UPLOAD_MB || '50') * 1024 * 1024 -export async function GET(request: NextRequest) { - const investorId = request.headers.get('x-investor-id') - if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +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, mime_type, file_size, created_at @@ -23,9 +24,10 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const investorId = request.headers.get('x-investor-id') - const sessionId = request.headers.get('x-session-id') - if (!investorId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const 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 file = formData.get('file') as File | null diff --git a/pitch-deck/app/auth/page.tsx b/pitch-deck/app/auth/page.tsx index 55b13c4..93a156d 100644 --- a/pitch-deck/app/auth/page.tsx +++ b/pitch-deck/app/auth/page.tsx @@ -39,15 +39,16 @@ export default function AuthPage() { } return ( -
+
{/* Background gradient */} -
+
+

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

+

{/* Privacy Notice Footer */} -
+

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

From f130c45ca804190f613f75ba85f84ae7026b185a Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 1 May 2026 21:00:36 +0200 Subject: [PATCH 11/11] feat(dataroom): bilingual descriptions, drag-drop multi-file upload, edit existing upload descriptions - lib/translate.ts: LiteLLM DE<>EN translation utility - Migration 006: description_de/description_en on both dataroom tables - Admin + investor upload APIs: accept description+lang, auto-translate the other language on save - PATCH /api/admin/dataroom/documents/[id]: description path in addition to display_name path - PATCH /api/dataroom/uploads/[id]: investor can edit their own upload descriptions - PATCH /api/admin/dataroom/investors/[id]/uploads: admin can edit investor upload descriptions - All GET queries updated to return description fields - Admin dataroom: drop zone replaces upload button, multi-file, inline description editor per doc and per investor upload - Investor dataroom: drop zone, multi-file, description+lang textarea before upload, inline description editing on existing uploads Co-Authored-By: Claude Sonnet 4.6 --- .../admin/dataroom/documents/[id]/route.ts | 20 +- .../app/api/admin/dataroom/documents/route.ts | 51 ++- .../dataroom/investors/[id]/uploads/route.ts | 25 +- pitch-deck/app/api/admin/migrate/route.ts | 5 + .../app/api/dataroom/documents/route.ts | 5 +- .../app/api/dataroom/uploads/[id]/route.ts | 28 ++ pitch-deck/app/api/dataroom/uploads/route.ts | 53 +++- pitch-deck/app/dataroom/page.tsx | 214 ++++++++----- .../pitch-admin/(authed)/dataroom/page.tsx | 290 ++++++++++++------ pitch-deck/lib/translate.ts | 28 ++ 10 files changed, 521 insertions(+), 198 deletions(-) create mode 100644 pitch-deck/app/api/dataroom/uploads/[id]/route.ts create mode 100644 pitch-deck/lib/translate.ts diff --git a/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts index 46a50a7..971e255 100644 --- a/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts +++ b/pitch-deck/app/api/admin/dataroom/documents/[id]/route.ts @@ -3,6 +3,7 @@ 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 }> } @@ -11,8 +12,25 @@ export async function PATCH(request: NextRequest, ctx: Ctx) { if (guard.kind === 'response') return guard.response const { id } = await ctx.params - const { display_name } = await request.json() + 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 *`, diff --git a/pitch-deck/app/api/admin/dataroom/documents/route.ts b/pitch-deck/app/api/admin/dataroom/documents/route.ts index 3c548ed..d01b78f 100644 --- a/pitch-deck/app/api/admin/dataroom/documents/route.ts +++ b/pitch-deck/app/api/admin/dataroom/documents/route.ts @@ -3,6 +3,7 @@ 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' @@ -12,7 +13,8 @@ export async function GET(request: NextRequest) { if (guard.kind === 'response') return guard.response const { rows } = await pool.query( - `SELECT d.id, d.filename, d.display_name, d.mime_type, d.file_size, d.uploaded_by, d.created_at, + `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 @@ -28,25 +30,40 @@ export async function POST(request: NextRequest) { const admin = await getAdminFromCookie() const formData = await request.formData() - const file = formData.get('file') as File | null - const displayName = (formData.get('display_name') as string | null) || null - if (!file || file.size === 0) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + const 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 documentId = randomUUID() - const filename = safeName(file.name) - const buffer = Buffer.from(await file.arrayBuffer()) - const filePath = await saveFile(adminDocDir(documentId), filename, buffer) + 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, mime_type, file_size, uploaded_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [documentId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size, admin?.email ?? 'admin'], - ) + 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]) + } - await logAudit(null, 'dataroom_document_uploaded', { document_id: documentId, filename, file_size: file.size }, request, undefined, undefined, admin?.id) - return NextResponse.json({ document: rows[0] }, { status: 201 }) + return NextResponse.json({ documents: inserted }, { status: 201 }) } diff --git a/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts b/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts index e2e8df9..6584d20 100644 --- a/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts +++ b/pitch-deck/app/api/admin/dataroom/investors/[id]/uploads/route.ts @@ -2,6 +2,7 @@ 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 }> } @@ -31,7 +32,7 @@ export async function GET(request: NextRequest, ctx: Ctx) { } const { rows } = await pool.query( - `SELECT id, filename, display_name, mime_type, file_size, created_at + `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`, @@ -39,3 +40,25 @@ export async function GET(request: NextRequest, ctx: Ctx) { ) return NextResponse.json({ uploads: rows }) } + +export async function PATCH(request: NextRequest, ctx: Ctx) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id: investorId } = await ctx.params + const { upload_id, description, description_lang } = await request.json() + const lang: 'de' | 'en' = description_lang || 'en' + + const translated = description ? await translateText(description, lang) : null + const desc_de = lang === 'de' ? (description || null) : translated + const desc_en = lang === 'en' ? (description || null) : translated + + const { rows } = await pool.query( + `UPDATE dataroom_investor_uploads SET description_de = $1, description_en = $2 + WHERE id = $3 AND investor_id = $4 + RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`, + [desc_de, desc_en, upload_id, investorId], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ upload: rows[0] }) +} diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index 73e6c94..8299282 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -155,6 +155,11 @@ export async function POST(request: NextRequest) { `CREATE INDEX IF NOT EXISTS idx_dataroom_releases_investor ON dataroom_releases(investor_id)`, `CREATE INDEX IF NOT EXISTS idx_dataroom_releases_document ON dataroom_releases(document_id)`, `CREATE INDEX IF NOT EXISTS idx_dataroom_uploads_investor ON dataroom_investor_uploads(investor_id)`, + // 006 — dataroom bilingual descriptions + `ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_de TEXT`, + `ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_en TEXT`, + `ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_de TEXT`, + `ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`, ] for (const sql of statements) { diff --git a/pitch-deck/app/api/dataroom/documents/route.ts b/pitch-deck/app/api/dataroom/documents/route.ts index 70d7aa4..63c0f76 100644 --- a/pitch-deck/app/api/dataroom/documents/route.ts +++ b/pitch-deck/app/api/dataroom/documents/route.ts @@ -4,13 +4,14 @@ import { getSessionFromCookie } from '@/lib/auth' export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +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.mime_type, d.file_size, r.released_at + `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 diff --git a/pitch-deck/app/api/dataroom/uploads/[id]/route.ts b/pitch-deck/app/api/dataroom/uploads/[id]/route.ts new file mode 100644 index 0000000..f045314 --- /dev/null +++ b/pitch-deck/app/api/dataroom/uploads/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' +import { translateText } from '@/lib/translate' + +interface Ctx { params: Promise<{ id: string }> } + +export async function PATCH(request: NextRequest, ctx: Ctx) { + const session = await getSessionFromCookie() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { id } = await ctx.params + const { description, description_lang } = await request.json() + const lang: 'de' | 'en' = description_lang || 'en' + + const translated = description ? await translateText(description, lang) : null + const desc_de = lang === 'de' ? (description || null) : translated + const desc_en = lang === 'en' ? (description || null) : translated + + const { rows } = await pool.query( + `UPDATE dataroom_investor_uploads SET description_de = $1, description_en = $2 + WHERE id = $3 AND investor_id = $4 + RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`, + [desc_de, desc_en, id, session.sub], + ) + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ upload: rows[0] }) +} diff --git a/pitch-deck/app/api/dataroom/uploads/route.ts b/pitch-deck/app/api/dataroom/uploads/route.ts index 12fc132..1f09eac 100644 --- a/pitch-deck/app/api/dataroom/uploads/route.ts +++ b/pitch-deck/app/api/dataroom/uploads/route.ts @@ -2,6 +2,7 @@ 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' @@ -14,7 +15,7 @@ export async function GET(_request: NextRequest) { const investorId = session.sub const { rows } = await pool.query( - `SELECT id, filename, display_name, mime_type, file_size, created_at + `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`, @@ -30,24 +31,42 @@ export async function POST(request: NextRequest) { const sessionId = session.sessionId const formData = await request.formData() - const file = formData.get('file') as File | null - const displayName = (formData.get('display_name') as string | null) || null + 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 }) - if (!file || file.size === 0) return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - if (file.size > MAX_BYTES) return NextResponse.json({ error: `File exceeds ${process.env.DATAROOM_MAX_UPLOAD_MB || 50}MB limit` }, { status: 413 }) + const 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 uploadId = randomUUID() - const filename = safeName(file.name) - const buffer = Buffer.from(await file.arrayBuffer()) - const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer) + const description = (formData.get('description') as string | null) || null + const descLang = (formData.get('description_lang') as 'de' | 'en' | null) || 'en' - const { rows } = await pool.query( - `INSERT INTO dataroom_investor_uploads (id, investor_id, filename, file_path, display_name, mime_type, file_size) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, filename, display_name, mime_type, file_size, created_at`, - [uploadId, investorId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size], - ) + 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) + } - await logAudit(investorId, 'dataroom_investor_uploaded', { upload_id: uploadId, filename, file_size: file.size }, request, undefined, sessionId ?? undefined) - return NextResponse.json({ upload: rows[0] }, { status: 201 }) + const inserted = [] + for (const file of validFiles) { + const uploadId = randomUUID() + const filename = safeName(file.name) + const buffer = Buffer.from(await file.arrayBuffer()) + const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer) + + const { rows } = await pool.query( + `INSERT INTO dataroom_investor_uploads + (id, investor_id, filename, file_path, display_name, description_de, description_en, mime_type, file_size) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`, + [uploadId, investorId, filename, filePath, file.name, desc_de, desc_en, + file.type || 'application/octet-stream', file.size], + ) + await logAudit(investorId, 'dataroom_investor_uploaded', { upload_id: uploadId, filename, file_size: file.size }, request, undefined, sessionId) + inserted.push(rows[0]) + } + + return NextResponse.json({ uploads: inserted }, { status: 201 }) } diff --git a/pitch-deck/app/dataroom/page.tsx b/pitch-deck/app/dataroom/page.tsx index 991c15e..6776a11 100644 --- a/pitch-deck/app/dataroom/page.tsx +++ b/pitch-deck/app/dataroom/page.tsx @@ -1,12 +1,14 @@ 'use client' -import { useEffect, useState, useRef } from 'react' -import { FileText, Download, Upload, Eye, LogOut } from 'lucide-react' +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 @@ -16,6 +18,8 @@ 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 @@ -27,57 +31,83 @@ function fmt(bytes: number) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } -function isPDF(mime: string) { - return mime === 'application/pdf' +function isPDF(mime: string) { return mime === 'application/pdf' } + +function LangToggle({ lang, onChange }: { lang: 'de' | 'en'; onChange: (l: 'de' | 'en') => void }) { + return ( +
+ {(['de', 'en'] as const).map(l => ( + + ))} +
+ ) } export default function DataroomPage() { const [docs, setDocs] = useState([]) const [uploads, setUploads] = useState([]) + const [dragging, setDragging] = useState(false) const [uploading, setUploading] = useState(false) const [toast, setToast] = useState(null) + const [description, setDescription] = useState('') + const [descLang, setDescLang] = useState<'de' | 'en'>('en') + const [editingUpload, setEditingUpload] = useState<{ id: string; text: string; lang: 'de' | 'en' } | null>(null) const fileRef = useRef(null) - function flash(msg: string) { - setToast(msg) - setTimeout(() => setToast(null), 3500) - } + 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'), - ]) + const [dr, ur] = await Promise.all([fetch('/api/dataroom/documents'), fetch('/api/dataroom/uploads')]) if (dr.ok) setDocs((await dr.json()).documents) if (ur.ok) setUploads((await ur.json()).uploads) } useEffect(() => { loadAll() }, []) - async function handleUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return + 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() - fd.append('file', file) + 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('File uploaded successfully'); loadAll() } - else { + 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') } - e.target.value = '' + } + + 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 (
- {/* Header */}
-

- Data Room -

+

Data Room

BreakPilot ComplAI · Investor Portal

@@ -101,32 +131,30 @@ export default function DataroomPage() { ) : (
{docs.map(doc => ( -
-
+
+
-
{doc.display_name || doc.filename}
-
- {fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()} -
+
{doc.display_name || doc.filename}
+
{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()}
+ {(doc.description_en || doc.description_de) && ( +
+ {doc.description_en &&

{doc.description_en}

} + {doc.description_de && !doc.description_en &&

{doc.description_de}

} + {doc.description_de && doc.description_en &&

{doc.description_de}

} +
+ )}
{isPDF(doc.mime_type) && ( - + Preview )} - + Download
@@ -138,40 +166,88 @@ export default function DataroomPage() { {/* Upload section */}
-
-

Your Documents

- - -
- +

Your Documents

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

- {uploads.length === 0 ? ( -
- -

No files uploaded yet.

-
- ) : ( -
- {uploads.map(u => ( -
- -
-
{u.display_name || u.filename}
-
{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}
+ {/* Description field */} +
+