63 Commits

Author SHA1 Message Date
Benjamin Admin 9675c1f896 Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
Build pitch-deck / build-push-deploy (push) Successful in 1m11s
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 32s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
2026-04-15 18:00:45 +02:00
Benjamin Admin 9736476a0c feat(pitch-deck): legal disclaimer slide + projection footer on financial slides
New DisclaimerSlide (last slide):
- Full liability disclaimer (German/English)
- Confidentiality clause (purpose limitation, 3yr duration, Konstanz jurisdiction)
- Status as private individual in founding preparation

ProjectionFooter component on 4 financial slides:
- FinancialsSlide, TheAskSlide, FinanzplanSlide, CapTableSlide
- "Alle Finanzdaten sind Planzahlen" disclaimer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:00:08 +02:00
Sharang Parnerkar 03d420c984 feat(pitch-deck): self-service magic-link reissue on /auth
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
Investors who lost their session or whose invite token was already used
can now enter their email on /auth to receive a fresh access link,
without needing a manual re-invite from an admin.

- New /api/auth/request-link endpoint looks up the investor by email,
  issues a new pitch_magic_links row, and emails the link via the
  existing sendMagicLinkEmail path. Response is generic regardless of
  whether the email exists (enumeration resistance) and silently no-ops
  for revoked investors.
- Rate-limited both per-IP (authVerify preset) and per-email (magicLink
  preset, 3/hour — same ceiling as admin-invite/resend).
- /auth page now renders an email form; submits to the new endpoint and
  shows a generic "if invited, link sent" confirmation.
- Route-level tests cover validation, normalization, unknown email,
  revoked investor, and both rate-limit paths.
- End-to-end regression test wires request-link + verify against an
  in-memory fake DB and asserts the full flow: original invite used →
  replay rejected → email submission → fresh link → verify succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 17:06:12 +02:00
Benjamin Admin 6b52719079 feat(pitch-deck): rename Traction → Meilensteine, update milestones data
Build pitch-deck / build-push-deploy (push) Successful in 1m10s
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 32s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 36s
- i18n: Traction & Meilensteine → Meilensteine / Milestones
- slideNames updated (DE + EN)
- Chat display name updated
- Milestones data replaced via MCP (both versions):
  13 milestones chronologically: domains, DPMA, IHK, prototype,
  pilot customers, RAG pipeline, EUIPO, L-Bank, Gründerzuschuss,
  GmbH founding, onboarding, App Store, distribution
- Metrics updated: 385 docs, 25k controls, 12 modules, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:20:47 +02:00
Benjamin Admin a5b7d62969 fix(pitch-deck): USP cards wider (290px), circle larger (440px), more height
Build pitch-deck / build-push-deploy (push) Successful in 1m11s
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 30s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:49:28 +02:00
Benjamin Admin ef9e3699b2 fix(pitch-deck): USP cards overlap — increase container height to 520px
Build pitch-deck / build-push-deploy (push) Successful in 1m8s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:06:33 +02:00
Benjamin Admin 440367b69d feat(pitch-deck): USP font sizes match Solution slide, product modules updated
Build pitch-deck / build-push-deploy (push) Successful in 1m4s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 30s
USP slide:
- Title/subtitle same as Solution (text-4xl/text-lg)
- Card titles: text-base font-bold (was text-xs)
- Card descriptions: text-sm text-white/50 (was text-[10px])
- Circle text: text-sm (was text-[11px]/text-[9px])
- Cards 240px wide with GlassCard wrapper

Product slide:
- "Integration in Kundenprozesse" → "AI Act Compliance" (UCCA, Betriebsrat)
- "Cookie-Generator" → "Tender Matching" (RFQ gegen Codebase)
- Remove "FR" badge from deployment options

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:45:14 +02:00
Benjamin Admin 801a5a43f5 feat(pitch-deck): USP slide — larger circle, title back, infinity hub
Build pitch-deck / build-push-deploy (push) Successful in 1m6s
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 34s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 34s
- USP as slide title (GradientText) above
- Circle doubled to 380px with spinning ring
- Infinity symbol (∞) in center hub instead of text
- Compliance left, Code right inside circle — larger font
- 4 cards in corners (220px wide, larger text, ~5 lines each)
- Cards spread to corners (top/bottom, left/right)
- Dashed SVG lines connecting circle to cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:33:28 +02:00
Benjamin Admin 9c23068a4f feat(pitch-deck): USP slide — large circle with cards on sides
Build pitch-deck / build-push-deploy (push) Successful in 1m7s
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 35s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 31s
- Large spinning circle (320px) with USP hub in center
- Compliance items left, Code items right inside circle
- 4 arrows pointing outward to capability cards
- 2 cards left (RFQ, Bidirectional), 2 cards right (Process, Continuous)
- Longer descriptions (~5 lines per card)
- Grid layout: cards | circle | cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:26:59 +02:00
Benjamin Admin d359b7b734 fix(pitch-deck): HowItWorks line behind icons, remove France refs, SOM label
Build pitch-deck / build-push-deploy (push) Successful in 1m6s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
- Connection line: starts/ends between icons, opaque icon background
- Remove all "oder Frankreich/or France/oder FR/or FR" references
- Market subtitle: remove "Der Maschinenbau"
- SOM label: add "(nur Maschinen- und Anlagenbauer als Kernmarkt)"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:46:21 +02:00
Benjamin Admin bd37ff807e fix(pitch-deck): USP slide complete redesign — grid layout
Build pitch-deck / build-push-deploy (push) Successful in 1m7s
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 30s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
Replace broken absolute positioning with clean grid layout:
- Top: Compliance card | BreakPilot hub (spinning) | Code card
- Arrows + sync labels between cards
- Bottom: 4 capability cards in a row
- No more floating text, no overlapping elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:25:03 +02:00
Benjamin Admin 40d2342086 fix(pitch-deck): fix JSX syntax error in USPSlide corner cards
Build pitch-deck / build-push-deploy (push) Successful in 1m3s
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 30s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:14:03 +02:00
Benjamin Admin adf3bf8301 feat(pitch-deck): USP slide redesign + add to sidebar
Build pitch-deck / build-push-deploy (push) Failing after 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 33s
- USP added to slideNames (DE+EN) and chat display names
- Circular layout: BreakPilot hub center, rotating ring,
  Compliance & Code sections inside circle
- 4 capability cards in corners connected by dashed lines
- Removed variant toggle (kept variant A design)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:04:06 +02:00
Benjamin Admin 1b5ccd4dec feat(pitch-deck): solution text fixes + USP bridge 3 variants
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
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 36s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 32s
- Solution: 30k → 15k+ EUR per year per application
- Solution: DE oder FR → Deutschland
- USP title: Unser USP → USP
- USP bridge: 3 switchable variants (A: circular loop,
  B: infinity loop, C: hexagonal hub) with toggle buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:57:30 +02:00
Benjamin Admin b5d8f9aed3 feat(pitch-deck): add USP slide + update cover and problem texts
Build pitch-deck / build-push-deploy (push) Successful in 1m8s
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 30s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 28s
- Cover: remove "für den Maschinenbau" from tagline
- Problem subtitle: Maschinenbauer → Deutsche und europäische Unternehmen
- New USP slide after Solution: bridge between compliance docs/audits
  and actual code implementation — RFQ verification, bidirectional sync,
  automated process compliance, continuous instead of annual checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:44:32 +02:00
Benjamin Admin c8171b0a1e chore(pitch-deck): trigger rebuild 2
Build pitch-deck / build-push-deploy (push) Successful in 1m22s
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 36s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 28s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:05:40 +02:00
Benjamin Admin 7e15ef3725 chore(pitch-deck): trigger rebuild for i18n Problem slide changes
Build pitch-deck / build-push-deploy (push) Failing after 24s
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) Failing after 9s
CI / test-python-voice (push) Failing after 11s
CI / test-bqas (push) Failing after 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:54:03 +02:00
Benjamin Admin e3a3802f5b chore(pitch-deck): trigger rebuild for i18n changes
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) Failing after 8s
CI / test-python-voice (push) Failing after 9s
CI / test-bqas (push) Failing after 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:53:17 +02:00
Benjamin Admin 93e319e9fb feat(pitch-deck): rewrite Problem slide cards for investors
Build pitch-deck / build-push-deploy (push) Failing after 8s
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) Failing after 8s
CI / test-python-voice (push) Failing after 5s
CI / test-bqas (push) Failing after 5s
- Card 1 (KI-Dilemma): clearer framing of sovereignty vs competitiveness
- Card 2: Patriots Act → Patriot Act + FISA 702, Schrems II reference
- Card 3: 50.000+ EUR → Nicht tragbar / Unsustainable, focus on
  AI Act, NIS2, CRA since 2024, competitive disadvantage vs US/Asia,
  supply chain costs, geopolitical pressure
- Quote updated: Maschinenbauer → Produzierende Unternehmen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:59:59 +02:00
Benjamin Admin 6626d2a8f9 fix(pitch-deck): fix ReferenceError in ChatFAB breaking 2nd message
Build pitch-deck / build-push-deploy (push) Successful in 1m4s
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 32s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 28s
faqMatch (undefined) → faqMatches[0]. The undefined variable caused
a ReferenceError after streaming completed, which the catch block
turned into "Verbindung fehlgeschlagen" for every subsequent message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:53:42 +02:00
Benjamin Admin 3dbc470158 feat: DSFA Generator — FISA 702 Risiken bei US-Cloud-Providern
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 26s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 30s
Erkennt automatisch US-Provider (AWS, Azure, Google, Microsoft, OpenAI,
Anthropic, Oracle, Amazon) und fuegt 3 Drittland-Risiken hinzu:
- FISA 702 Zugriff nicht ausschliessbar
- EU-Serverstandort schuetzt nicht gegen US-Rechtszugriff
- Fehlende Rechtsbehelfe fuer EU-Betroffene

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:47:21 +02:00
Benjamin Admin e5d0386cfb feat(pitch-deck): add FISA 702 FAQ entries for investor agent
Build pitch-deck / build-push-deploy (push) Successful in 1m1s
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 30s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 26s
5 new FAQ entries covering:
- FISA 702 basics (PRISM, Upstream, Schrems II)
- EU cloud region myth (extraterritorial US law)
- DSFA contradiction (risk acceptance vs risk elimination)
- Market opportunity (structural independence)
- BreakPilot architecture (BSI, SysEleven, Hetzner)

Also: middleware fix to allow admin sessions on investor routes
(enables chat in preview mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:27:47 +02:00
Benjamin Admin ff071af2a0 fix(pitch-deck): allow admin sessions to access investor routes
Build pitch-deck / build-push-deploy (push) Successful in 1m3s
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 30s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 34s
Admins in preview mode can now use /api/chat and other investor
endpoints without needing a separate investor login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:13:13 +02:00
Benjamin Admin fcdcbc51e3 fix(pitch-deck): regulatory matrix header positioning
Build pitch-deck / build-push-deploy (push) Successful in 1m12s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 30s
- Regulatorien + Branche moved to top header row
- Branche: white/70 instead of white/30 for readability
- Regulatorien: indigo color instead of grey

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:59:53 +02:00
Benjamin Admin 7b8f8d4b5a fix(pitch-deck): regulatory matrix — remove legend, stagger headers
Build pitch-deck / build-push-deploy (push) Successful in 1m1s
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 30s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 29s
- Remove colored dot legend row (redundant with column headers)
- Stagger column headers on 2 rows (odd/even) to save space
- Last column: Reg. → Regulatorien

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:54:59 +02:00
Benjamin Admin f385c612f5 fix(pitch-deck): regulatory matrix header alignment + labels
Build pitch-deck / build-push-deploy (push) Successful in 1m4s
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 32s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 32s
- Column headers: centered text labels instead of icons
- Remove colored dots from headers
- Last column: # → Reg. (Regulierungen)
- Consistent column width for last column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:45:24 +02:00
Benjamin Admin 9166d9dade fix(pitch-deck): resolve merge conflict in AIPipelineSlide — keep updated version
Build pitch-deck / build-push-deploy (push) Successful in 1m0s
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 34s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:42:13 +02:00
Benjamin Admin 7ae5bc0fd5 feat(pitch-deck): overhaul AI Pipeline slide with real data
- Hero stats: 75+ sources, 70k+ controls, 47k+ obligations
- RAG tab: source categories with investor-friendly explanations
  (why court rulings matter, why frameworks define state of art)
- Remove inflated numbers (was 110+ regulations, now accurate 75+)
- Quality tab: continuous expansion, cross-regulation mapping
- Remove NiBiS/education references (irrelevant for compliance)
- All numbers verified against production database

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:40:27 +02:00
Sharang Parnerkar 242ed1101e style(team): tighter card layout — equal height, equity pill, GitHub/LinkedIn detection
Build pitch-deck / build-push-deploy (push) Successful in 1m11s
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 33s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 31s
- grid items-stretch so cards match height
- Smaller avatar (16->64px) to free vertical space
- Equity moved to a top-right pill (compact); decimals collapsed via equityDisplay()
- Profile link icon auto-detects GitHub vs LinkedIn vs generic
- Expertise tags get their own divider strip at card bottom — cleaner hierarchy
- Card background lightened from 0.08 to 0.04 with subtle hover border

Bio text itself shortened on the data side (both draft versions via admin API).
2026-04-14 16:25:37 +02:00
Sharang Parnerkar 8b2e9ac328 content(pitch-deck): tidy slide text — remove OVH, generalize issue tracker, add live support, Mac Studio option
Build pitch-deck / build-push-deploy (push) Successful in 1m4s
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 32s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 32s
Solution slide:
- Continuous Code Security: "Jira tickets" -> "tickets in the issue tracker of your choice"
- German Cloud / Full Integration: removed OVH (now "BSI cloud DE or FR"),
  removed "AI task creation from audio", added "Live support via Jitsi (video) and Matrix (chat)",
  "Mac Mini" -> "Mac Mini/Studio"

Products / Modular toolkit slide:
- Regional bubble: "OVH FR" -> "FR"

How It Works:
- Cloud step: removed OVH and "pre-configured Mac Mini" mentions

Engineering deep dive:
- "Docker Containers" stat -> "Services"; "Coolify -> Hetzner" -> "orca -> Hetzner"
- "Dockerfiles / Fully containerized" stat -> "Infra Components / orca (Rust) + infisical + pg + qdrant"
- devopsStack: Coolify -> orca (Rust), Docker Compose -> Private Registry (registry.meghsakha.com),
  HashiCorp Vault -> Infisical, EU-Cloud list drops OVH
- Service Architecture Infrastructure section: add orca (Rust), Infisical, Private Registry
- Footer note drops OVH

Chat / Presenter (consistency):
- chat/route.ts system prompt: OVH removed, Jira-Integration -> Issue-Tracker-Integration
- presenter-faq.ts + presenter-script.ts: OVH references removed across all answers,
  Jira mentioned alongside GitLab/Linear/Gitea as examples, Mac Mini -> Mac Mini/Studio

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:14:40 +02:00
Benjamin Admin 084d09e9bd fix(pitch-deck): revert banner test text back to Draft
Build pitch-deck / build-push-deploy (push) Successful in 13s
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 32s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:32:46 +02:00
Benjamin Admin 646143ce5a Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
Build pitch-deck / build-push-deploy (push) Successful in 1m3s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
2026-04-14 15:20:56 +02:00
Benjamin Admin 00d802f965 test(pitch-deck): banner text Draft → Draft V1 — deployment test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:20:41 +02:00
Sharang Parnerkar ebb7575f2c test: retrigger with http:// webhook URL
Build pitch-deck / build-push-deploy (push) Successful in 15s
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 33s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 30s
2026-04-14 09:32:36 +02:00
Sharang Parnerkar d0539d0f2f ci: use http:// for orca webhook (port 6880 serves plain HTTP)
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been cancelled
CI / nodejs-lint (push) Has been cancelled
CI / test-go-consent (push) Has been cancelled
CI / test-python-voice (push) Has been cancelled
CI / test-bqas (push) Has been cancelled
2026-04-14 09:32:08 +02:00
Sharang Parnerkar 8e92a93aa8 test: verify full CI pipeline with registry auth + orca webhook
Build pitch-deck / build-push-deploy (push) Failing after 14s
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 34s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 32s
2026-04-14 09:27:05 +02:00
Sharang Parnerkar f794347827 ci: add docker login step for registry.meghsakha.com
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 30s
CI / test-bqas (push) Has been cancelled
CI / test-python-voice (push) Has been cancelled
Requires Gitea Actions secrets: REGISTRY_USERNAME, REGISTRY_PASSWORD
2026-04-14 09:26:12 +02:00
Sharang Parnerkar 1af160eed0 test: trigger orca webhook via CI
Build pitch-deck / build-push-deploy (push) Failing after 10s
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 33s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 32s
2026-04-14 09:22:10 +02:00
Sharang Parnerkar eb118ebf92 ci: re-add HMAC-SHA256 signing on orca webhook (ORCA_WEBHOOK_SECRET)
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 33s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
2026-04-14 08:31:29 +02:00
Sharang Parnerkar dbb476cc3b ci: drop HMAC signing (orca webhooks have no secret by default)
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 33s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 32s
2026-04-14 08:27:22 +02:00
Sharang Parnerkar 9345efc3f0 ci(pipeline): trigger orca redeploy after image push, remove coolify
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 33s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 32s
build-pitch-deck workflow now posts an HMAC-signed push event to orca's
webhook endpoint after the image is built + pushed. This avoids the race
where orca would otherwise redeploy with the old :latest image before
CI finishes pushing the new one.

Removed the obsolete deploy-coolify.yml (wrong branch, wrong system) and
stripped the deploy-coolify job from ci.yaml.

Requires Gitea Actions secret: ORCA_WEBHOOK_SECRET_PITCH_DECK
2026-04-14 08:20:05 +02:00
Benjamin Admin c4e993e3f8 fix: Leere Controls (title/objective=None) filtern vor Store
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 44s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 30s
CI / Deploy (push) Failing after 4s
- Batch-Postprocessing: Controls mit title/objective = None/null/"" werden
  gefiltert und nicht gespeichert. Title wird aus Objective abgeleitet falls
  nur Title fehlt.
- _store_control: Pre-store Quality Guard lehnt leere Controls ab
- Verhindert "None"-Controls die durch LLM-Parsing-Fehler entstehen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:59:47 +02:00
Benjamin Admin a58d1aa403 fix: KRITISCH — 12 Pipeline-Bugs gefixt, 36.000 verlorene Controls retten
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 36s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 31s
CI / Deploy (push) Failing after 2s
Root Cause: _generate_control_id erzeugte ID-Kollisionen (String-Sort statt
numeric), ON CONFLICT DO NOTHING verwarf Controls stillschweigend, Chunks
wurden als "processed" markiert obwohl Store fehlschlug → permanent verloren.

Fixes:
1. _generate_control_id: Numeric MAX statt String-Sort, Collision Guard
   mit UUID-Suffix Fallback, Exception wird geloggt statt verschluckt
2. _store_control: ON CONFLICT DO UPDATE statt DO NOTHING → ID immer returned
3. Store-Logik: Chunk wird bei store_failed NICHT mehr als processed markiert
   → Retry beim naechsten Lauf moeglich
4. Counter: controls_generated nur bei erfolgreichem Store inkrementiert
   Neue Counter: controls_stored + controls_store_failed
5. Anthropic API: HTTP 429/500/502/503/504 werden jetzt retried (2 Versuche)
6. Monitoring: Progress-Log zeigt Store-Rate (%), ALARM bei <80%
7. Post-Job Validierung: Vergleicht Generated vs Stored vs DB-Realitaet
   WARNUNG wenn store_failed > 0, KRITISCH wenn Rate < 90%

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:39:12 +02:00
Benjamin Admin d7ed5ce8c5 fix(pitch-deck): add 8 missing slides to renderSlide switch
Build pitch-deck / build-and-push (push) Failing after 1m4s
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 33s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 35s
CI / Deploy (push) Failing after 2s
ExecutiveSummary, RegulatoryLandscape, CapTable, Savings,
SDKDemo, Strategy, Finanzplan, Glossary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:36:14 +02:00
Benjamin Admin 512088ab93 feat(pitch-deck): HTTPS via Nginx reverse proxy on port 3012
Build pitch-deck / build-and-push (push) Failing after 56s
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 32s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 33s
CI / Deploy (push) Failing after 4s
- Add Nginx SSL server block for pitch-deck on port 3012
- Route through Nginx instead of direct container port
- Restore secure cookie flag (requires HTTPS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:13:52 +02:00
Benjamin Admin 32b5e0223d fix(pitch-deck): use explicit PITCH_SECURE_COOKIE flag for cookie security
HTTP access on local network was blocked by secure cookie flag when
NODE_ENV=production. Now requires explicit opt-in via env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:11:36 +02:00
Benjamin Admin 9354cbf775 fix(pitch-deck): add PITCH_JWT_SECRET + PITCH_ADMIN_SECRET env vars
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:00:57 +02:00
Benjamin Admin 756d068b4f fix: skip_web_search Default auf True — 5x schnellere Pipeline
Anchor-Search (DuckDuckGo + RAG via SDK) verlangsamt Pipeline von
~50 Chunks/min auf ~10 Chunks/min. Anchors (OWASP/NIST-Referenzen)
koennen nachtraeglich in einem Batch-Job befuellt werden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:26:01 +02:00
Benjamin Admin c02a7bd8a6 feat(pitch-deck): show version name + status in preview banner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:21:59 +02:00
Benjamin Admin b6d3fad6ab Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core into feature/payment-compliance-module 2026-04-13 11:49:25 +02:00
Sharang Parnerkar 27479ee553 docs(mcp-server): add README + gitignore .mcp.json
Build pitch-deck / build-and-push (push) Failing after 1m2s
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 35s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 3s
Setup instructions for the pitch version MCP server.
.mcp.json contains the admin secret and is gitignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:36:54 +02:00
Sharang Parnerkar 82a5d62f44 feat(pitch-deck): MCP server for pitch version management via Claude Code
Build pitch-deck / build-and-push (push) Failing after 1m8s
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 36s
CI / test-python-voice (push) Successful in 42s
CI / test-bqas (push) Successful in 40s
CI / Deploy (push) Failing after 3s
Stdio MCP server that wraps the pitch-deck admin API, exposing 11 tools:
list_versions, create_version, get_version, get_table_data,
update_table_data, commit_version, fork_version, diff_versions,
list_investors, assign_version, invite_investor.

Authenticates via PITCH_ADMIN_SECRET bearer token against the deployed
pitch-deck API. All existing auth, validation, and audit logging is
reused — the MCP server is a thin adapter.

Usage: add to ~/.claude/settings.json mcpServers, set PITCH_API_URL
and PITCH_ADMIN_SECRET env vars. See mcp-server/README.md (to be added).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:32:45 +02:00
Benjamin Admin bc23c6815a docs: README aktualisiert — BV + FRIA Templates + Domain-Risiken
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:05:05 +02:00
Benjamin Admin 7dd2dc89a9 test: FRIA + DSFA Domain-Risiken Tests — 15/15 bestanden
FRIA: Minimal-Context, Domain-Rights (HR/Edu/HC), Universal Rights,
      Massnahmen, Public Entity, Risikomatrix, Betroffene.
DSFA: Domain-spezifische Risiken (AGG, Chancenungleichheit, Fehldiagnose,
      Kredit-Scoring), keine Extra-Risiken ohne Domain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:58:36 +02:00
Benjamin Admin 57462899f6 fix: DSFA Generator — Domain-spezifische Risiken (HR/Edu/HC/Finance)
Risikoanalyse erkennt jetzt den Domain-Kontext und fuegt automatisch
domain-spezifische Risiken hinzu:
- HR: AGG-Verstoss, Beweislastumkehr, Art. 22, Proxy-Diskriminierung
- Education: Chancenungleichheit, Minderjaehrige, Fehlbewertung
- Healthcare: Fehldiagnose, Triage, Patientenautonomie
- Finance: Kredit-Scoring Diskriminierung, Dienstverweigerung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:36:11 +02:00
Benjamin Admin f23b872c54 feat: FRIA Template (Art. 27 AI Act) — 7. Document Template
Grundrechte-Folgenabschaetzung mit 8 Sektionen, ~26 Placeholders,
Conditional Blocks fuer Bildung/HR/oeffentliche Stellen.
Python-Generator mit Domain→Grundrechte-Mapping (Education, HR, Healthcare, Finance).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:38:59 +02:00
Benjamin Admin 55f7195edd test: BV-Generator Tests — 9 Tests (alle bestanden)
Testet: minimaler/voller Kontext, verbotene Nutzungen (KI/Standard),
Datenarten-Mapping, TOM bei hohem Konflikt-Score, Speicherfristen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:02:32 +02:00
Benjamin Admin b14be8583d feat: Betriebsrats-Compliance — BAG-Ingestion Script + BV-Template
1. BAG-Urteile Ingestion Script (21 kuratierte Urteile zu §87 BetrVG)
   - Microsoft 365, SAP ERP, E-Mail, Standardsoftware, Video, SaaS/Cloud
   - 14 erfolgreich ingestiert (4.726 Chunks in bp_compliance_datenschutz)
2. Betriebsvereinbarung Template (6. Document Template)
   - SQL-Migration mit 13 Sektionen (A-M), ~30 Placeholders
   - Conditional Blocks fuer KI-Systeme, Video, HR
   - Python-Generator mit automatischer TOM-Befuellung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:49:01 +02:00
Benjamin Admin 67ad7c236b Merge remote-tracking branch 'gitea/main'
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 35s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 33s
CI / Deploy (push) Failing after 4s
2026-04-12 09:08:04 +02:00
Sharang Parnerkar ea752088f6 feat(pitch-admin): structured form editors, bilingual fields, version preview
Build pitch-deck / build-and-push (push) Failing after 59s
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 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s
Replaces raw JSON textarea in version editor with proper form UIs:

- Company: single-record form with side-by-side DE/EN tagline + mission
- Team: expandable card list with bilingual role/bio, expertise tags
- Financials: year-by-year table with numeric inputs
- Market: TAM/SAM/SOM row table
- Competitors: card list with strengths/weaknesses tag arrays
- Features: card list with DE/EN names + checkbox matrix
- Milestones: card list with DE/EN title/description + status dropdown
- Metrics: card list with DE/EN labels
- Funding: form + nested use_of_funds table
- Products: card list with DE/EN capabilities + feature tag arrays
- FM Scenarios: card list with color picker
- FM Assumptions: row table

Shared editor primitives (components/pitch-admin/editors/):
  BilingualField, FormField, ArrayField, RowTable, CardList

"Edit as JSON" toggle preserved as escape hatch on every tab.

Preview: admin clicks "Preview" on version editor → opens
/pitch-preview/[versionId] in new tab showing the full pitch deck
with that version's data. Admin-cookie gated (no investor auth).
Yellow "PREVIEW MODE" banner at top.

Also fixes the [object Object] inline table type cast in FM editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:34:42 +02:00
Sharang Parnerkar edadf39445 fix(pitch-admin): render JSONB arrays as inline table editors
Build pitch-deck / build-and-push (push) Failing after 57s
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 32s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 30s
CI / Deploy (push) Failing after 3s
Arrays of objects (funding_schedule, founder_salary_schedule, etc.)
now render as editable tables with per-field inputs, add/remove row
buttons, instead of a raw JSON string in a single text input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:09:26 +02:00
sharang 1c3cec2c06 feat(pitch-deck): full pitch versioning with git-style history (#4)
Build pitch-deck / build-and-push (push) Failing after 1m8s
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 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s
Full pitch versioning: 12 data tables versioned as JSONB snapshots,
git-style parent chain (draft→commit→fork), per-investor assignment,
side-by-side diff engine, version-aware /api/data + /api/financial-model.

Bug fixes: FM editor [object Object] for JSONB arrays, admin scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:37:33 +00:00
Sharang Parnerkar 746daaef6d ci: add Gitea Actions workflow to build + push pitch-deck image
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 31s
CI / Deploy (push) Failing after 5s
Builds and pushes to registry.meghsakha.com/breakpilot/pitch-deck
on every push to main that touches pitch-deck/ files. Tags with
:latest and :SHORT_SHA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:36:23 +02:00
80 changed files with 7527 additions and 387 deletions
+65
View File
@@ -0,0 +1,65 @@
# Build + push pitch-deck Docker image to registry.meghsakha.com
# and trigger orca redeploy on every push to main that touches pitch-deck/.
#
# Requires Gitea Actions secret: ORCA_WEBHOOK_SECRET
# (must match the `secret` field in ~/.orca/webhooks.json on the orca master)
name: Build pitch-deck
on:
push:
branches: [main]
paths:
- 'pitch-deck/**'
jobs:
build-push-deploy:
runs-on: docker
container:
image: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git openssl curl
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login to registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
run: |
cd pitch-deck
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
.
- name: Push to registry
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
echo "Pushed :latest + :${SHORT_SHA}"
- name: Trigger orca redeploy
env:
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
run: |
SHA=$(git rev-parse HEAD)
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: pitch-deck image build\"}}"
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
curl -sSf -k \
-X POST \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$PAYLOAD" \
"$ORCA_WEBHOOK_URL" \
|| { echo "Orca redeploy failed"; exit 1; }
echo "Orca redeploy triggered"
+2 -16
View File
@@ -140,20 +140,6 @@ jobs:
python -m pytest tests/bqas/ -v --tb=short || true
# ========================================
# Deploy via Coolify (nur main, kein PR)
# Deploys now handled by per-service workflows (e.g. build-pitch-deck.yml)
# which trigger orca webhooks directly after building + pushing the image.
# ========================================
deploy-coolify:
name: Deploy
runs-on: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-go-consent
container:
image: alpine:latest
steps:
- name: Trigger Coolify deploy
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
-27
View File
@@ -1,27 +0,0 @@
name: Deploy to Coolify
on:
push:
branches:
- coolify
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via Coolify API
run: |
echo "Deploying breakpilot-core to Coolify..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"uuid": "${{ secrets.COOLIFY_RESOURCE_UUID }}", "force_rebuild": true}' \
"${{ secrets.COOLIFY_BASE_URL }}/api/v1/deploy")
echo "HTTP Status: $HTTP_STATUS"
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
echo "Deployment failed with status $HTTP_STATUS"
exit 1
fi
echo "Deployment triggered successfully!"
+1
View File
@@ -7,6 +7,7 @@
secrets/
*.pem
*.key
.mcp.json
# Node
node_modules/
@@ -52,7 +52,7 @@ class GenerateRequest(BaseModel):
max_controls: int = 50
max_chunks: int = 1000 # Default: process max 1000 chunks per job (respects document boundaries)
batch_size: int = 5
skip_web_search: bool = False
skip_web_search: bool = True # Default True — Anchors nachtraeglich batchen
dry_run: bool = False
regulation_filter: Optional[List[str]] = None # Only process these regulation_code prefixes
regulation_exclude: Optional[List[str]] = None # Skip these regulation_code prefixes
@@ -0,0 +1,284 @@
"""Ingest BAG (Bundesarbeitsgericht) court decisions into RAG.
Downloads PDFs from bundesarbeitsgericht.de and uploads them to the
bp_compliance_datenschutz Qdrant collection via the RAG-Service API.
These decisions are curated for IT/KI-Mitbestimmung relevance (§87 BetrVG).
Usage:
python scripts/ingest_bag_urteile.py [--rag-url https://macmini:8097] [--dry-run]
"""
import argparse
import json
import os
import re
import sys
import tempfile
import time
import httpx
# ---------------------------------------------------------------------------
# Curated BAG decisions for IT/AI works council co-determination
# ---------------------------------------------------------------------------
BAG_DECISIONS = [
# --- M365 / Copilot / Standardsoftware ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/",
"case_number": "1 ABR 20/21",
"date": "2022-03-08",
"subject": "Microsoft Office 365 — Mitbestimmung",
"keywords": ["Microsoft 365", "Standardsoftware", "Ueberwachung", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abn-36-18/",
"case_number": "1 ABN 36/18",
"date": "2018-10-23",
"subject": "Excel / Standardsoftware — keine Geringfuegigkeitsschwelle",
"keywords": ["Excel", "Standardsoftware", "Geringfuegigkeit", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-45-11/",
"case_number": "1 ABR 45/11",
"date": "2012-09-25",
"subject": "SAP ERP im Personalwesen",
"keywords": ["SAP", "ERP", "Personalwesen", "Verhaltenskontrolle", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-31-19/",
"case_number": "1 ABR 31/19",
"date": "2021-01-27",
"subject": "E-Mail-Kommunikationssoftware — Mitbestimmung",
"keywords": ["E-Mail", "Kommunikation", "Software", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-13-17/",
"case_number": "1 ABR 13/17",
"date": "2019-07-09",
"subject": "IT-System fuer Mitarbeiterbefragung",
"keywords": ["Mitarbeiterbefragung", "Feedback", "technische Einrichtung", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-16-23/",
"case_number": "1 ABR 16/23",
"date": "2024-07-16",
"subject": "Headset-System — Geraetenutzungsdaten",
"keywords": ["Headset", "Geraetenutzung", "Ueberwachung", "§87 BetrVG"],
},
# --- Ueberwachung, Social, Drittplattformen ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-7-15/",
"case_number": "1 ABR 7/15",
"date": "2016-12-13",
"subject": "Facebook-Seite — indirekte Leistungsueberwachung",
"keywords": ["Facebook", "Social Media", "Besucherbeitraege", "Ueberwachung", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-43-12/",
"case_number": "1 ABR 43/12",
"date": "2013-12-10",
"subject": "Google Maps — indirekte Ueberwachung / Definition Ueberwachung",
"keywords": ["Google Maps", "Routenplaner", "indirekte Ueberwachung", "Definition", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-68-13/",
"case_number": "1 ABR 68/13",
"date": "2015-07-21",
"subject": "Ueberwachung durch technische Einrichtung eines Dritten (SaaS/Cloud)",
"keywords": ["Drittsystem", "SaaS", "Cloud", "Ueberwachung", "§87 BetrVG"],
},
# --- Video, Belastung, Leistungskennzahlen ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-78-11/",
"case_number": "1 ABR 78/11",
"date": "2012-12-11",
"subject": "Videoueberwachung — Grundsatzentscheidung",
"keywords": ["Videoueberwachung", "Kamera", "Arbeitsplatz", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-46-15/",
"case_number": "1 ABR 46/15",
"date": "2017-04-25",
"subject": "Belastungsstatistik — dauerhafte Kennzahlenueberwachung",
"keywords": ["Belastungsstatistik", "Kennzahlen", "Analytics", "Persoenlichkeitsrecht", "§87 BetrVG"],
},
# --- Negative / abgrenzende Faelle ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-32-16/",
"case_number": "1 ABR 32/16",
"date": "2017-12-19",
"subject": "Anti-Terror-Listen — keine Mitbestimmung",
"keywords": ["Anti-Terror", "Sanktionsliste", "keine Mitbestimmung", "Abgrenzung", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-22-21/",
"case_number": "1 ABR 22/21",
"date": "2022-09-13",
"subject": "Elektronische Arbeitszeiterfassung — Initiativrecht",
"keywords": ["Arbeitszeiterfassung", "Initiativrecht", "digitale Systeme", "§87 BetrVG"],
},
# --- Historische Grundsatzentscheidungen ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-43-81/",
"case_number": "1 ABR 43/81",
"date": "1983-12-06",
"subject": "Grundsatz technische Ueberwachung — Eignung genuegt",
"keywords": ["Grundsatz", "Eignung", "technische Einrichtung", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-23-82/",
"case_number": "1 ABR 23/82",
"date": "1984-09-14",
"subject": "Erste Grundlinie IT-Systeme",
"keywords": ["IT-System", "Grundlinie", "technische Einrichtung", "§87 BetrVG"],
},
# --- E-Mail / Internet ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-46-10/",
"case_number": "1 ABR 46/10",
"date": "2012-02-07",
"subject": "Internet- und E-Mail-Nutzung — Kommunikationsdaten",
"keywords": ["Internet", "E-Mail", "Kommunikationsdaten", "Auswertung", "§87 BetrVG"],
},
# --- HR / Bewertungssysteme ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-40-07/",
"case_number": "1 ABR 40/07",
"date": "2008-07-22",
"subject": "Beurteilungssysteme — §94/§95 BetrVG",
"keywords": ["Beurteilung", "Bewertungssystem", "HR", "§94 BetrVG", "§95 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-16-07/",
"case_number": "1 ABR 16/07",
"date": "2008-03-18",
"subject": "Personalfrageboegen — Bewertung",
"keywords": ["Personalfragebogen", "Bewertung", "HR-Tools", "§94 BetrVG"],
},
# --- Video / physische Ueberwachung ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-21-03/",
"case_number": "1 ABR 21/03",
"date": "2004-06-29",
"subject": "Videoueberwachung Arbeitsplatz",
"keywords": ["Video", "Kamera", "Arbeitsplatz", "Ueberwachung", "§87 BetrVG"],
},
# --- Zustaendigkeit ---
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-2-05/",
"case_number": "1 ABR 2/05",
"date": "2006-05-03",
"subject": "Zustaendigkeit Betriebsrat bei konzernweiten Tools",
"keywords": ["Zustaendigkeit", "Konzern", "Gesamtbetriebsrat", "§87 BetrVG"],
},
{
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-58-04/",
"case_number": "1 ABR 58/04",
"date": "2006-03-28",
"subject": "Mitbestimmung bei Einfuehrung technischer Systeme",
"keywords": ["Systemeinführung", "technische Systeme", "Mitbestimmung", "§87 BetrVG"],
},
]
def normalize_case_number(case_number: str) -> str:
"""Normalize case number for use as regulation_id."""
return re.sub(r"[^a-z0-9]", "_", case_number.lower()).strip("_")
def download_decision(url: str, client: httpx.Client) -> bytes:
"""Download a BAG decision page as HTML."""
resp = client.get(url, follow_redirects=True)
resp.raise_for_status()
return resp.content
def upload_to_rag(
file_bytes: bytes,
filename: str,
metadata: dict,
rag_url: str,
client: httpx.Client,
) -> dict:
"""Upload a document to the RAG service."""
files = {"file": (filename, file_bytes, "text/html")}
data = {
"collection": "bp_compliance_datenschutz",
"data_type": "compliance_datenschutz",
"bundesland": "bund",
"use_case": "court_decision",
"year": metadata.get("date", "2024")[:4],
"chunk_strategy": "legal",
"chunk_size": "512",
"chunk_overlap": "50",
"metadata_json": json.dumps(metadata),
}
resp = client.post(f"{rag_url}/api/v1/documents/upload", files=files, data=data)
resp.raise_for_status()
return resp.json()
def main():
parser = argparse.ArgumentParser(description="Ingest BAG court decisions into RAG")
parser.add_argument("--rag-url", default="https://macmini:8097", help="RAG service URL")
parser.add_argument("--dry-run", action="store_true", help="Download only, don't upload")
args = parser.parse_args()
client = httpx.Client(timeout=60, verify=False)
stats = {"downloaded": 0, "uploaded": 0, "errors": 0}
for decision in BAG_DECISIONS:
case_id = normalize_case_number(decision["case_number"])
print(f"\n--- {decision['case_number']}: {decision['subject']} ---")
# Download
try:
html_bytes = download_decision(decision["url"], client)
stats["downloaded"] += 1
print(f" Downloaded: {len(html_bytes)} bytes")
except Exception as e:
print(f" ERROR downloading: {e}")
stats["errors"] += 1
continue
if args.dry_run:
continue
# Upload
metadata = {
"regulation_id": f"bag_{case_id}",
"regulation_name_de": f"BAG {decision['case_number']}{decision['subject']}",
"category": "arbeitsrecht",
"source": "bundesarbeitsgericht.de",
"doc_type": "court_decision",
"license": "public_domain_§5_UrhG",
"court": "BAG",
"case_number": decision["case_number"],
"date": decision["date"],
"subject_matter": decision["subject"],
"keywords": decision["keywords"],
}
try:
result = upload_to_rag(
html_bytes,
f"bag_{case_id}.html",
metadata,
args.rag_url,
client,
)
stats["uploaded"] += 1
print(f" Uploaded: {result.get('chunks_count', '?')} chunks, doc_id={result.get('document_id', '?')}")
except Exception as e:
print(f" ERROR uploading: {e}")
stats["errors"] += 1
time.sleep(1) # Rate limiting
print(f"\n=== Done: {stats['downloaded']} downloaded, {stats['uploaded']} uploaded, {stats['errors']} errors ===")
if __name__ == "__main__":
main()
+130 -20
View File
@@ -489,7 +489,7 @@ class GeneratorConfig(BaseModel):
max_controls: int = 0 # 0 = unlimited (process ALL chunks)
max_chunks: int = 0 # 0 = unlimited; >0 = stop after N chunks (respects document boundaries)
skip_processed: bool = True
skip_web_search: bool = False
skip_web_search: bool = True # Default True — Anchor-Search verlangsamt 5x, nachtraeglich batchen
dry_run: bool = False
existing_job_id: Optional[str] = None # If set, reuse this job instead of creating a new one
regulation_filter: Optional[List[str]] = None # Only process chunks matching these regulation_code prefixes
@@ -544,6 +544,8 @@ class GeneratorResult:
controls_too_close: int = 0
controls_duplicates_found: int = 0
controls_qa_fixed: int = 0
controls_stored: int = 0 # Actually persisted to DB
controls_store_failed: int = 0 # Generated but failed to persist
chunks_skipped_prefilter: int = 0
errors: list = field(default_factory=list)
controls: list = field(default_factory=list)
@@ -645,6 +647,13 @@ async def _llm_anthropic(prompt: str, system_prompt: Optional[str] = None, max_r
json=payload,
)
if resp.status_code != 200:
# Retry on transient HTTP errors
if resp.status_code in (429, 500, 502, 503, 504) and attempt < max_retries:
wait = 2 ** attempt
logger.warning("Anthropic API %d (transient) — retry in %ds...", resp.status_code, wait)
import asyncio
await asyncio.sleep(wait)
continue
logger.error("Anthropic API %d: %s", resp.status_code, resp.text[:300])
return ""
data = resp.json()
@@ -1517,9 +1526,22 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Elementen. Fuer Aspekte ohne
final: list[Optional[GeneratedControl]] = []
for i in range(len(batch_items)):
control = all_controls.get(i)
if not control or (not control.title and not control.objective):
# Filter empty or invalid controls (LLM returned None/empty)
if not control:
final.append(None)
continue
title_invalid = not control.title or control.title.strip().lower() in ("none", "null", "")
obj_invalid = not control.objective or control.objective.strip().lower() in ("none", "null", "")
if title_invalid and obj_invalid:
logger.warning("Leerer Control gefiltert (title=%s, objective=%s) — wird nicht gespeichert",
control.title, control.objective)
final.append(None)
continue
# Clean up "None" strings from LLM
if title_invalid:
control.title = control.objective[:120] if control.objective else "Unbenannt"
if obj_invalid:
control.objective = control.title
if control.release_state == "too_close":
final.append(control)
@@ -1732,20 +1754,52 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Elementen. Fuer Aspekte ohne
)
def _generate_control_id(self, domain: str, db: Session) -> str:
"""Generate next sequential control ID like AUTH-011."""
"""Generate unique control ID using numeric MAX + collision guard.
Uses CAST to INTEGER for correct numeric ordering (not string sort).
Falls back to UUID suffix if collision is detected.
"""
prefix = domain.upper()[:4]
try:
# Numeric ordering — CAST to INTEGER, not string sort
result = db.execute(
text("SELECT control_id FROM canonical_controls WHERE control_id LIKE :prefix ORDER BY control_id DESC LIMIT 1"),
{"prefix": f"{prefix}-%"},
text("""
SELECT COALESCE(
MAX(CAST(SUBSTRING(control_id FROM :prefix_len) AS INTEGER)),
0
) + 1
FROM canonical_controls
WHERE control_id ~ (:pattern)
"""),
{"prefix_len": len(prefix) + 2, "pattern": f"^{prefix}-[0-9]+$"},
)
row = result.fetchone()
if row:
last_num = int(row[0].split("-")[-1])
return f"{prefix}-{last_num + 1:03d}"
except Exception:
pass
return f"{prefix}-001"
next_num = result.scalar() or 1
candidate = f"{prefix}-{next_num:03d}"
# Collision guard — check if ID already exists
exists = db.execute(
text("SELECT 1 FROM canonical_controls WHERE control_id = :cid LIMIT 1"),
{"cid": candidate},
).fetchone()
if exists:
# UUID suffix as fallback for race conditions
suffix = uuid.uuid4().hex[:6]
candidate = f"{prefix}-{next_num:03d}-{suffix}"
logger.warning(
"ID collision for %s-%03d — using unique suffix: %s",
prefix, next_num, candidate,
)
return candidate
except Exception as e:
# NEVER swallow silently — UUID as safe fallback
fallback = f"{prefix}-{uuid.uuid4().hex[:8]}"
logger.error(
"Failed to generate control_id for domain %s: %s — using fallback %s",
domain, e, fallback,
)
return fallback
# ── Stage QA: Automated Quality Validation ───────────────────────
@@ -1890,6 +1944,14 @@ Kategorien: {CATEGORY_LIST_STR}"""
def _store_control(self, control: GeneratedControl, job_id: str) -> Optional[str]:
"""Persist a generated control to DB. Returns the control UUID or None."""
# Pre-store quality guard — reject empty/invalid controls
if not control.title or control.title.strip().lower() in ("none", "null", ""):
logger.warning("Rejected control with empty/None title: %s", control.control_id)
return None
if not control.objective or control.objective.strip().lower() in ("none", "null", ""):
logger.warning("Rejected control with empty/None objective: %s%s", control.control_id, control.title)
return None
try:
# Get framework UUID
fw_result = self.db.execute(
@@ -1929,7 +1991,11 @@ Kategorien: {CATEGORY_LIST_STR}"""
:target_audience, :pipeline_version,
:applicable_industries, :applicable_company_size, :scope_conditions
)
ON CONFLICT (framework_id, control_id) DO NOTHING
ON CONFLICT (framework_id, control_id) DO UPDATE SET
updated_at = NOW(),
title = EXCLUDED.title,
objective = EXCLUDED.objective,
generation_metadata = EXCLUDED.generation_metadata
RETURNING id
"""),
{
@@ -2169,12 +2235,21 @@ Kategorien: {CATEGORY_LIST_STR}"""
if ctrl_uuid:
path = control.generation_metadata.get("processing_path", "structured_batch")
self._mark_chunk_processed(chunk, lic_info, path, [ctrl_uuid], job_id)
result.controls_generated += 1
result.controls_stored += 1
controls_count += 1
else:
self._mark_chunk_processed(chunk, lic_info, "store_failed", [], job_id)
# CRITICAL FIX: Do NOT mark chunk as processed — allow retry
logger.error(
"STORE_FAILED: Control '%s' (%s) nicht gespeichert — Chunk bleibt unverarbeitet fuer Retry",
control.control_id, control.title[:60],
)
result.controls_store_failed += 1
else:
result.controls_generated += 1
controls_count += 1
result.controls_generated += 1
result.controls.append(asdict(control))
controls_count += 1
if self._existing_controls is not None:
self._existing_controls.append({
@@ -2187,10 +2262,18 @@ Kategorien: {CATEGORY_LIST_STR}"""
try:
# Progress logging every 50 chunks
if i > 0 and i % 50 == 0:
store_rate = (result.controls_stored / max(result.controls_generated, 1)) * 100 if result.controls_generated > 0 else 100
logger.info(
"Progress: %d/%d chunks processed, %d controls generated, %d skipped by prefilter",
i, len(chunks), controls_count, chunks_skipped_prefilter,
"Progress: %d/%d chunks | %d generated | %d stored (%.0f%%) | %d store_failed | %d skipped",
i, len(chunks), result.controls_generated, result.controls_stored,
store_rate, result.controls_store_failed, chunks_skipped_prefilter,
)
# ALARM bei niedriger Store-Rate
if result.controls_generated > 10 and store_rate < 80:
logger.error(
"ALARM: Store-Erfolgsrate nur %.0f%% — moeglicherweise ID-Kollisionen!",
store_rate,
)
self._update_job(job_id, result)
# Stage 1.5: Local LLM pre-filter — skip chunks without requirements
@@ -2235,11 +2318,38 @@ Kategorien: {CATEGORY_LIST_STR}"""
await _flush_batch()
result.chunks_skipped_prefilter = chunks_skipped_prefilter
# Post-Job Validierung — DB-Realitaet pruefen
try:
actual_stored = self.db.execute(
text("SELECT count(*) FROM canonical_controls WHERE generation_metadata::text LIKE :jid"),
{"jid": f"%{job_id}%"},
).scalar() or 0
except Exception:
actual_stored = -1
final_store_rate = (result.controls_stored / max(result.controls_generated, 1)) * 100 if result.controls_generated > 0 else 0
logger.info(
"Pipeline complete: %d controls generated, %d chunks skipped by prefilter, %d total chunks",
controls_count, chunks_skipped_prefilter, len(chunks),
"Pipeline complete: %d chunks | %d generated | %d stored (%.0f%%) | %d store_failed | %d skipped | DB actual: %d",
len(chunks), result.controls_generated, result.controls_stored,
final_store_rate, result.controls_store_failed,
chunks_skipped_prefilter, actual_stored,
)
if result.controls_store_failed > 0:
logger.error(
"WARNUNG: %d Controls konnten NICHT gespeichert werden! "
"Diese Chunks bleiben unverarbeitet und muessen erneut verarbeitet werden.",
result.controls_store_failed,
)
if result.controls_generated > 0 and final_store_rate < 90:
logger.error(
"KRITISCH: Store-Rate nur %.0f%%%d von %d Controls verloren!",
final_store_rate, result.controls_store_failed, result.controls_generated,
)
result.status = "completed"
except Exception as e:
+5 -2
View File
@@ -61,6 +61,7 @@ services:
- "3008:3008" # Admin Core
- "3010:3010" # Portal Dashboard
- "8011:8011" # Compliance Docs (MkDocs)
- "3012:3012" # Pitch Deck
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- vault_certs:/etc/nginx/certs:ro
@@ -873,11 +874,13 @@ services:
dockerfile: Dockerfile
container_name: bp-core-pitch-deck
platform: linux/arm64
ports:
- "3012:3000"
expose:
- "3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
PITCH_JWT_SECRET: ${PITCH_JWT_SECRET:-7025f5da6d2ea384353ea6debddae0ea9e2dbca151a1df4b65be8cb80a5cf002}
PITCH_ADMIN_SECRET: ${PITCH_ADMIN_SECRET:-40df9e6f2ca2e90729030af37bf79199710b09c898cac9df}
LITELLM_URL: ${LITELLM_URL:-https://llm-dev.meghsakha.com}
LITELLM_MODEL: ${LITELLM_MODEL:-gpt-oss-120b}
LITELLM_API_KEY: ${LITELLM_API_KEY:-sk-0nAyxaMVbIqmz_ntnndzag}
+7 -3
View File
@@ -1,8 +1,8 @@
# Document Templates V2
Erweiterte Compliance-Vorlagen (DSFA, TOM, VVT, AVV) fuer den BreakPilot Document Generator.
Erweiterte Compliance-Vorlagen (DSFA, TOM, VVT, AVV, BV, FRIA) fuer den BreakPilot Document Generator.
**Branch:** `feature/document-templates-v2`
**Branch:** `feature/betriebsrat-compliance-module`
**Ziel-Integration:** breakpilot-compliance (nach Abschluss des Refactoring)
**Datenbank:** `compliance.compliance_legal_templates` (shared PostgreSQL)
@@ -17,14 +17,18 @@ Erweiterte Compliance-Vorlagen (DSFA, TOM, VVT, AVV) fuer den BreakPilot Documen
| `003_vvt_sector_templates.sql` | VVT | 6 Branchen-Muster (IT/SaaS, Gesundheit, Handel, Handwerk, Bildung, Beratung) |
| `004_avv_template.sql` | AVV | Auftragsverarbeitungsvertrag Art. 28, 12 Sections, TOM-Anlage |
| `005_additional_templates.sql` | Div. | Verpflichtungserklaerung + Art. 13/14 Informationspflichten |
| `006_betriebsvereinbarung_template.sql` | BV | Betriebsvereinbarung §87 BetrVG, 13 Sektionen (A-M), KI/IT-Systeme |
| `007_fria_template.sql` | FRIA | Grundrechte-Folgenabschaetzung Art. 27 AI Act, 8 Sektionen |
### Python Generators (`generators/`)
| Datei | Beschreibung |
|-------|--------------|
| `dsfa_template.py` | DSFA-Generator mit Schwellwertanalyse, Bundesland-Mapping, SDM-TOM, Art. 36 |
| `dsfa_template.py` | DSFA-Generator mit Schwellwertanalyse, Bundesland-Mapping, SDM-TOM, Art. 36, Domain-Risiken (HR/Edu/HC/Finance) |
| `tom_template.py` | TOM-Generator mit SDM-Struktur, NIS2/ISO27001/AI Act Erweiterungen, Sektoren |
| `vvt_template.py` | VVT-Generator mit 6 Branchen-Katalogen, Art. 30 Validierung |
| `betriebsvereinbarung_template.py` | BV-Generator mit TOM-Befuellung, Konflikt-Score-basierte Schutzklauseln |
| `fria_template.py` | FRIA-Generator mit Domain→Grundrechte-Mapping (6 Domains), Risikomatrix |
### Scripts (`scripts/`)
@@ -0,0 +1,214 @@
"""Betriebsvereinbarung template generator — creates BV draft from UCCA assessment.
Generates a modular works council agreement (Betriebsvereinbarung) based on:
- UCCA Assessment result (triggered rules, risk score, obligations)
- Company profile (name, location, works council)
- System details (name, type, modules)
Sections A-M follow the template in migration 006.
"""
from typing import Optional
# -- Default verbotene Nutzungen nach BAG-Rechtsprechung --------------------
DEFAULT_VERBOTENE_NUTZUNGEN = [
"Verdeckte Leistungs- oder Verhaltenskontrolle einzelner Beschaeftigter",
"Erstellung individueller Persoenlichkeitsprofile oder Verhaltensanalysen",
"Nutzung von Nutzungshistorien zu disziplinarischen Zwecken",
"Automatisierte Personalentscheidungen ohne menschliche Ueberpruefung (Art. 22 DSGVO)",
"Personenbezogene Rankings oder Leistungsvergleiche ohne gesonderte Mitbestimmung",
"Korrelation von Systemnutzungsdaten mit Leistungsbeurteilungen",
]
AI_VERBOTENE_NUTZUNGEN = [
"Einsatz von KI-Funktionen zur biometrischen Echtzeit-Identifizierung am Arbeitsplatz",
"KI-gestuetztes Social Scoring von Beschaeftigten",
"Nutzung von KI-generierten Bewertungen als alleinige Grundlage fuer Personalentscheidungen",
]
# -- Standard-TOM Massnahmen ------------------------------------------------
DEFAULT_TOM = [
"Rollen- und Rechtekonzept mit Least-Privilege-Prinzip",
"Verschluesselung der Daten bei Uebertragung (TLS 1.2+) und Speicherung (AES-256)",
"Protokollierung aller administrativen Zugriffe",
"Pseudonymisierung personenbezogener Daten, wo technisch moeglich",
"Deaktivierung nicht benoetigter Telemetrie- und Diagnosefunktionen",
"Getrennte Umgebungen fuer Test und Produktion",
"Regelmaessige Sicherheitsupdates und Patch-Management",
"Zugangsschutz durch Multi-Faktor-Authentifizierung fuer Administratoren",
]
# -- Standard erlaubte Reports ----------------------------------------------
DEFAULT_ERLAUBTE_REPORTS = [
"Systemgesundheit und Verfuegbarkeit (ohne Personenbezug)",
"Lizenznutzung auf aggregierter Ebene (Abteilung/Standort, nicht Person)",
"Sicherheitsereignisse und Anomalien",
"Speicherplatznutzung (ohne Personenbezug)",
"Fehlerstatistiken (technisch, nicht personenbezogen)",
]
# -- Standard Datenarten bei IT/KI-Systemen ---------------------------------
DATENARTEN_MAP = {
"email": "E-Mail-Metadaten (Absender, Empfaenger, Zeitstempel — NICHT Inhalte)",
"chat": "Chat-/Messaging-Metadaten (Teilnehmer, Zeitstempel)",
"document": "Dokumentenmetadaten (Ersteller, Aenderungsdatum, Dateiname)",
"login": "Anmeldedaten (Benutzername, Zeitstempel, IP-Adresse)",
"usage": "Nutzungsdaten (aufgerufene Funktionen, Nutzungsdauer — aggregiert)",
"prompt": "KI-Eingaben und -Ausgaben (Prompts, Antworten)",
"calendar": "Kalendereintraege (Betreff, Teilnehmer, Zeiten)",
"hr": "Personalstammdaten (Name, Abteilung, Position, Eintrittsdatum)",
"performance": "Leistungsdaten (Kennzahlen, Bewertungen, Zielvereinbarungen)",
"video": "Videoaufnahmen (Arbeitsplatz, Zugangsbereiche)",
"location": "Standortdaten (GPS, WLAN-basierte Ortung, Gebaeudezutritt)",
}
def generate_betriebsvereinbarung_draft(ctx: dict) -> dict:
"""Generate a Betriebsvereinbarung draft from company + assessment context.
Args:
ctx: Dict with keys:
Required:
- company_name: str
- system_name: str
- system_description: str
Optional:
- company_address: str
- employer_representative: str
- works_council_chair: str
- system_vendor: str
- locations: list[str]
- departments: list[str]
- modules: list[str]
- purposes: list[str]
- data_types: list[str] keys from DATENARTEN_MAP
- is_ai_system: bool
- has_employee_monitoring: bool
- has_hr_features: bool
- has_video: bool
- dpo_name: str
- dpo_contact: str
- audit_interval: str e.g. "12 Monate"
- duration: str e.g. "unbefristet"
- notice_period: str e.g. "3 Monate"
- retention_audit_logs: str e.g. "90 Tage"
- retention_usage_data: str e.g. "30 Tage"
- retention_prompts: str e.g. "deaktiviert"
- additional_forbidden: list[str]
- additional_tom: list[str]
- additional_reports: list[str]
- betrvg_conflict_score: int 0-100
Returns:
Dict with placeholder values ready for template substitution.
"""
result = {}
# Basic info
result["UNTERNEHMEN_NAME"] = ctx.get("company_name", "{{UNTERNEHMEN_NAME}}")
result["UNTERNEHMEN_SITZ"] = ctx.get("company_address", "{{UNTERNEHMEN_SITZ}}")
result["ARBEITGEBER_VERTRETER"] = ctx.get("employer_representative", "{{ARBEITGEBER_VERTRETER}}")
result["BETRIEBSRAT_VORSITZ"] = ctx.get("works_council_chair", "{{BETRIEBSRAT_VORSITZ}}")
result["SYSTEM_NAME"] = ctx.get("system_name", "{{SYSTEM_NAME}}")
result["SYSTEM_BESCHREIBUNG"] = ctx.get("system_description", "{{SYSTEM_BESCHREIBUNG}}")
result["SYSTEM_HERSTELLER"] = ctx.get("system_vendor", "")
result["DSB_NAME"] = ctx.get("dpo_name", "{{DSB_NAME}}")
result["DSB_KONTAKT"] = ctx.get("dpo_contact", "{{DSB_KONTAKT}}")
# B. Geltungsbereich
locations = ctx.get("locations", [])
result["GELTUNGSBEREICH_STANDORTE"] = _bullet_list(locations) if locations else "Alle Standorte der {{UNTERNEHMEN_NAME}}"
departments = ctx.get("departments", [])
result["GELTUNGSBEREICH_BEREICHE"] = _bullet_list(departments) if departments else "Alle Beschaeftigten"
modules = ctx.get("modules", [])
result["GELTUNGSBEREICH_MODULE"] = _bullet_list(modules) if modules else "Alle Module und Dienste von {{SYSTEM_NAME}}"
# C. Zweck
purposes = ctx.get("purposes", [])
result["ZWECK_BESCHREIBUNG"] = _bullet_list(purposes) if purposes else "{{ZWECK_BESCHREIBUNG}}"
# C.2 Verbotene Nutzungen
forbidden = list(DEFAULT_VERBOTENE_NUTZUNGEN)
if ctx.get("is_ai_system"):
forbidden.extend(AI_VERBOTENE_NUTZUNGEN)
forbidden.extend(ctx.get("additional_forbidden", []))
result["VERBOTENE_NUTZUNGEN"] = _bullet_list(forbidden)
# D. Datenarten
data_type_keys = ctx.get("data_types", [])
datenarten = []
for key in data_type_keys:
if key in DATENARTEN_MAP:
datenarten.append(DATENARTEN_MAP[key])
else:
datenarten.append(key)
result["DATENARTEN_LISTE"] = _bullet_list(datenarten) if datenarten else "{{DATENARTEN_LISTE}}"
# E. Rollen
result["ROLLEN_ADMIN"] = ctx.get("roles_admin", "IT-Administration: Systemkonfiguration, Benutzerverwaltung, Sicherheitsupdates")
result["ROLLEN_FUEHRUNGSKRAFT"] = ctx.get("roles_manager", "Fuehrungskraefte: Nur aggregierte, nicht-personenbezogene Reports")
result["ROLLEN_REPORTING"] = ctx.get("roles_reporting", "Controlling/Reporting: Nur freigegebene Standardreports (siehe Abschnitt G)")
# F. Transparenz
result["TRANSPARENZ_INFO"] = ctx.get("transparency_info",
"Die Information erfolgt schriftlich und in einer Informationsveranstaltung vor Einfuehrung des Systems.")
# G. Reports
reports = list(DEFAULT_ERLAUBTE_REPORTS)
reports.extend(ctx.get("additional_reports", []))
result["ERLAUBTE_REPORTS"] = _bullet_list(reports)
# H. Speicherfristen
result["SPEICHERFRIST_AUDIT_LOGS"] = ctx.get("retention_audit_logs", "90 Tage")
result["SPEICHERFRIST_NUTZUNGSDATEN"] = ctx.get("retention_usage_data", "30 Tage")
result["SPEICHERFRIST_CHAT_PROMPTS"] = ctx.get("retention_prompts", "deaktiviert")
# I. TOM
tom = list(DEFAULT_TOM)
tom.extend(ctx.get("additional_tom", []))
# Intensivere Schutzmassnahmen bei hohem Konflikt-Score
conflict_score = ctx.get("betrvg_conflict_score", 0)
if conflict_score >= 50:
tom.append("Automatische Anomalie-Erkennung bei ungewoehnlichen Admin-Zugriffen")
tom.append("Quartalsweise Datenschutz-Audit durch externen Prueer")
if conflict_score >= 75:
tom.append("Betriebsrat erhaelt Leserechte auf Audit-Log-Dashboard")
tom.append("Jede Sonderauswertung wird dem Betriebsrat innerhalb von 24h gemeldet")
result["TOM_MASSNAHMEN"] = _bullet_list(tom)
# J. Change-Management
result["CHANGE_MANAGEMENT_PROZESS"] = ctx.get("change_process",
"Die Arbeitgeberin informiert den Betriebsrat schriftlich ueber geplante Aenderungen "
"mindestens 14 Kalendertage vor Umsetzung. Bei sicherheitskritischen Updates kann die "
"Frist auf 3 Werktage verkuerzt werden.")
# K. Audit
result["AUDIT_INTERVALL"] = ctx.get("audit_interval", "12 Monate")
# L. Beschwerde
result["BESCHWERDE_ANSPRECHPARTNER"] = ctx.get("complaint_contacts",
"- Direkter Vorgesetzter\n- Betriebsrat ({{BETRIEBSRAT_VORSITZ}})\n"
"- Datenschutzbeauftragter ({{DSB_NAME}}, {{DSB_KONTAKT}})")
# M. Schluss
result["LAUFZEIT"] = ctx.get("duration", "unbefristet")
result["KUENDIGUNGSFRIST"] = ctx.get("notice_period", "3 Monate")
result["DATUM_UNTERZEICHNUNG"] = ctx.get("signing_date", "{{DATUM_UNTERZEICHNUNG}}")
# Conditional flags
result["AI_SYSTEM"] = ctx.get("is_ai_system", False)
result["VIDEO_UEBERWACHUNG"] = ctx.get("has_video", False)
result["HR_SYSTEM"] = ctx.get("has_hr_features", False)
return result
def _bullet_list(items: list) -> str:
"""Format a list as markdown bullet points."""
return "\n".join(f"- {item}" for item in items)
@@ -330,6 +330,36 @@ def _generate_risk_assessment(ctx: dict) -> str:
if any(ctx.get(k) for k in ["third_country_transfer", "processes_in_third_country"]):
risks.append(("Zugriff durch Behoerden in Drittlaendern", "mittel", "hoch", "hoch"))
# FISA 702 Risiko bei US-Cloud-Providern
hosting = (ctx.get("hosting_provider") or "").lower()
us_providers = ("aws", "azure", "google", "microsoft", "amazon", "openai", "anthropic", "oracle")
if any(p in hosting for p in us_providers):
risks.append(("FISA 702: Zugriff durch US-Behoerden auf EU-Daten nicht ausschliessbar", "mittel", "hoch", "hoch"))
risks.append(("EU-Serverstandort schuetzt nicht gegen US-Rechtszugriff (Cloud Act + FISA)", "mittel", "hoch", "hoch"))
risks.append(("Fehlende effektive Rechtsbehelfe fuer EU-Betroffene gegen US-Ueberwachung", "mittel", "hoch", "hoch"))
# Domain-spezifische Risiken (AI Act Annex III)
domain = ctx.get("domain", "")
if domain in ("hr", "recruiting") or ctx.get("has_hr_context"):
risks.append(("AGG-Verstoss: Diskriminierung bei Bewerberauswahl (§ 1 AGG)", "mittel", "hoch", "hoch"))
risks.append(("Beweislastumkehr bei Diskriminierungsklagen (§ 22 AGG)", "mittel", "hoch", "hoch"))
risks.append(("Art. 22 DSGVO: Unzulaessige automatisierte Einzelentscheidung", "mittel", "hoch", "hoch"))
risks.append(("Proxy-Diskriminierung durch Name/Foto/Alter-Erkennung", "mittel", "hoch", "hoch"))
if domain in ("education", "higher_education", "vocational_training"):
risks.append(("Chancenungleichheit durch KI-gestuetzte Bewertung", "mittel", "hoch", "hoch"))
risks.append(("Benachteiligung Minderjaehriger ohne Lehrkraft-Kontrolle", "niedrig", "gross", "hoch"))
risks.append(("Fehlbewertung mit Auswirkung auf Bildungschancen", "mittel", "hoch", "hoch"))
if domain in ("healthcare", "medical_devices", "pharma", "elderly_care"):
risks.append(("Fehldiagnose durch KI mit gesundheitlichen Folgen", "niedrig", "gross", "hoch"))
risks.append(("Falsche Triage-Priorisierung (lebenskritisch)", "niedrig", "gross", "hoch"))
risks.append(("Verletzung der Patientenautonomie", "mittel", "hoch", "hoch"))
if domain in ("finance", "banking", "insurance", "investment"):
risks.append(("Diskriminierendes Kredit-Scoring", "mittel", "hoch", "hoch"))
risks.append(("Ungerechtfertigte Verweigerung von Finanzdienstleistungen", "mittel", "hoch", "hoch"))
lines.append("| Risiko | Eintrittswahrscheinlichkeit | Schwere | Gesamt |")
lines.append("|--------|----------------------------|---------|--------|")
for risk_name, likelihood, severity, overall in risks:
@@ -0,0 +1,227 @@
"""FRIA template generator — creates Fundamental Rights Impact Assessment from UCCA context.
Generates a FRIA (Art. 27 AI Act) based on:
- UCCA Assessment result (risk level, triggered rules, domain)
- AI Act Decision Tree classification
- Company profile
Automatically maps domains to affected fundamental rights.
"""
from typing import Optional
# -- Domain → Fundamental Rights Mapping ------------------------------------
DOMAIN_RIGHTS_MAP = {
"education": [
{"right": "Recht auf Bildung", "charter": "Art. 14", "gg": "Art. 12",
"risk": "Chancengleichheit bei KI-gestuetzter Bewertung oder Auswahl"},
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
"risk": "Bias bei Leistungsbewertung nach Herkunft, Sprache oder Geschlecht"},
{"right": "Rechte des Kindes", "charter": "Art. 24", "gg": "Art. 6 Abs. 2",
"risk": "Besonderer Schutz Minderjaehriger vor automatisierten Bewertungen"},
],
"hr": [
{"right": "Berufsfreiheit / Recht zu arbeiten", "charter": "Art. 15", "gg": "Art. 12",
"risk": "KI-gestuetzte Auswahl kann Zugang zum Arbeitsmarkt einschraenken"},
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
"risk": "Bias bei Recruiting, Befoerderung oder Kuendigung"},
{"right": "Schutz personenbezogener Daten", "charter": "Art. 8", "gg": "Art. 2 Abs. 1",
"risk": "Umfangreiche Verarbeitung von Beschaeftigtendaten"},
],
"healthcare": [
{"right": "Menschenwuerde", "charter": "Art. 1", "gg": "Art. 1",
"risk": "KI-Diagnosen koennen existenzielle Auswirkungen haben"},
{"right": "Schutz personenbezogener Daten", "charter": "Art. 8", "gg": "Art. 2 Abs. 1",
"risk": "Gesundheitsdaten sind besondere Kategorien (Art. 9 DSGVO)"},
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
"risk": "Bias bei Behandlungsempfehlungen nach Alter, Geschlecht oder Ethnie"},
],
"finance": [
{"right": "Recht auf soziale Sicherheit", "charter": "Art. 34", "gg": "Art. 20",
"risk": "Zugang zu Finanzdienstleistungen und Versicherungen"},
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
"risk": "Scoring-Bias bei Kreditvergabe oder Versicherungspraemien"},
{"right": "Recht auf wirksamen Rechtsbehelf", "charter": "Art. 47", "gg": "Art. 19 Abs. 4",
"risk": "Anfechtbarkeit automatisierter Finanzentscheidungen"},
],
"law_enforcement": [
{"right": "Recht auf Freiheit und Sicherheit", "charter": "Art. 6", "gg": "Art. 2 Abs. 2",
"risk": "KI-gestuetzte Ueberwachung oder Vorhersage"},
{"right": "Unschuldsvermutung", "charter": "Art. 48", "gg": "Art. 20 Abs. 3",
"risk": "Predictive Policing kann Vorverurteilung erzeugen"},
{"right": "Recht auf Privatsphaere", "charter": "Art. 7", "gg": "Art. 2 Abs. 1",
"risk": "Biometrische Identifizierung im oeffentlichen Raum"},
],
"public_sector": [
{"right": "Recht auf eine gute Verwaltung", "charter": "Art. 41", "gg": "Art. 20 Abs. 3",
"risk": "Automatisierte Verwaltungsentscheidungen muessen nachvollziehbar sein"},
{"right": "Nicht-Diskriminierung", "charter": "Art. 21", "gg": "Art. 3",
"risk": "Gleichbehandlung aller Buerger bei KI-gestuetzten Verwaltungsakten"},
{"right": "Recht auf wirksamen Rechtsbehelf", "charter": "Art. 47", "gg": "Art. 19 Abs. 4",
"risk": "Widerspruchsmoeglichkeit gegen KI-gestuetzte Bescheide"},
],
}
# Universal rights (always relevant for High-Risk AI)
UNIVERSAL_RIGHTS = [
{"right": "Schutz personenbezogener Daten", "charter": "Art. 8", "gg": "Art. 2 Abs. 1 i.V.m. Art. 1 Abs. 1",
"risk": "Datenverarbeitung durch KI-System"},
{"right": "Menschenwuerde", "charter": "Art. 1", "gg": "Art. 1",
"risk": "KI darf Menschen nicht auf Datenpunkte reduzieren"},
]
# -- Default measures -------------------------------------------------------
DEFAULT_MEASURES = [
"Human-in-the-Loop: Menschliche Ueberpruefung aller KI-Empfehlungen vor Umsetzung",
"Transparenz: Betroffene werden ueber den Einsatz von KI informiert",
"Erklaerbarkeit: KI-Ergebnisse koennen nachvollzogen und begruendet werden",
"Beschwerdemechanismus: Betroffene koennen KI-Entscheidungen anfechten",
"Logging: Alle Eingaben und Ausgaben werden fuer Audit-Zwecke protokolliert",
"Regelmaessige Bias-Audits: Systematische Pruefung auf Diskriminierung",
]
HR_MEASURES = [
"AGG-konforme Gestaltung: Kein Bias bei Geschlecht, Alter, Herkunft, Behinderung",
"Betriebsrat gemaess §87 Abs.1 Nr.6 und §95 BetrVG beteiligt",
"Keine automatisierte Endentscheidung bei Personalangelegenheiten",
]
EDUCATION_MEASURES = [
"Lehrkraft ueberprueft und verantwortet alle KI-generierten Bewertungen",
"Chancengleichheit unabhaengig von sozioekonomischem Hintergrund",
"Schueler/Eltern koennen KI-gestuetzte Bewertungen anfechten",
]
def generate_fria_draft(ctx: dict) -> dict:
"""Generate a FRIA draft from UCCA assessment context.
Args:
ctx: Dict with keys:
Required:
- organisation_name: str
- system_name: str
- system_description: str
- einsatzzweck: str
Optional:
- organisation_address: str
- system_version: str
- system_provider: str
- domain: str (education, hr, healthcare, finance, etc.)
- affected_groups: list[str]
- affected_count: str
- ai_act_classification: str (high_risk, limited_risk, etc.)
- annex_iii_category: str
- is_public_entity: bool
- has_hr_context: bool
- has_education_context: bool
- risk_score: int
- dpo_name: str
- dpo_contact: str
- review_interval: str
Returns:
Dict with placeholder values for template substitution.
"""
result = {}
# Section 1: Basic info
result["ORGANISATION_NAME"] = ctx.get("organisation_name", "{{ORGANISATION_NAME}}")
result["ORGANISATION_ADRESSE"] = ctx.get("organisation_address", "{{ORGANISATION_ADRESSE}}")
result["VERANTWORTLICHER"] = ctx.get("responsible_person", "{{VERANTWORTLICHER}}")
result["ERSTELLT_VON"] = ctx.get("created_by", "{{ERSTELLT_VON}}")
result["ERSTELLT_AM"] = ctx.get("created_at", "{{ERSTELLT_AM}}")
result["SYSTEM_NAME"] = ctx.get("system_name", "{{SYSTEM_NAME}}")
result["SYSTEM_VERSION"] = ctx.get("system_version", "1.0")
result["SYSTEM_BESCHREIBUNG"] = ctx.get("system_description", "{{SYSTEM_BESCHREIBUNG}}")
result["SYSTEM_ANBIETER"] = ctx.get("system_provider", "{{SYSTEM_ANBIETER}}")
result["EINSATZZWECK"] = ctx.get("einsatzzweck", "{{EINSATZZWECK}}")
result["EINSATZKONTEXT"] = ctx.get("einsatzkontext", "{{EINSATZKONTEXT}}")
result["AI_ACT_KLASSIFIKATION"] = ctx.get("ai_act_classification", "High-Risk")
result["ANNEX_III_KATEGORIE"] = ctx.get("annex_iii_category", "")
result["DSB_NAME"] = ctx.get("dpo_name", "{{DSB_NAME}}")
result["DSB_KONTAKT"] = ctx.get("dpo_contact", "{{DSB_KONTAKT}}")
# Section 1.5: Affected groups
groups = ctx.get("affected_groups", [])
result["BETROFFENE_GRUPPEN"] = _bullet_list(groups) if groups else "{{BETROFFENE_GRUPPEN}}"
result["BETROFFENE_ANZAHL"] = ctx.get("affected_count", "{{BETROFFENE_ANZAHL}}")
# Section 2: Fundamental rights mapping
domain = ctx.get("domain", "")
rights = list(UNIVERSAL_RIGHTS)
if domain in DOMAIN_RIGHTS_MAP:
rights.extend(DOMAIN_RIGHTS_MAP[domain])
rights_table = []
for i, r in enumerate(rights, 1):
rights_table.append(
f"| {i} | {r['right']} | {r['charter']} | {r['gg']} | Ja | {r['risk']} |"
)
result["GRUNDRECHTE_ANALYSE"] = "\n".join(rights_table) if rights_table else "{{GRUNDRECHTE_ANALYSE}}"
# Section 3: Risk matrix
risk_rows = []
risk_score = ctx.get("risk_score", 0)
base_likelihood = min(3, 1 + risk_score // 30)
for r in rights:
severity = 3 if "Diskriminierung" in r["risk"] or "existenz" in r["risk"].lower() else 2
likelihood = base_likelihood
level = _risk_level(likelihood * severity)
risk_rows.append(
f"| {r['right']} | {r['risk']} | {likelihood} | {severity} | {level} | Basierend auf Systemanalyse |"
)
result["RISIKOMATRIX"] = "\n".join(risk_rows) if risk_rows else "{{RISIKOMATRIX}}"
# Section 4: Measures
measures = list(DEFAULT_MEASURES)
if ctx.get("has_hr_context") or domain == "hr":
measures.extend(HR_MEASURES)
if ctx.get("has_education_context") or domain == "education":
measures.extend(EDUCATION_MEASURES)
result["MASSNAHMEN_LISTE"] = _bullet_list(measures)
result["HUMAN_OVERSIGHT_BESCHREIBUNG"] = ctx.get("human_oversight",
"Das System unterstuetzt menschliche Entscheidungen, trifft jedoch keine eigenstaendigen Entscheidungen. "
"Alle KI-generierten Empfehlungen werden von qualifiziertem Personal geprueft.")
result["TRANSPARENZ_MASSNAHMEN"] = ctx.get("transparency_measures",
"Betroffene Personen werden ueber den Einsatz des KI-Systems informiert. "
"KI-generierte Ergebnisse werden als solche gekennzeichnet.")
# Section 5: Consultation
result["KONSULTATION_ERGEBNISSE"] = ctx.get("consultation_results",
"Konsultation steht aus — bitte vor Freigabe durchfuehren.")
# Section 6: Approval
result["GENEHMIGT_VON"] = ctx.get("approved_by", "{{GENEHMIGT_VON}}")
result["GENEHMIGT_AM"] = ctx.get("approved_at", "{{GENEHMIGT_AM}}")
# Section 7: Monitoring
result["NAECHSTE_UEBERPRUEFUNG"] = ctx.get("review_interval", "12 Monate nach Inbetriebnahme")
# Conditional flags
result["BILDUNGSKONTEXT"] = ctx.get("has_education_context", False) or domain == "education"
result["HR_KONTEXT"] = ctx.get("has_hr_context", False) or domain == "hr"
result["OEFFENTLICHE_STELLE"] = ctx.get("is_public_entity", False)
return result
def _risk_level(score: int) -> str:
"""Map risk score to level label."""
if score <= 6:
return "Niedrig"
elif score <= 12:
return "Mittel"
elif score <= 19:
return "Hoch"
else:
return "Kritisch"
def _bullet_list(items: list) -> str:
"""Format a list as markdown bullet points."""
return "\n".join(f"- {item}" for item in items)
@@ -0,0 +1,158 @@
"""Tests for Betriebsvereinbarung template generator."""
import pytest
from betriebsvereinbarung_template import (
generate_betriebsvereinbarung_draft,
DEFAULT_VERBOTENE_NUTZUNGEN,
AI_VERBOTENE_NUTZUNGEN,
DEFAULT_TOM,
DATENARTEN_MAP,
)
class TestGenerateBetriebsvereinbarung:
"""Tests for generate_betriebsvereinbarung_draft()."""
def test_minimal_context(self):
"""Minimal context should produce valid output with placeholders."""
ctx = {
"company_name": "Test GmbH",
"system_name": "Microsoft 365",
"system_description": "Office-Suite mit KI-Funktionen",
}
result = generate_betriebsvereinbarung_draft(ctx)
assert result["UNTERNEHMEN_NAME"] == "Test GmbH"
assert result["SYSTEM_NAME"] == "Microsoft 365"
assert "{{BETRIEBSRAT_VORSITZ}}" in result["BETRIEBSRAT_VORSITZ"]
def test_full_context(self):
"""Full context should fill all placeholders."""
ctx = {
"company_name": "Acme Corp",
"company_address": "Hamburg",
"employer_representative": "Dr. Schmidt",
"works_council_chair": "Fr. Mueller",
"system_name": "Copilot",
"system_description": "KI-Assistent",
"system_vendor": "Microsoft",
"locations": ["Hamburg", "Berlin"],
"departments": ["IT", "HR"],
"modules": ["Teams", "Outlook", "Word"],
"purposes": ["Texterstellung", "Zusammenfassung"],
"data_types": ["email", "chat", "login"],
"is_ai_system": True,
"dpo_name": "Dr. Datenschutz",
"dpo_contact": "dsb@acme.de",
"audit_interval": "6 Monate",
"duration": "2 Jahre",
"notice_period": "6 Monate",
}
result = generate_betriebsvereinbarung_draft(ctx)
assert result["ARBEITGEBER_VERTRETER"] == "Dr. Schmidt"
assert result["BETRIEBSRAT_VORSITZ"] == "Fr. Mueller"
assert "Hamburg" in result["GELTUNGSBEREICH_STANDORTE"]
assert "Berlin" in result["GELTUNGSBEREICH_STANDORTE"]
assert "Teams" in result["GELTUNGSBEREICH_MODULE"]
assert result["AUDIT_INTERVALL"] == "6 Monate"
assert result["LAUFZEIT"] == "2 Jahre"
assert result["AI_SYSTEM"] is True
def test_verbotene_nutzungen_default(self):
"""Default forbidden uses should always be included."""
ctx = {"company_name": "Test", "system_name": "Tool", "system_description": "x"}
result = generate_betriebsvereinbarung_draft(ctx)
for nutzung in DEFAULT_VERBOTENE_NUTZUNGEN:
assert nutzung in result["VERBOTENE_NUTZUNGEN"]
def test_verbotene_nutzungen_ai_system(self):
"""AI-specific forbidden uses should be added for AI systems."""
ctx = {
"company_name": "Test",
"system_name": "Tool",
"system_description": "x",
"is_ai_system": True,
}
result = generate_betriebsvereinbarung_draft(ctx)
for nutzung in AI_VERBOTENE_NUTZUNGEN:
assert nutzung in result["VERBOTENE_NUTZUNGEN"]
def test_verbotene_nutzungen_no_ai(self):
"""AI-specific forbidden uses should NOT be added for non-AI systems."""
ctx = {
"company_name": "Test",
"system_name": "Tool",
"system_description": "x",
"is_ai_system": False,
}
result = generate_betriebsvereinbarung_draft(ctx)
for nutzung in AI_VERBOTENE_NUTZUNGEN:
assert nutzung not in result["VERBOTENE_NUTZUNGEN"]
def test_datenarten_mapping(self):
"""Data types should be resolved from DATENARTEN_MAP."""
ctx = {
"company_name": "Test",
"system_name": "Tool",
"system_description": "x",
"data_types": ["email", "prompt", "hr"],
}
result = generate_betriebsvereinbarung_draft(ctx)
assert DATENARTEN_MAP["email"] in result["DATENARTEN_LISTE"]
assert DATENARTEN_MAP["prompt"] in result["DATENARTEN_LISTE"]
assert DATENARTEN_MAP["hr"] in result["DATENARTEN_LISTE"]
def test_tom_high_conflict_score(self):
"""High conflict score should add extra TOM measures."""
ctx_low = {
"company_name": "Test",
"system_name": "Tool",
"system_description": "x",
"betrvg_conflict_score": 20,
}
ctx_high = {
"company_name": "Test",
"system_name": "Tool",
"system_description": "x",
"betrvg_conflict_score": 80,
}
result_low = generate_betriebsvereinbarung_draft(ctx_low)
result_high = generate_betriebsvereinbarung_draft(ctx_high)
# High score should have more TOM items
low_count = result_low["TOM_MASSNAHMEN"].count("- ")
high_count = result_high["TOM_MASSNAHMEN"].count("- ")
assert high_count > low_count, f"High conflict ({high_count} TOMs) should have more than low ({low_count})"
def test_speicherfristen_defaults(self):
"""Default retention periods should be set."""
ctx = {"company_name": "Test", "system_name": "Tool", "system_description": "x"}
result = generate_betriebsvereinbarung_draft(ctx)
assert result["SPEICHERFRIST_AUDIT_LOGS"] == "90 Tage"
assert result["SPEICHERFRIST_NUTZUNGSDATEN"] == "30 Tage"
assert result["SPEICHERFRIST_CHAT_PROMPTS"] == "deaktiviert"
def test_custom_retention(self):
"""Custom retention periods should override defaults."""
ctx = {
"company_name": "Test",
"system_name": "Tool",
"system_description": "x",
"retention_audit_logs": "180 Tage",
"retention_prompts": "7 Tage",
}
result = generate_betriebsvereinbarung_draft(ctx)
assert result["SPEICHERFRIST_AUDIT_LOGS"] == "180 Tage"
assert result["SPEICHERFRIST_CHAT_PROMPTS"] == "7 Tage"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,198 @@
"""Tests for FRIA (Fundamental Rights Impact Assessment) template generator."""
import pytest
from fria_template import (
generate_fria_draft,
DOMAIN_RIGHTS_MAP,
UNIVERSAL_RIGHTS,
DEFAULT_MEASURES,
HR_MEASURES,
EDUCATION_MEASURES,
)
class TestGenerateFRIA:
"""Tests for generate_fria_draft()."""
def test_minimal_context(self):
ctx = {
"organisation_name": "Test GmbH",
"system_name": "AI Tool",
"system_description": "KI-Assistenz",
"einsatzzweck": "Automatisierung",
}
result = generate_fria_draft(ctx)
assert result["ORGANISATION_NAME"] == "Test GmbH"
assert result["SYSTEM_NAME"] == "AI Tool"
assert result["AI_ACT_KLASSIFIKATION"] == "High-Risk"
def test_hr_domain_rights(self):
ctx = {
"organisation_name": "HR Corp",
"system_name": "Recruiting AI",
"system_description": "Bewerber-Screening",
"einsatzzweck": "Personalauswahl",
"domain": "hr",
}
result = generate_fria_draft(ctx)
# HR domain should include employment rights
assert "Berufsfreiheit" in result["GRUNDRECHTE_ANALYSE"]
assert "Nicht-Diskriminierung" in result["GRUNDRECHTE_ANALYSE"]
assert result["HR_KONTEXT"] is True
assert result["BILDUNGSKONTEXT"] is False
def test_education_domain_rights(self):
ctx = {
"organisation_name": "Schule",
"system_name": "Bewertungs-KI",
"system_description": "Notenunterstuetzung",
"einsatzzweck": "Leistungsbewertung",
"domain": "education",
}
result = generate_fria_draft(ctx)
assert "Recht auf Bildung" in result["GRUNDRECHTE_ANALYSE"]
assert "Rechte des Kindes" in result["GRUNDRECHTE_ANALYSE"]
assert result["BILDUNGSKONTEXT"] is True
def test_healthcare_domain_rights(self):
ctx = {
"organisation_name": "Klinik",
"system_name": "Diagnose-KI",
"system_description": "Diagnoseunterstuetzung",
"einsatzzweck": "Diagnostik",
"domain": "healthcare",
}
result = generate_fria_draft(ctx)
assert "Menschenwuerde" in result["GRUNDRECHTE_ANALYSE"]
assert "Schutz personenbezogener Daten" in result["GRUNDRECHTE_ANALYSE"]
def test_universal_rights_always_present(self):
for domain in ["hr", "education", "healthcare", "finance", ""]:
ctx = {
"organisation_name": "Test",
"system_name": "Tool",
"system_description": "x",
"einsatzzweck": "y",
"domain": domain,
}
result = generate_fria_draft(ctx)
assert "Schutz personenbezogener Daten" in result["GRUNDRECHTE_ANALYSE"]
def test_hr_measures_included(self):
ctx = {
"organisation_name": "Test",
"system_name": "Tool",
"system_description": "x",
"einsatzzweck": "y",
"domain": "hr",
}
result = generate_fria_draft(ctx)
for measure in HR_MEASURES:
assert measure in result["MASSNAHMEN_LISTE"]
def test_education_measures_included(self):
ctx = {
"organisation_name": "Test",
"system_name": "Tool",
"system_description": "x",
"einsatzzweck": "y",
"domain": "education",
}
result = generate_fria_draft(ctx)
for measure in EDUCATION_MEASURES:
assert measure in result["MASSNAHMEN_LISTE"]
def test_public_entity_flag(self):
ctx = {
"organisation_name": "Behoerde",
"system_name": "Tool",
"system_description": "x",
"einsatzzweck": "y",
"is_public_entity": True,
}
result = generate_fria_draft(ctx)
assert result["OEFFENTLICHE_STELLE"] is True
def test_risk_matrix_generated(self):
ctx = {
"organisation_name": "Test",
"system_name": "Tool",
"system_description": "x",
"einsatzzweck": "y",
"domain": "hr",
"risk_score": 60,
}
result = generate_fria_draft(ctx)
assert result["RISIKOMATRIX"] != "{{RISIKOMATRIX}}"
assert "Nicht-Diskriminierung" in result["RISIKOMATRIX"]
def test_affected_groups(self):
ctx = {
"organisation_name": "Test",
"system_name": "Tool",
"system_description": "x",
"einsatzzweck": "y",
"affected_groups": ["Bewerber", "Beschaeftigte"],
"affected_count": "~500 pro Jahr",
}
result = generate_fria_draft(ctx)
assert "Bewerber" in result["BETROFFENE_GRUPPEN"]
assert result["BETROFFENE_ANZAHL"] == "~500 pro Jahr"
class TestDSFADomainRisks:
"""Tests for domain-specific risks in DSFA generator."""
def test_hr_domain_adds_agg_risks(self):
# Import from dsfa_template
from dsfa_template import _generate_risk_assessment
ctx = {"has_ai_systems": True, "domain": "hr"}
output = _generate_risk_assessment(ctx)
assert "AGG-Verstoss" in output
assert "Beweislastumkehr" in output
def test_education_domain_adds_risks(self):
from dsfa_template import _generate_risk_assessment
ctx = {"has_ai_systems": True, "domain": "education"}
output = _generate_risk_assessment(ctx)
assert "Chancenungleichheit" in output
def test_healthcare_domain_adds_risks(self):
from dsfa_template import _generate_risk_assessment
ctx = {"has_ai_systems": True, "domain": "healthcare"}
output = _generate_risk_assessment(ctx)
assert "Fehldiagnose" in output
def test_finance_domain_adds_risks(self):
from dsfa_template import _generate_risk_assessment
ctx = {"has_ai_systems": True, "domain": "finance"}
output = _generate_risk_assessment(ctx)
assert "Kredit-Scoring" in output
def test_no_domain_no_extra_risks(self):
from dsfa_template import _generate_risk_assessment
ctx = {"has_ai_systems": True}
output = _generate_risk_assessment(ctx)
assert "AGG-Verstoss" not in output
assert "Fehldiagnose" not in output
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,350 @@
-- Migration 006: Betriebsvereinbarung Template V1
-- Modulare Vorlage fuer Betriebsvereinbarungen zu KI/IT-Systemen
-- Rechtsgrundlage: §87 Abs.1 Nr.6 BetrVG, DSGVO, BDSG
INSERT INTO compliance.compliance_legal_templates (
tenant_id, document_type, title, description, language, jurisdiction,
version, status, license_name, source_name, attribution_required,
is_complete_document, placeholders, content
) VALUES (
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid,
'betriebsvereinbarung',
'Betriebsvereinbarung — Einfuehrung und Nutzung von KI-/IT-Systemen',
'Modulare Vorlage fuer eine Betriebsvereinbarung gemaess §87 Abs.1 Nr.6 BetrVG zur Einfuehrung und Nutzung von IT-Systemen und KI-Anwendungen. Umfasst Datenschutz, Ueberwachungsschutz, Change-Management und Kontrollrechte des Betriebsrats. Basiert auf BAG-Rechtsprechung zu Microsoft 365, SAP ERP und Standardsoftware.',
'de',
'DE',
'1.0',
'published',
'MIT',
'BreakPilot Compliance',
false,
true,
CAST('[
"{{UNTERNEHMEN_NAME}}",
"{{UNTERNEHMEN_SITZ}}",
"{{ARBEITGEBER_VERTRETER}}",
"{{BETRIEBSRAT_VORSITZ}}",
"{{SYSTEM_NAME}}",
"{{SYSTEM_BESCHREIBUNG}}",
"{{SYSTEM_HERSTELLER}}",
"{{GELTUNGSBEREICH_STANDORTE}}",
"{{GELTUNGSBEREICH_BEREICHE}}",
"{{GELTUNGSBEREICH_MODULE}}",
"{{ZWECK_BESCHREIBUNG}}",
"{{DATENARTEN_LISTE}}",
"{{VERBOTENE_NUTZUNGEN}}",
"{{ROLLEN_ADMIN}}",
"{{ROLLEN_FUEHRUNGSKRAFT}}",
"{{ROLLEN_REPORTING}}",
"{{TRANSPARENZ_INFO}}",
"{{ERLAUBTE_REPORTS}}",
"{{SPEICHERFRIST_AUDIT_LOGS}}",
"{{SPEICHERFRIST_NUTZUNGSDATEN}}",
"{{SPEICHERFRIST_CHAT_PROMPTS}}",
"{{TOM_MASSNAHMEN}}",
"{{CHANGE_MANAGEMENT_PROZESS}}",
"{{AUDIT_INTERVALL}}",
"{{BESCHWERDE_ANSPRECHPARTNER}}",
"{{LAUFZEIT}}",
"{{KUENDIGUNGSFRIST}}",
"{{DATUM_UNTERZEICHNUNG}}",
"{{DSB_NAME}}",
"{{DSB_KONTAKT}}"
]' AS jsonb),
$template$# Betriebsvereinbarung
**ueber die Einfuehrung und Nutzung von {{SYSTEM_NAME}}**
zwischen
**{{UNTERNEHMEN_NAME}}**, {{UNTERNEHMEN_SITZ}},
vertreten durch {{ARBEITGEBER_VERTRETER}}
(nachfolgend "Arbeitgeberin")
und dem
**Betriebsrat** der {{UNTERNEHMEN_NAME}},
vertreten durch den/die Vorsitzende/n {{BETRIEBSRAT_VORSITZ}}
(nachfolgend "Betriebsrat")
---
## A. Praeambel und Rechtsgrundlagen
Diese Betriebsvereinbarung regelt die Einfuehrung und Nutzung von **{{SYSTEM_NAME}}** ({{SYSTEM_BESCHREIBUNG}}) im Betrieb der {{UNTERNEHMEN_NAME}}.
**Rechtsgrundlagen:**
- §87 Abs.1 Nr.6 BetrVG (Mitbestimmung bei technischen Ueberwachungseinrichtungen)
- §90 BetrVG (Unterrichtung bei Planung technischer Anlagen)
- Art. 5, 6, 32 DSGVO (Datenschutzgrundsaetze, Rechtsgrundlage, TOM)
- §26 BDSG (Beschaeftigtendatenschutz)
{{#IF AI_SYSTEM}}
- Verordnung (EU) 2024/1689 (KI-Verordnung / AI Act)
{{/IF}}
Die Parteien sind sich einig, dass {{SYSTEM_NAME}} eine technische Einrichtung im Sinne des §87 Abs.1 Nr.6 BetrVG darstellt, die geeignet ist, das Verhalten oder die Leistung der Beschaeftigten zu ueberwachen. Die Einigung erfolgt in Kenntnis der Rechtsprechung des Bundesarbeitsgerichts (vgl. BAG 1 ABR 20/21 Microsoft Office 365; BAG 1 ABN 36/18 Standardsoftware).
---
## B. Geltungsbereich
### B.1 Raeumlicher Geltungsbereich
Diese Betriebsvereinbarung gilt fuer folgende Standorte:
{{GELTUNGSBEREICH_STANDORTE}}
### B.2 Persoenlicher Geltungsbereich
Die Betriebsvereinbarung gilt fuer alle Beschaeftigten der folgenden Bereiche:
{{GELTUNGSBEREICH_BEREICHE}}
### B.3 Sachlicher Geltungsbereich
Die Betriebsvereinbarung umfasst folgende Module und Dienste des Systems:
{{GELTUNGSBEREICH_MODULE}}
{{#IF SYSTEM_HERSTELLER}}
**Systemhersteller/-anbieter:** {{SYSTEM_HERSTELLER}}
{{/IF}}
---
## C. Zweckbestimmung
### C.1 Erlaubte Nutzungszwecke
{{SYSTEM_NAME}} darf ausschliesslich zu folgenden Zwecken eingesetzt werden:
{{ZWECK_BESCHREIBUNG}}
### C.2 Verbotene Nutzungen
Folgende Nutzungen sind ausdruecklich untersagt:
{{VERBOTENE_NUTZUNGEN}}
Darueber hinaus ist generell untersagt:
- Verdeckte Leistungs- oder Verhaltenskontrolle einzelner Beschaeftigter
- Erstellung individueller Persoenlichkeitsprofile
- Nutzung von Prompt-, Chat- oder Nutzungshistorien zu disziplinarischen Zwecken
- Automatisierte Personalentscheidungen ohne menschliche Ueberpruefung
- Personenbezogene Rankings oder Leistungsvergleiche ohne gesonderte Mitbestimmung
{{#IF AI_SYSTEM}}
- Einsatz von KI-Funktionen zur biometrischen Echtzeit-Identifizierung
- KI-gestuetztes Social Scoring von Beschaeftigten
{{/IF}}
---
## D. Datenarten und Verarbeitungszwecke
### D.1 Verarbeitete Datenarten
Im Rahmen der Nutzung von {{SYSTEM_NAME}} werden folgende Datenarten verarbeitet:
{{DATENARTEN_LISTE}}
### D.2 Rechtsgrundlage
Die Verarbeitung der Beschaeftigtendaten erfolgt auf Grundlage von:
- §26 Abs.1 BDSG i.V.m. Art. 6 Abs.1 lit. b DSGVO (Durchfuehrung des Arbeitsverhaeltnisses)
- §26 Abs.4 BDSG i.V.m. Art. 88 DSGVO (diese Betriebsvereinbarung als Kollektivvereinbarung)
### D.3 Keine Verarbeitung besonderer Kategorien
Daten gemaess Art. 9 DSGVO (Gesundheitsdaten, Gewerkschaftszugehoerigkeit, biometrische Daten etc.) werden nicht verarbeitet, es sei denn, dies ist in einem gesonderten Anhang zu dieser Betriebsvereinbarung ausdruecklich geregelt.
---
## E. Rollen- und Zugriffskonzept
### E.1 Administratoren
{{ROLLEN_ADMIN}}
### E.2 Fuehrungskraefte
{{ROLLEN_FUEHRUNGSKRAFT}}
Fuehrungskraefte erhalten **keinen** Zugriff auf:
- individuelle Nutzungsprotokolle
- Prompt-/Chat-Historien einzelner Beschaeftigter
- Produktivitaetskennzahlen auf Personenebene
### E.3 Reporting-Zugriff
{{ROLLEN_REPORTING}}
### E.4 Vier-Augen-Prinzip
Sonderauswertungen mit Personenbezug beduerfen:
- der Zustimmung des Betriebsrats
- der Beteiligung des Datenschutzbeauftragten ({{DSB_NAME}}, {{DSB_KONTAKT}})
- einer dokumentierten Begruendung
---
## F. Transparenz gegenueber Beschaeftigten
Die Arbeitgeberin informiert alle Beschaeftigten vor Einfuehrung von {{SYSTEM_NAME}} ueber:
{{TRANSPARENZ_INFO}}
Insbesondere:
- Welche Daten verarbeitet werden
- Welche KI-Funktionen aktiviert sind
- Welche Protokollierung stattfindet
- Wer Zugriff auf welche Daten hat
- Wie lange Daten gespeichert werden
- An wen sich Beschaeftigte bei Fragen oder Beschwerden wenden koennen
{{#IF AI_SYSTEM}}
Bei KI-gestuetzten Funktionen wird zusaetzlich transparent gemacht:
- Ob und wie KI-generierte Inhalte gekennzeichnet werden
- Ob Eingaben fuer Modelltraining verwendet werden (Standard: Nein)
- Welche Entscheidungsunterstuetzung die KI leistet
{{/IF}}
---
## G. Auswertungen und Reports
### G.1 Erlaubte Reports
Folgende Auswertungen sind ohne gesonderte Zustimmung zulaessig:
{{ERLAUBTE_REPORTS}}
### G.2 Unzulaessige Reports
Ohne ausdrueckliche, vorherige Zustimmung des Betriebsrats sind unzulaessig:
- individuelle Produktivitaetsreports
- Teamvergleiche mit Personenbezug
- Verhaltensprofile oder Nutzungsmuster einzelner Beschaeftigter
- Rankinglisten (auch anonymisierte, wenn Re-Identifikation moeglich)
- Korrelation von Nutzungsdaten mit Leistungsbeurteilungen
### G.3 Neue Reporttypen
Die Einfuehrung neuer Reporttypen bedarf der vorherigen Zustimmung des Betriebsrats.
---
## H. Speicher- und Loeschfristen
| Datenkategorie | Speicherfrist | Loeschverfahren |
|----------------|---------------|-----------------|
| Audit-/Admin-Logs | {{SPEICHERFRIST_AUDIT_LOGS}} | Automatische Loeschung |
| Nutzungsdaten (aggregiert) | {{SPEICHERFRIST_NUTZUNGSDATEN}} | Automatische Loeschung |
| Prompt-/Chat-Historien | {{SPEICHERFRIST_CHAT_PROMPTS}} | Automatische Loeschung oder deaktiviert |
| Exportdateien | 30 Tage | Automatische Loeschung |
Die Speicherdauer der Audit-Logs orientiert sich am berechtigten Interesse der Arbeitgeberin an der Systemsicherheit und wird auf das erforderliche Minimum begrenzt.
{{#IF AI_SYSTEM}}
**KI-spezifisch:**
- Trainingsdaten aus Beschaeftigten-Interaktionen: **nicht zulaessig** ohne gesonderte Vereinbarung
- Feedback-Daten zur Modellverbesserung: nur anonymisiert und aggregiert
{{/IF}}
---
## I. Technische und organisatorische Massnahmen (TOM)
Zum Schutz der Beschaeftigtendaten werden folgende Massnahmen umgesetzt:
{{TOM_MASSNAHMEN}}
Ergaenzend gelten mindestens:
- Rollen- und Rechtekonzept mit Least-Privilege-Prinzip
- Verschluesselung der Daten bei Uebertragung und Speicherung
- Protokollierung aller administrativen Zugriffe
- Pseudonymisierung, wo technisch moeglich
- Deaktivierung nicht benoetigter Telemetrie- und Diagnosefunktionen
- Getrennte Umgebungen fuer Test und Produktion
---
## J. Change-Management
### J.1 Aenderungspflicht
Folgende Aenderungen an {{SYSTEM_NAME}} beduerfen der vorherigen Information und ggf. erneuten Mitbestimmung des Betriebsrats:
{{CHANGE_MANAGEMENT_PROZESS}}
Insbesondere:
- Aktivierung neuer Module oder Funktionen
- Anbindung neuer Datenquellen oder Konnektoren
- Aenderung der Reporting-Funktionalitaet
- Updates mit neuen KI-Modellen oder -Funktionen
- Aenderung der Datenverarbeitungsstandorte
- Erweiterung des Nutzerkreises
### J.2 Informationsfrist
Die Arbeitgeberin informiert den Betriebsrat mindestens **14 Kalendertage** vor geplanten Aenderungen schriftlich. Bei sicherheitskritischen Updates kann die Frist auf 3 Werktage verkuerzt werden.
### J.3 Bewertungsverfahren
Jede Aenderung wird anhand folgender Kriterien bewertet:
- Aendert sich die Ueberwachungseignung?
- Werden neue Datenarten verarbeitet?
- Aendert sich der Personenbezug?
Bei positiver Beantwortung einer dieser Fragen ist eine erneute Mitbestimmung erforderlich.
---
## K. Kontroll- und Audit-Rechte des Betriebsrats
### K.1 Laufende Kontrolle
Der Betriebsrat hat das Recht auf:
- Einsicht in die Systemdokumentation
- Einsicht in den Katalog aktiver Reports und Auswertungen
- Information ueber alle Administrationszugriffe
- Teilnahme an Schulungen zum System
### K.2 Regelmaessige Reviews
Arbeitgeberin und Betriebsrat fuehren alle **{{AUDIT_INTERVALL}}** einen gemeinsamen Review durch. Gegenstand:
- Aktuelle Nutzung und Funktionsumfang
- Eingehaltene/verletzte Regelungen
- Eingegangene Beschwerden
- Geplante Aenderungen
- Aktualitaet der TOM
### K.3 Anlassbezogene Pruefung
Bei begruendetem Verdacht auf Verstoss gegen diese Betriebsvereinbarung kann der Betriebsrat jederzeit eine Sonderpruefung verlangen. Die Arbeitgeberin stellt innerhalb von 5 Werktagen die angeforderten Informationen bereit.
---
## L. Beschwerden und Eskalation
### L.1 Beschwerderecht
Beschaeftigte koennen sich bei Bedenken hinsichtlich der Datenverarbeitung wenden an:
{{BESCHWERDE_ANSPRECHPARTNER}}
### L.2 Eskalation
Bei Meinungsverschiedenheiten ueber die Auslegung oder Anwendung dieser Betriebsvereinbarung gilt:
1. Gespraech zwischen Arbeitgeberin und Betriebsrat (Frist: 2 Wochen)
2. Hinzuziehung des Datenschutzbeauftragten
3. Einigungsstelle gemaess §76 BetrVG
### L.3 Sofortmassnahmen
Bei schwerwiegenden Verstoessen (insbesondere unzulaessige Ueberwachung, Datenmissbrauch) kann der Betriebsrat die sofortige Aussetzung der betroffenen Funktion verlangen. Die Arbeitgeberin setzt die Funktion bis zur Klaerung aus.
---
## M. Schlussbestimmungen
### M.1 Inkrafttreten und Laufzeit
Diese Betriebsvereinbarung tritt am {{DATUM_UNTERZEICHNUNG}} in Kraft und gilt fuer die Dauer von {{LAUFZEIT}}.
### M.2 Kuendigung
Die Betriebsvereinbarung kann von jeder Seite mit einer Frist von {{KUENDIGUNGSFRIST}} zum Monatsende schriftlich gekuendigt werden.
### M.3 Nachwirkung
Die Betriebsvereinbarung wirkt nach Kuendigung bis zum Abschluss einer neuen Vereinbarung nach (§77 Abs.6 BetrVG).
### M.4 Salvatorische Klausel
Sollten einzelne Bestimmungen unwirksam sein, bleibt die Wirksamkeit der uebrigen Bestimmungen unberuehrt. Die Parteien verpflichten sich, unwirksame Bestimmungen durch wirksame zu ersetzen, die dem wirtschaftlichen Zweck am naechsten kommen.
### M.5 Anlagen
Folgende Anlagen sind Bestandteil dieser Betriebsvereinbarung:
- Anlage 1: Detaillierte Systemdokumentation
- Anlage 2: Rollen- und Rechtekonzept
- Anlage 3: TOM-Dokumentation
- Anlage 4: Reportkatalog
{{#IF AI_SYSTEM}}
- Anlage 5: KI-Transparenzbericht
{{/IF}}
---
**{{UNTERNEHMEN_SITZ}}, den {{DATUM_UNTERZEICHNUNG}}**
| | |
|---|---|
| _________________________ | _________________________ |
| {{ARBEITGEBER_VERTRETER}} | {{BETRIEBSRAT_VORSITZ}} |
| fuer die Arbeitgeberin | fuer den Betriebsrat |
$template$
) ON CONFLICT DO NOTHING;
@@ -0,0 +1,330 @@
-- Migration 007: FRIA Template V1 — Grundrechte-Folgenabschaetzung (Art. 27 KI-VO)
-- Fundamental Rights Impact Assessment fuer Hochrisiko-KI-Systeme
-- Rechtsgrundlage: Art. 27 Verordnung (EU) 2024/1689 (KI-Verordnung / AI Act)
INSERT INTO compliance.compliance_legal_templates (
tenant_id, document_type, title, description, language, jurisdiction,
version, status, license_name, source_name, attribution_required,
is_complete_document, placeholders, content
) VALUES (
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid,
'fria',
'Grundrechte-Folgenabschaetzung (FRIA) gemaess Art. 27 KI-Verordnung',
'Vorlage fuer eine Grundrechte-Folgenabschaetzung (Fundamental Rights Impact Assessment) gemaess Art. 27 der Verordnung (EU) 2024/1689 (KI-Verordnung). Erforderlich fuer Hochrisiko-KI-Systeme, insbesondere bei oeffentlichen Stellen und in den Bereichen Beschaeftigung, Bildung und Zugang zu wesentlichen Dienstleistungen.',
'de',
'EU/KI-VO',
'1.0',
'published',
'MIT',
'BreakPilot Compliance',
false,
true,
CAST('[
"{{ORGANISATION_NAME}}",
"{{ORGANISATION_ADRESSE}}",
"{{VERANTWORTLICHER}}",
"{{ERSTELLT_VON}}",
"{{ERSTELLT_AM}}",
"{{SYSTEM_NAME}}",
"{{SYSTEM_VERSION}}",
"{{SYSTEM_BESCHREIBUNG}}",
"{{SYSTEM_ANBIETER}}",
"{{EINSATZZWECK}}",
"{{EINSATZKONTEXT}}",
"{{BETROFFENE_GRUPPEN}}",
"{{BETROFFENE_ANZAHL}}",
"{{GRUNDRECHTE_ANALYSE}}",
"{{RISIKOMATRIX}}",
"{{MASSNAHMEN_LISTE}}",
"{{HUMAN_OVERSIGHT_BESCHREIBUNG}}",
"{{TRANSPARENZ_MASSNAHMEN}}",
"{{KONSULTATION_ERGEBNISSE}}",
"{{GENEHMIGT_VON}}",
"{{GENEHMIGT_AM}}",
"{{NAECHSTE_UEBERPRUEFUNG}}",
"{{DSB_NAME}}",
"{{DSB_KONTAKT}}",
"{{AI_ACT_KLASSIFIKATION}}",
"{{ANNEX_III_KATEGORIE}}"
]' AS jsonb),
$template$# Grundrechte-Folgenabschaetzung (FRIA)
**gemaess Art. 27 der Verordnung (EU) 2024/1689 (KI-Verordnung)**
---
| Feld | Wert |
|------|------|
| Organisation | {{ORGANISATION_NAME}} |
| Adresse | {{ORGANISATION_ADRESSE}} |
| KI-System | {{SYSTEM_NAME}} (Version {{SYSTEM_VERSION}}) |
| Erstellt von | {{ERSTELLT_VON}} |
| Erstellt am | {{ERSTELLT_AM}} |
| Status | Entwurf |
---
## 1. Systembeschreibung und Einsatzkontext
### 1.1 KI-System
**Systemname:** {{SYSTEM_NAME}}
**Version:** {{SYSTEM_VERSION}}
**Anbieter:** {{SYSTEM_ANBIETER}}
**Beschreibung:** {{SYSTEM_BESCHREIBUNG}}
### 1.2 AI Act Klassifikation
**Risikoklasse:** {{AI_ACT_KLASSIFIKATION}}
{{#IF ANNEX_III_KATEGORIE}}
**Annex III Kategorie:** {{ANNEX_III_KATEGORIE}}
{{/IF}}
### 1.3 Einsatzzweck
{{EINSATZZWECK}}
### 1.4 Einsatzkontext
{{EINSATZKONTEXT}}
Folgende Fragen sind zu beantworten:
- In welchem organisatorischen Kontext wird das System eingesetzt?
- Welche Entscheidungen werden durch das System unterstuetzt oder automatisiert?
- Wie haeufig wird das System eingesetzt?
- Welche Rolle spielt das System im Gesamtprozess?
### 1.5 Betroffene Personengruppen
{{BETROFFENE_GRUPPEN}}
**Geschaetzte Anzahl betroffener Personen:** {{BETROFFENE_ANZAHL}}
{{#IF BILDUNGSKONTEXT}}
**Besonderer Schutz:** Schueler, Studierende und Auszubildende geniessen als besonders schutzbeduerftiger Personenkreis erhoehten Schutz.
{{/IF}}
{{#IF HR_KONTEXT}}
**Besonderer Schutz:** Beschaeftigte und Bewerber befinden sich in einem Abhaengigkeitsverhaeltnis und beduerfen besonderen Schutzes vor diskriminierenden KI-Entscheidungen.
{{/IF}}
---
## 2. Grundrechte-Mapping
### 2.1 Betroffene Grundrechte
Die folgenden Grundrechte der EU-Grundrechtecharta und des Grundgesetzes wurden auf Betroffenheit geprueft:
{{GRUNDRECHTE_ANALYSE}}
### 2.2 Referenz-Grundrechte
| Nr. | Grundrecht | EU-Charta | GG | Betroffen | Begruendung |
|-----|-----------|-----------|-----|-----------|-------------|
| 1 | Menschenwuerde | Art. 1 | Art. 1 | | |
| 2 | Recht auf Privatsphaere | Art. 7 | Art. 2 Abs. 1 | | |
| 3 | Schutz personenbezogener Daten | Art. 8 | Art. 2 Abs. 1 i.V.m. Art. 1 Abs. 1 | | |
| 4 | Nicht-Diskriminierung | Art. 21 | Art. 3 | | |
| 5 | Gleichheit von Frauen und Maennern | Art. 23 | Art. 3 Abs. 2 | | |
| 6 | Rechte des Kindes | Art. 24 | Art. 6 Abs. 2 | | |
| 7 | Recht auf Bildung | Art. 14 | Art. 12 | | |
| 8 | Berufsfreiheit / Recht zu arbeiten | Art. 15 | Art. 12 | | |
| 9 | Recht auf wirksamen Rechtsbehelf | Art. 47 | Art. 19 Abs. 4 | | |
| 10 | Meinungs- und Informationsfreiheit | Art. 11 | Art. 5 | | |
| 11 | Versammlungs- und Vereinigungsfreiheit | Art. 12 | Art. 8, 9 | | |
| 12 | Recht auf soziale Sicherheit | Art. 34 | Art. 20 | | |
{{#IF OEFFENTLICHE_STELLE}}
### 2.3 Besondere Pflichten oeffentlicher Stellen
Als oeffentliche Stelle gelten zusaetzliche Anforderungen:
- Erweiterte Transparenzpflicht gegenueber Buergern
- Pflicht zur Barrierefreiheit des Systems
- Beruecksichtigung des Gleichheitsgrundsatzes (Art. 3 GG)
- Demokratische Kontrolle und Rechenschaftspflicht
{{/IF}}
---
## 3. Risikoanalyse
### 3.1 Risikobewertung pro Grundrecht
Fuer jedes betroffene Grundrecht wird das Risiko bewertet:
**Eintrittswahrscheinlichkeit:**
- 1 = Sehr unwahrscheinlich
- 2 = Unwahrscheinlich
- 3 = Moeglich
- 4 = Wahrscheinlich
- 5 = Sehr wahrscheinlich
**Schadensausmass:**
- 1 = Geringfuegig
- 2 = Begrenzt
- 3 = Erheblich
- 4 = Schwerwiegend
- 5 = Katastrophal
### 3.2 Risikomatrix
{{RISIKOMATRIX}}
| Grundrecht | Risikoszenario | Wahrscheinlichkeit | Schwere | Risiko-Level | Begruendung |
|-----------|----------------|--------------------:|--------:|:------------:|-------------|
| | | | | | |
**Risiko-Level Berechnung:** Wahrscheinlichkeit × Schwere
| Risiko-Level | Punktzahl | Bedeutung |
|:------------:|:---------:|-----------|
| Niedrig | 1-6 | Akzeptables Risiko, Standardmassnahmen |
| Mittel | 7-12 | Erhoehte Aufmerksamkeit, zusaetzliche Massnahmen |
| Hoch | 13-19 | Erhebliches Risiko, umfassende Massnahmen erforderlich |
| Kritisch | 20-25 | Nicht akzeptabel ohne fundamentale Aenderungen |
---
## 4. Massnahmen zur Risikominderung
### 4.1 Uebersicht der Massnahmen
{{MASSNAHMEN_LISTE}}
### 4.2 Human Oversight (Art. 14 KI-VO)
{{HUMAN_OVERSIGHT_BESCHREIBUNG}}
Folgende Massnahmen zur menschlichen Aufsicht werden umgesetzt:
- [ ] Mensch kann KI-Entscheidung jederzeit uebersteuern
- [ ] Mensch versteht KI-Output vollstaendig
- [ ] Keine automatisierten Entscheidungen ohne menschliche Ueberpruefung
- [ ] Schulung der Nutzer zu Systemgrenzen und Risiken
- [ ] Eingriffsprotokolle werden gefuehrt
### 4.3 Transparenz (Art. 13 KI-VO)
{{TRANSPARENZ_MASSNAHMEN}}
Folgende Transparenzmassnahmen werden umgesetzt:
- [ ] Betroffene werden ueber KI-Nutzung informiert
- [ ] KI-generierte Outputs sind als solche gekennzeichnet
- [ ] Erklaerbarkeit der Entscheidungslogik sichergestellt
- [ ] Kontaktmoeglichkeit fuer Betroffene vorhanden
- [ ] Informationen sind verstaendlich und zugaenglich
### 4.4 Logging und Audit (Art. 12 KI-VO)
- [ ] Alle Eingaben und Ausgaben werden protokolliert
- [ ] Logs sind manipulationssicher
- [ ] Aufbewahrungsfristen definiert
- [ ] Audit-Trail fuer Entscheidungsnachvollziehbarkeit
### 4.5 Bias-Pruefung und Nicht-Diskriminierung
- [ ] Trainingsdaten auf Bias geprueft
- [ ] Regelmaessige Bias-Audits geplant
- [ ] Beschwerdemechanismus fuer Diskriminierungsfaelle
{{#IF HR_KONTEXT}}
- [ ] AGG-konforme Gestaltung (kein Bias bei Geschlecht, Alter, Herkunft, Behinderung)
- [ ] Betriebsrat gemaess §95 BetrVG beteiligt (bei Auswahlrichtlinien)
{{/IF}}
{{#IF BILDUNGSKONTEXT}}
- [ ] Chancengleichheit unabhaengig von sozioekonomischem Hintergrund
- [ ] Keine Benachteiligung aufgrund von Sprachkenntnissen oder Behinderung
{{/IF}}
---
## 5. Konsultation
### 5.1 Einbeziehung Betroffener
{{KONSULTATION_ERGEBNISSE}}
Folgende Stakeholder wurden konsultiert:
- [ ] Datenschutzbeauftragter ({{DSB_NAME}}, {{DSB_KONTAKT}})
- [ ] Betroffene Personengruppen oder deren Vertreter
{{#IF HR_KONTEXT}}
- [ ] Betriebsrat / Personalrat
{{/IF}}
{{#IF OEFFENTLICHE_STELLE}}
- [ ] Buergervertreter / Ombudsstelle
- [ ] Zustaendige Aufsichtsbehoerde
{{/IF}}
- [ ] Fachexperten fuer betroffene Grundrechte
### 5.2 Ergebnisse der Konsultation
| Stakeholder | Datum | Ergebnis | Massnahme |
|------------|-------|----------|-----------|
| | | | |
---
## 6. Gesamtbewertung und Freigabe
### 6.1 Gesamtrisiko-Bewertung
| Kriterium | Bewertung |
|-----------|-----------|
| Hoechstes Einzelrisiko | |
| Anzahl betroffene Grundrechte | |
| Anzahl betroffene Personen | {{BETROFFENE_ANZAHL}} |
| Massnahmen ausreichend | Ja / Nein / Teilweise |
| Restrisiko akzeptabel | Ja / Nein |
### 6.2 Entscheidung
- [ ] **Freigabe** Restrisiko akzeptabel, Massnahmen ausreichend
- [ ] **Freigabe mit Auflagen** Zusaetzliche Massnahmen erforderlich (siehe unten)
- [ ] **Ablehnung** Grundrechtsrisiken nicht akzeptabel mitigierbar
### 6.3 Auflagen (falls zutreffend)
| Nr. | Auflage | Frist | Verantwortlich |
|-----|---------|-------|----------------|
| | | | |
---
## 7. Laufende Ueberwachung
### 7.1 Naechste Ueberpruefung
**Geplante Ueberpruefung:** {{NAECHSTE_UEBERPRUEFUNG}}
### 7.2 Trigger fuer ausserplanmaessige Ueberpruefung
Eine erneute FRIA ist durchzufuehren bei:
- Wesentlicher Aenderung des KI-Systems oder seines Einsatzzwecks
- Erweiterung auf neue Personengruppen oder Anwendungsbereiche
- Beschwerden oder Vorfaellen mit Grundrechtsbezug
- Aenderung der Rechtsgrundlage oder Risikoklassifikation
- Neuen wissenschaftlichen Erkenntnissen zu Risiken
- Aenderung des KI-Modells oder der Trainingsdaten
### 7.3 Dokumentation und Archivierung
Diese FRIA wird mindestens fuer die Dauer des Einsatzes des KI-Systems und darueberhinaus fuer 10 Jahre archiviert (Art. 18 KI-VO).
---
## 8. Unterschriften
| | |
|---|---|
| _________________________ | _________________________ |
| {{ERSTELLT_VON}} | {{GENEHMIGT_VON}} |
| Erstellt am {{ERSTELLT_AM}} | Genehmigt am {{GENEHMIGT_AM}} |
---
**Anhang A:** Vollstaendige Systemdokumentation (Art. 11 KI-VO)
**Anhang B:** AI Act Decision Tree Ergebnis
**Anhang C:** Verknuepfte DSFA (falls vorhanden)
**Anhang D:** Konsultationsprotokolle
$template$
) ON CONFLICT DO NOTHING;
+30
View File
@@ -760,3 +760,33 @@ server {
try_files $uri $uri/ /index.html;
}
}
# =========================================================
# PITCH DECK: Investor Presentation on port 3012
# =========================================================
server {
listen 3012 ssl;
http2 on;
server_name macmini localhost;
ssl_certificate /etc/nginx/certs/macmini.crt;
ssl_certificate_key /etc/nginx/certs/macmini.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
location / {
set $upstream_pitch bp-core-pitch-deck:3000;
proxy_pass http://$upstream_pitch;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
}
}
+7
View File
@@ -0,0 +1,7 @@
Tue Apr 14 09:22:10 AM CEST 2026
Tue Apr 14 09:27:05 AM CEST 2026
Tue Apr 14 09:32:36 AM CEST 2026
Tue Apr 15 rebuild trigger
Tue Apr 15 rebuild 2
@@ -0,0 +1,294 @@
/**
* Regression test for the "lost access" scenario:
*
* 1. Admin invites investor A token T1 is created and emailed.
* 2. Investor A opens the link successfully T1 is marked used_at.
* 3. Investor A clears their session (or a redeploy drops cookies).
* 4. Investor A returns to / redirected to /auth.
* 5. Without this feature, A is stuck: T1 is already used, expired, or the
* session is gone, and there is no self-service way to get back in.
* 6. With this feature, A enters their email on /auth and the endpoint
* issues a brand new, unused magic link T2 for the same investor row.
*
* This test wires together the request-link handler with the real verify
* handler against an in-memory fake of the two tables the flow touches
* (pitch_investors, pitch_magic_links) so we can assert end-to-end that a
* second link works after the first one was used.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'
// ---- In-memory fake of the two tables touched by this flow ----
interface InvestorRow {
id: string
email: string
name: string | null
company: string | null
status: 'invited' | 'active' | 'revoked'
last_login_at: Date | null
login_count: number
}
interface MagicLinkRow {
id: string
investor_id: string
token: string
expires_at: Date
used_at: Date | null
ip_address: string | null
user_agent: string | null
}
const db = {
investors: [] as InvestorRow[],
magicLinks: [] as MagicLinkRow[],
sessions: [] as { id: string; investor_id: string; ip_address: string | null }[],
}
let idCounter = 0
const nextId = () => `row-${++idCounter}`
// A tiny query router: match the SQL fragment we care about, ignore the rest.
const queryMock = vi.fn(async (sql: string, params: unknown[] = []) => {
const s = sql.replace(/\s+/g, ' ').trim()
// Investor lookup by email (used by request-link)
if (/SELECT id, email, name, status FROM pitch_investors WHERE email = \$1/i.test(s)) {
const row = db.investors.find(i => i.email === params[0])
return { rows: row ? [row] : [] }
}
// Insert magic link
if (/INSERT INTO pitch_magic_links \(investor_id, token, expires_at\)/i.test(s)) {
db.magicLinks.push({
id: nextId(),
investor_id: params[0] as string,
token: params[1] as string,
expires_at: params[2] as Date,
used_at: null,
ip_address: null,
user_agent: null,
})
return { rows: [] }
}
// Verify: magic link + investor JOIN lookup
if (/FROM pitch_magic_links ml JOIN pitch_investors i/i.test(s)) {
const link = db.magicLinks.find(ml => ml.token === params[0])
if (!link) return { rows: [] }
const inv = db.investors.find(i => i.id === link.investor_id)!
return {
rows: [{
id: link.id,
investor_id: link.investor_id,
expires_at: link.expires_at,
used_at: link.used_at,
email: inv.email,
investor_status: inv.status,
}],
}
}
// Mark magic link used
if (/UPDATE pitch_magic_links SET used_at = NOW/i.test(s)) {
const link = db.magicLinks.find(ml => ml.id === params[2])
if (link) {
link.used_at = new Date()
link.ip_address = params[0] as string | null
link.user_agent = params[1] as string | null
}
return { rows: [] }
}
// Activate investor
if (/UPDATE pitch_investors SET status = 'active'/i.test(s)) {
const inv = db.investors.find(i => i.id === params[0])
if (inv) {
inv.status = 'active'
inv.last_login_at = new Date()
inv.login_count += 1
}
return { rows: [] }
}
// createSession: revoke prior sessions (no-op in fake)
if (/UPDATE pitch_sessions SET revoked = true WHERE investor_id/i.test(s)) {
return { rows: [] }
}
// createSession: insert
if (/INSERT INTO pitch_sessions/i.test(s)) {
const id = nextId()
db.sessions.push({ id, investor_id: params[0] as string, ip_address: params[2] as string | null })
return { rows: [{ id }] }
}
// createSession: fetch investor email for JWT
if (/SELECT email FROM pitch_investors WHERE id = \$1/i.test(s)) {
const inv = db.investors.find(i => i.id === params[0])
return { rows: inv ? [{ email: inv.email }] : [] }
}
// new-ip detection query (verify route)
if (/SELECT DISTINCT ip_address FROM pitch_sessions/i.test(s)) {
return { rows: [] }
}
// Audit log insert — accept everything
if (/INSERT INTO pitch_audit_logs/i.test(s)) {
return { rows: [] }
}
throw new Error(`Unmocked query: ${s.slice(0, 120)}`)
})
vi.mock('@/lib/db', () => ({
default: { query: (...args: unknown[]) => queryMock(args[0] as string, args[1] as unknown[]) },
}))
// Capture emails instead of sending them
const sentEmails: Array<{ to: string; url: string }> = []
vi.mock('@/lib/email', () => ({
sendMagicLinkEmail: vi.fn(async (to: string, _name: string | null, url: string) => {
sentEmails.push({ to, url })
}),
}))
// next/headers cookies() needs to be stubbed — setSessionCookie calls it.
vi.mock('next/headers', () => ({
cookies: async () => ({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
// Import the handlers AFTER mocks are set up
import { POST as requestLink } from '@/app/api/auth/request-link/route'
import { POST as verifyLink } from '@/app/api/auth/verify/route'
function makeJsonRequest(url: string, body: unknown, ip = '203.0.113.1'): NextRequest {
return new NextRequest(url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify(body),
})
}
function extractToken(url: string): string {
const m = url.match(/token=([0-9a-f]+)/)
if (!m) throw new Error(`No token in url: ${url}`)
return m[1]
}
beforeEach(() => {
db.investors = []
db.magicLinks = []
db.sessions = []
sentEmails.length = 0
idCounter = 0
queryMock.mockClear()
})
describe('Regression: investor can re-request a working magic link after the first is consumed', () => {
it('full flow — invite → use → request-link → new link works', async () => {
// --- Setup: admin has already invited the investor (simulate the outcome) ---
const investorId = 'investor-42'
db.investors.push({
id: investorId,
email: 'vc@example.com',
name: 'VC Partner',
company: 'Acme Capital',
status: 'invited',
last_login_at: null,
login_count: 0,
})
db.magicLinks.push({
id: 'ml-original',
investor_id: investorId,
token: 'a'.repeat(96), // original invite token
expires_at: new Date(Date.now() + 72 * 60 * 60 * 1000),
used_at: null,
ip_address: null,
user_agent: null,
})
// --- Step 1: investor uses the original invite link ---
const firstVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
expect(firstVerify.status).toBe(200)
const first = db.magicLinks.find(ml => ml.id === 'ml-original')!
expect(first.used_at).not.toBeNull()
// --- Step 2: investor comes back later; clicks the same link → rejected ---
const replay = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
expect(replay.status).toBe(401)
const replayBody = await replay.json()
expect(replayBody.error).toMatch(/already been used/i)
// --- Step 3: investor visits /auth and submits their email ---
const reissue = await requestLink(
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'vc@example.com' }, '203.0.113.99'),
)
expect(reissue.status).toBe(200)
const reissueBody = await reissue.json()
expect(reissueBody.success).toBe(true)
// --- Step 4: a fresh email was dispatched to the investor ---
expect(sentEmails).toHaveLength(1)
expect(sentEmails[0].to).toBe('vc@example.com')
const newToken = extractToken(sentEmails[0].url)
expect(newToken).not.toBe('a'.repeat(96))
expect(newToken).toMatch(/^[0-9a-f]{96}$/)
// A second unused magic link row exists for the same investor
const links = db.magicLinks.filter(ml => ml.investor_id === investorId)
expect(links).toHaveLength(2)
const newLink = links.find(ml => ml.token === newToken)!
expect(newLink.used_at).toBeNull()
// --- Step 5: the new token validates successfully ---
const secondVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: newToken }))
expect(secondVerify.status).toBe(200)
const secondBody = await secondVerify.json()
expect(secondBody.success).toBe(true)
expect(secondBody.redirect).toBe('/')
// And the new link is now used, mirroring the one-time-use contract
expect(newLink.used_at).not.toBeNull()
})
it('unknown emails do not create magic links or send email (prevents enumeration & abuse)', async () => {
// No investors in the DB
const res = await requestLink(
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'stranger@example.com' }),
)
expect(res.status).toBe(200)
const body = await res.json()
// Same generic message as the happy path
expect(body.success).toBe(true)
expect(body.message).toMatch(/if this email was invited/i)
expect(sentEmails).toHaveLength(0)
expect(db.magicLinks).toHaveLength(0)
})
it('revoked investors cannot self-serve a new link', async () => {
db.investors.push({
id: 'revoked-1',
email: 'gone@example.com',
name: null,
company: null,
status: 'revoked',
last_login_at: null,
login_count: 0,
})
const res = await requestLink(
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'gone@example.com' }),
)
expect(res.status).toBe(200) // generic success (no info leak)
expect(sentEmails).toHaveLength(0)
expect(db.magicLinks).toHaveLength(0)
})
})
@@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'
// Mock the DB pool before the route is imported
const queryMock = vi.fn()
vi.mock('@/lib/db', () => ({
default: { query: (...args: unknown[]) => queryMock(...args) },
}))
// Mock the email sender so no SMTP is attempted
const sendMagicLinkEmailMock = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/email', () => ({
sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args),
}))
// Import after mocks are registered
import { POST } from '@/app/api/auth/request-link/route'
// Unique suffix per test so the rate-limit store (keyed by IP / email) doesn't
// bleed across cases — the rate-limiter holds state at module scope.
let testId = 0
function uniqueIp() {
testId++
return `10.0.${Math.floor(testId / 250)}.${testId % 250}`
}
function makeRequest(body: unknown, ip = uniqueIp()): NextRequest {
return new NextRequest('http://localhost/api/auth/request-link', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-forwarded-for': ip,
},
body: JSON.stringify(body),
})
}
function investorRow(overrides: Partial<{ id: string; email: string; name: string | null; status: string }> = {}) {
return {
id: overrides.id ?? 'investor-1',
email: overrides.email ?? 'invited@example.com',
name: overrides.name ?? 'Alice',
status: overrides.status ?? 'invited',
}
}
beforeEach(() => {
queryMock.mockReset()
sendMagicLinkEmailMock.mockReset()
sendMagicLinkEmailMock.mockResolvedValue(undefined)
})
describe('POST /api/auth/request-link — input validation', () => {
it('returns 400 when email is missing', async () => {
const res = await POST(makeRequest({}))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBe('Email required')
expect(queryMock).not.toHaveBeenCalled()
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
})
it('returns 400 when email is not a string', async () => {
const res = await POST(makeRequest({ email: 12345 }))
expect(res.status).toBe(400)
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
})
it('handles malformed JSON body as missing email (400)', async () => {
const req = new NextRequest('http://localhost/api/auth/request-link', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': uniqueIp() },
body: 'not-json',
})
const res = await POST(req)
expect(res.status).toBe(400)
})
})
describe('POST /api/auth/request-link — unknown email (enumeration resistance)', () => {
it('returns the generic success response without sending email', async () => {
// First query: investor lookup → empty rows
queryMock.mockResolvedValueOnce({ rows: [] })
// Second query: the audit log insert
queryMock.mockResolvedValueOnce({ rows: [] })
const res = await POST(makeRequest({ email: 'unknown@example.com' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
expect(body.message).toMatch(/if this email was invited/i)
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
// Verify the investor-lookup SQL was issued with the normalized email
const [sql, params] = queryMock.mock.calls[0]
expect(sql).toMatch(/FROM pitch_investors WHERE email/i)
expect(params).toEqual(['unknown@example.com'])
})
it('normalizes email (trim + lowercase) before lookup', async () => {
queryMock.mockResolvedValueOnce({ rows: [] })
queryMock.mockResolvedValueOnce({ rows: [] })
await POST(makeRequest({ email: ' Mixed@Example.COM ' }))
const [, params] = queryMock.mock.calls[0]
expect(params).toEqual(['mixed@example.com'])
})
})
describe('POST /api/auth/request-link — known investor', () => {
it('creates a new magic link and sends the email with generic response', async () => {
// 1st: investor lookup → found
queryMock.mockResolvedValueOnce({ rows: [investorRow()] })
// 2nd: magic link insert
queryMock.mockResolvedValueOnce({ rows: [] })
// 3rd: audit log insert
queryMock.mockResolvedValueOnce({ rows: [] })
const res = await POST(makeRequest({ email: 'invited@example.com' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
// Response is identical to the unknown-email case (no information leak)
expect(body.message).toMatch(/if this email was invited/i)
// Verify magic link insert
const [insertSql, insertParams] = queryMock.mock.calls[1]
expect(insertSql).toMatch(/INSERT INTO pitch_magic_links/i)
expect(insertParams[0]).toBe('investor-1')
expect(insertParams[1]).toMatch(/^[0-9a-f]{96}$/) // 96-char hex token
expect(insertParams[2]).toBeInstanceOf(Date)
// Verify email was sent with the fresh token URL
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(1)
const [emailTo, emailName, magicLinkUrl] = sendMagicLinkEmailMock.mock.calls[0]
expect(emailTo).toBe('invited@example.com')
expect(emailName).toBe('Alice')
expect(magicLinkUrl).toMatch(/\/auth\/verify\?token=[0-9a-f]{96}$/)
})
it('generates a different token on each call (re-invite is always fresh)', async () => {
// Call 1
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'a@x.com' })] })
queryMock.mockResolvedValueOnce({ rows: [] })
queryMock.mockResolvedValueOnce({ rows: [] })
await POST(makeRequest({ email: 'a@x.com' }))
// Call 2 — different email to avoid the per-email rate limit
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'b@x.com' })] })
queryMock.mockResolvedValueOnce({ rows: [] })
queryMock.mockResolvedValueOnce({ rows: [] })
await POST(makeRequest({ email: 'b@x.com' }))
const token1 = queryMock.mock.calls[1][1][1]
const token2 = queryMock.mock.calls[4][1][1]
expect(token1).not.toBe(token2)
})
it('skips email send for a revoked investor (returns generic response)', async () => {
queryMock.mockResolvedValueOnce({ rows: [investorRow({ status: 'revoked' })] })
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
const res = await POST(makeRequest({ email: 'invited@example.com' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
// Ensure no magic link was inserted
const inserts = queryMock.mock.calls.filter(c => /INSERT INTO pitch_magic_links/i.test(c[0]))
expect(inserts.length).toBe(0)
})
})
describe('POST /api/auth/request-link — rate limiting', () => {
it('throttles after N requests per email and returns generic success (silent throttle)', async () => {
const email = `throttle-${Date.now()}@example.com`
// First 3 requests succeed (RATE_LIMITS.magicLink.limit = 3)
for (let i = 0; i < 3; i++) {
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email })] })
queryMock.mockResolvedValueOnce({ rows: [] }) // magic link insert
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
const res = await POST(makeRequest({ email }))
expect(res.status).toBe(200)
}
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
// 4th request is silently throttled — same generic response, no email sent
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log only
const res4 = await POST(makeRequest({ email }))
expect(res4.status).toBe(200)
const body4 = await res4.json()
expect(body4.success).toBe(true)
// Still exactly 3 emails sent — nothing new
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
})
it('throttles with 429 after too many attempts from the same IP', async () => {
const ip = '172.31.99.99'
// RATE_LIMITS.authVerify.limit = 10 for IP-scoped checks
for (let i = 0; i < 10; i++) {
queryMock.mockResolvedValueOnce({ rows: [] }) // investor lookup returns empty
queryMock.mockResolvedValueOnce({ rows: [] }) // audit
const res = await POST(makeRequest({ email: `ip-test-${i}@example.com` }, ip))
expect(res.status).toBe(200)
}
const res = await POST(makeRequest({ email: 'final@example.com' }, ip))
expect(res.status).toBe(429)
})
})
@@ -14,8 +14,12 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
const [investor, sessions, snapshots, audit] = await Promise.all([
pool.query(
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at
FROM pitch_investors WHERE id = $1`,
`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,
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
WHERE i.id = $1`,
[id],
),
pool.query(
@@ -60,36 +64,58 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
const { id } = await ctx.params
const body = await request.json().catch(() => ({}))
const { name, company } = body
const { name, company, assigned_version_id } = body
if (name === undefined && company === undefined) {
return NextResponse.json({ error: 'name or company required' }, { status: 400 })
if (name === undefined && company === undefined && assigned_version_id === undefined) {
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
}
const before = await pool.query(
`SELECT name, company FROM pitch_investors WHERE id = $1`,
`SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
}
// Validate version exists and is committed (if assigning)
if (assigned_version_id !== undefined && assigned_version_id !== null) {
const ver = await pool.query(
`SELECT id, status FROM pitch_versions WHERE id = $1`,
[assigned_version_id],
)
if (ver.rows.length === 0) {
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
}
if (ver.rows[0].status !== 'committed') {
return NextResponse.json({ error: 'Can only assign committed versions' }, { status: 400 })
}
}
// Use null to clear version assignment, undefined to leave unchanged
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
const { rows } = await pool.query(
`UPDATE pitch_investors SET
name = COALESCE($1, name),
company = COALESCE($2, company),
assigned_version_id = $4,
updated_at = NOW()
WHERE id = $3
RETURNING id, email, name, company, status`,
[name ?? null, company ?? null, id],
RETURNING id, email, name, company, status, assigned_version_id`,
[name ?? null, company ?? null, id, versionValue],
)
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
? 'investor_version_assigned'
: 'investor_edited'
await logAdminAudit(
adminId,
'investor_edited',
action,
{
before: before.rows[0],
after: { name: rows[0].name, company: rows[0].company },
after: { name: rows[0].name, company: rows[0].company, assigned_version_id: rows[0].assigned_version_id },
},
request,
id,
@@ -8,9 +8,11 @@ export async function GET(request: NextRequest) {
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.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
FROM pitch_investors i
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
ORDER BY i.created_at DESC`,
)
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface Ctx { params: Promise<{ id: string }> }
export async function POST(request: NextRequest, ctx: Ctx) {
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 ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (ver.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Already committed' }, { status: 400 })
}
const { rows } = await pool.query(
`UPDATE pitch_versions SET status = 'committed', committed_at = NOW() WHERE id = $1 RETURNING *`,
[id],
)
await logAdminAudit(adminId, 'version_committed', {
version_id: id,
name: rows[0].name,
}, request)
return NextResponse.json({ version: rows[0] })
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { VERSION_TABLES, VersionTableName } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string; tableName: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id, tableName } = await ctx.params
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
}
const { rows } = await pool.query(
`SELECT data, updated_at, updated_by FROM pitch_version_data
WHERE version_id = $1 AND table_name = $2`,
[id, tableName],
)
if (rows.length === 0) {
return NextResponse.json({ data: [], updated_at: null })
}
const data = typeof rows[0].data === 'string' ? JSON.parse(rows[0].data) : rows[0].data
return NextResponse.json({ data, updated_at: rows[0].updated_at })
}
export async function PUT(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id, tableName } = await ctx.params
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
}
// Verify version is a draft
const ver = await pool.query(`SELECT status FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Version not found' }, { status: 404 })
if (ver.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
}
const body = await request.json().catch(() => ({}))
const { data } = body
if (!Array.isArray(data) && typeof data !== 'object') {
return NextResponse.json({ error: 'data must be an array or object' }, { status: 400 })
}
// Wrap single-record tables in array for consistency
const normalizedData = Array.isArray(data) ? data : [data]
await pool.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (version_id, table_name) DO UPDATE SET
data = $3, updated_at = NOW(), updated_by = $4`,
[id, tableName, JSON.stringify(normalizedData), adminId],
)
await logAdminAudit(adminId, 'version_data_edited', {
version_id: id,
table_name: tableName,
}, request)
return NextResponse.json({ success: true })
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
import { loadVersionData, VERSION_TABLES } from '@/lib/version-helpers'
import { diffTable } from '@/lib/version-diff'
interface Ctx { params: Promise<{ id: string; otherId: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id, otherId } = await ctx.params
// Verify both versions exist
const [vA, vB] = await Promise.all([
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [id]),
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [otherId]),
])
if (vA.rows.length === 0 || vB.rows.length === 0) {
return NextResponse.json({ error: 'One or both versions not found' }, { status: 404 })
}
const [dataA, dataB] = await Promise.all([
loadVersionData(id),
loadVersionData(otherId),
])
const diffs = VERSION_TABLES.map(tableName =>
diffTable(tableName, dataA[tableName] || [], dataB[tableName] || [])
).filter(d => d.hasChanges)
return NextResponse.json({
versionA: vA.rows[0],
versionB: vB.rows[0],
diffs,
total_changes: diffs.reduce((sum, d) => sum + d.rows.filter(r => r.status !== 'unchanged').length, 0),
})
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { copyVersionData } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string }> }
export async function POST(request: NextRequest, ctx: Ctx) {
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 body = await request.json().catch(() => ({}))
const name = body.name || ''
const parent = await pool.query(`SELECT id, name, status FROM pitch_versions WHERE id = $1`, [id])
if (parent.rows.length === 0) return NextResponse.json({ error: 'Parent version not found' }, { status: 404 })
const forkName = name.trim() || `${parent.rows[0].name} (fork)`
const { rows } = await pool.query(
`INSERT INTO pitch_versions (name, parent_id, status, created_by)
VALUES ($1, $2, 'draft', $3) RETURNING *`,
[forkName, id, adminId],
)
const version = rows[0]
await copyVersionData(id, version.id, adminId)
await logAdminAudit(adminId, 'version_forked', {
version_id: version.id,
parent_id: id,
parent_name: parent.rows[0].name,
name: forkName,
}, request)
return NextResponse.json({ version })
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { loadVersionData } from '@/lib/version-helpers'
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 v.*, a.name AS created_by_name, a.email AS created_by_email,
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
FROM pitch_versions v
LEFT JOIN pitch_admins a ON a.id = v.created_by
WHERE v.id = $1`,
[id],
)
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const data = await loadVersionData(id)
return NextResponse.json({ version: rows[0], data })
}
export async function PATCH(request: NextRequest, ctx: Ctx) {
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 body = await request.json().catch(() => ({}))
const { name, description } = body
const before = await pool.query(`SELECT name, description, status FROM pitch_versions WHERE id = $1`, [id])
if (before.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (before.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
}
const { rows } = await pool.query(
`UPDATE pitch_versions SET name = COALESCE($1, name), description = COALESCE($2, description)
WHERE id = $3 RETURNING *`,
[name ?? null, description ?? null, id],
)
await logAdminAudit(adminId, 'version_edited', {
version_id: id,
before: { name: before.rows[0].name, description: before.rows[0].description },
after: { name: rows[0].name, description: rows[0].description },
}, request)
return NextResponse.json({ version: rows[0] })
}
export async function DELETE(request: NextRequest, ctx: Ctx) {
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 ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Prevent deleting committed versions that have children or assigned investors
if (ver.rows[0].status === 'committed') {
const children = await pool.query(`SELECT id FROM pitch_versions WHERE parent_id = $1 LIMIT 1`, [id])
if (children.rows.length > 0) {
return NextResponse.json({ error: 'Cannot delete: has child versions' }, { status: 400 })
}
const investors = await pool.query(`SELECT id FROM pitch_investors WHERE assigned_version_id = $1 LIMIT 1`, [id])
if (investors.rows.length > 0) {
return NextResponse.json({ error: 'Cannot delete: assigned to investors' }, { status: 400 })
}
}
await pool.query(`DELETE FROM pitch_versions WHERE id = $1`, [id])
await logAdminAudit(adminId, 'version_deleted', { version_id: id, name: ver.rows[0].name }, request)
return NextResponse.json({ success: true })
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { snapshotBaseTables, copyVersionData } from '@/lib/version-helpers'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { rows } = await pool.query(`
SELECT v.*,
a.name AS created_by_name, a.email AS created_by_email,
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
FROM pitch_versions v
LEFT JOIN pitch_admins a ON a.id = v.created_by
ORDER BY v.created_at DESC
`)
return NextResponse.json({ versions: rows })
}
export async function POST(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const body = await request.json().catch(() => ({}))
const { name, description, parent_id } = body
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'name required' }, { status: 400 })
}
// Create the version row
const { rows } = await pool.query(
`INSERT INTO pitch_versions (name, description, parent_id, status, created_by)
VALUES ($1, $2, $3, 'draft', $4) RETURNING *`,
[name.trim(), description || null, parent_id || null, adminId],
)
const version = rows[0]
// Copy data from parent or snapshot base tables
if (parent_id) {
await copyVersionData(parent_id, version.id, adminId)
} else {
await snapshotBaseTables(version.id, adminId)
}
await logAdminAudit(adminId, 'version_created', {
version_id: version.id,
name: version.name,
parent_id: parent_id || null,
}, request)
return NextResponse.json({ version })
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { generateToken, getClientIp, logAudit } from '@/lib/auth'
import { sendMagicLinkEmail } from '@/lib/email'
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
// Generic response returned regardless of whether an investor exists, to
// prevent email enumeration. The client always sees the same success message.
const GENERIC_RESPONSE = {
success: true,
message: 'If this email was invited, a fresh access link has been sent.',
}
export async function POST(request: NextRequest) {
const ip = getClientIp(request) || 'unknown'
// IP-based rate limit to prevent enumeration / abuse
const ipRl = checkRateLimit(`request-link-ip:${ip}`, RATE_LIMITS.authVerify)
if (!ipRl.allowed) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
}
const body = await request.json().catch(() => ({}))
const { email } = body
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email required' }, { status: 400 })
}
const normalizedEmail = email.toLowerCase().trim()
// Per-email rate limit (silent — same generic response on throttle so callers
// can't distinguish a throttled-but-valid email from an unknown one)
const emailRl = checkRateLimit(`magic-link:${normalizedEmail}`, RATE_LIMITS.magicLink)
if (!emailRl.allowed) {
await logAudit(null, 'request_link_throttled', { email: normalizedEmail }, request)
return NextResponse.json(GENERIC_RESPONSE)
}
const { rows } = await pool.query(
`SELECT id, email, name, status FROM pitch_investors WHERE email = $1`,
[normalizedEmail],
)
if (rows.length === 0) {
await logAudit(null, 'request_link_unknown_email', { email: normalizedEmail }, request)
return NextResponse.json(GENERIC_RESPONSE)
}
const investor = rows[0]
if (investor.status === 'revoked') {
await logAudit(investor.id, 'request_link_revoked', { email: normalizedEmail }, request)
return NextResponse.json(GENERIC_RESPONSE)
}
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 magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl)
await logAudit(investor.id, 'request_link_sent', { email: normalizedEmail, expires_at: expiresAt.toISOString() }, request)
return NextResponse.json(GENERIC_RESPONSE)
}
+5 -4
View File
@@ -12,11 +12,12 @@ const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
'cover': { de: 'Cover', en: 'Cover' },
'problem': { de: 'Das Problem', en: 'The Problem' },
'solution': { de: 'Die Lösung', en: 'The Solution' },
'usp': { de: 'USP', en: 'USP' },
'product': { de: 'Produkte', en: 'Products' },
'how-it-works': { de: 'So funktioniert\'s', en: 'How It Works' },
'market': { de: 'Markt', en: 'Market' },
'business-model': { de: 'Geschäftsmodell', en: 'Business Model' },
'traction': { de: 'Traction', en: 'Traction' },
'traction': { de: 'Meilensteine', en: 'Milestones' },
'competition': { de: 'Wettbewerb', en: 'Competition' },
'team': { de: 'Team', en: 'Team' },
'financials': { de: 'Finanzen', en: 'Financials' },
@@ -51,7 +52,7 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
2. Das Problem: "Unternehmen stehen vor einem strategischen Dilemma: Ohne KI verlieren sie Wettbewerbsfähigkeit. Mit US-KI riskieren sie Datenkontrollverlust. Über 30.000 Unternehmen in DE durch EU-Regulierungen belastet."
3. 12 Module: "Code Security (SAST/DAST/SBOM/Pentesting), CE-Software-Risikobeurteilung, Compliance-Dokumente (VVT/DSFA/TOMs), Audit Manager, DSR/Betroffenenrechte, Consent Management, Notfallpläne, Cookie-Generator, Compliance LLM, Academy, Integration in Kundenprozesse, Sichere Kommunikation."
4. Code & CE: "Kontinuierlich statt einmal im Jahr. CE-Software-Risikobeurteilung auf Code-Basis schon in der Entwicklung. Findings als Tickets mit Implementierungsvorschlägen."
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder OVH in Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Kunden zahlen ~40-50k EUR/Jahr und sparen 50-110k EUR (Pentests 30k, CE-Beurteilungen 20k, Auditmanager 60k+). ROI ab Tag 1."
8. Team: "Skalierung 5→10→17→25→35 MA in 4 Jahren. 37% Engineering, 20% Sales, 9% CS, 9% Compliance/Legal, 9% Marketing. Compliance Consultant als erster Hire — Domain-Expertise vor Engineering."
@@ -68,9 +69,9 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
## IP-Schutz-Layer (KRITISCH)
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
Stattdessen: "Proprietäre KI-Engine", "BSI-zertifizierte EU-Cloud (SysEleven, OVH, Hetzner)", "Isolierte Kunden-Namespaces", "Enterprise-Grade Verschlüsselung".
Stattdessen: "Proprietäre KI-Engine", "BSI-zertifizierte EU-Cloud (SysEleven, Hetzner)", "Isolierte Kunden-Namespaces", "Enterprise-Grade Verschlüsselung".
## Erlaubt: Geschäftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (öffentlich), LLM-Größen (32b/40b/1000b), CE-Risikobeurteilung, Jira-Integration, Meeting-Recorder, Matrix/Jitsi.
## Erlaubt: Geschäftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (öffentlich), LLM-Größen (32b/40b/1000b), CE-Risikobeurteilung, Issue-Tracker-Integration, Meeting-Recorder, Matrix/Jitsi.
## Team-Antworten (WICHTIG)
Wenn nach dem Team gefragt wird: IMMER die Namen, Rollen und Expertise der Gründer aus den bereitgestellten Daten nennen. NIEMALS vage Antworten wie "unser Team vereint Expertise" ohne Namen. Zitiere die konkreten Personen aus den Unternehmensdaten.
+41 -15
View File
@@ -1,24 +1,53 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const client = await pool.connect()
// Check if investor has an assigned version
const session = await getSessionFromCookie()
let versionId: string | null = null
if (session) {
const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
[session.sub],
)
versionId = inv.rows[0]?.assigned_version_id || null
}
// If version assigned, load from pitch_version_data
if (versionId) {
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
const map: Record<string, unknown[]> = {}
for (const row of rows) {
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
return NextResponse.json({
company: (map.company || [])[0] || null,
team: map.team || [],
financials: map.financials || [],
market: map.market || [],
competitors: map.competitors || [],
features: map.features || [],
milestones: map.milestones || [],
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
})
}
// Fallback: read from base tables (backward compatible)
const client = await pool.connect()
try {
const [
companyRes,
teamRes,
financialsRes,
marketRes,
competitorsRes,
featuresRes,
milestonesRes,
metricsRes,
fundingRes,
productsRes,
companyRes, teamRes, financialsRes, marketRes, competitorsRes,
featuresRes, milestonesRes, metricsRes, fundingRes, productsRes,
] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
@@ -49,9 +78,6 @@ export async function GET() {
}
} catch (error) {
console.error('Database query error:', error)
return NextResponse.json(
{ error: 'Failed to load pitch data' },
{ status: 500 }
)
return NextResponse.json({ error: 'Failed to load pitch data' }, { status: 500 })
}
}
+45 -14
View File
@@ -1,32 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic'
// GET: Load all scenarios with their assumptions
function assembleScenarios(scenarioRows: Record<string, unknown>[], assumptionRows: Record<string, unknown>[]) {
return scenarioRows.map(s => ({
...s,
assumptions: assumptionRows
.filter((a: Record<string, unknown>) => a.scenario_id === (s as Record<string, unknown>).id)
.map((a: Record<string, unknown>) => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
})),
}))
}
// GET: Load all scenarios with their assumptions (version-aware)
export async function GET() {
try {
// Check if investor has an assigned version with FM data
const session = await getSessionFromCookie()
let versionId: string | null = null
if (session) {
const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
[session.sub],
)
versionId = inv.rows[0]?.assigned_version_id || null
}
if (versionId) {
const [scenarioData, assumptionData] = await Promise.all([
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_scenarios'`, [versionId]),
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_assumptions'`, [versionId]),
])
if (scenarioData.rows.length > 0) {
const scenarios = typeof scenarioData.rows[0].data === 'string'
? JSON.parse(scenarioData.rows[0].data) : scenarioData.rows[0].data
const assumptions = assumptionData.rows.length > 0
? (typeof assumptionData.rows[0].data === 'string'
? JSON.parse(assumptionData.rows[0].data) : assumptionData.rows[0].data)
: []
return NextResponse.json(assembleScenarios(scenarios, assumptions))
}
}
// Fallback: base tables
const client = await pool.connect()
try {
const scenarios = await client.query(
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
)
const assumptions = await client.query(
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
)
const result = scenarios.rows.map(s => ({
...s,
assumptions: assumptions.rows
.filter(a => a.scenario_id === s.id)
.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
})),
}))
return NextResponse.json(result)
return NextResponse.json(assembleScenarios(scenarios.rows, assumptions.rows))
} finally {
client.release()
}
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getAdminFromCookie } from '@/lib/admin-auth'
interface Ctx { params: Promise<{ versionId: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
// Admin-only: verify admin session
const admin = await getAdminFromCookie()
if (!admin) {
return NextResponse.json({ error: 'Admin access required for preview' }, { status: 401 })
}
const { versionId } = await ctx.params
// Load version metadata
const ver = await pool.query(
`SELECT name, status FROM pitch_versions WHERE id = $1`,
[versionId],
)
if (ver.rows.length === 0) {
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
}
// Load version data
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
if (rows.length === 0) {
return NextResponse.json({ error: 'Version has no data' }, { status: 404 })
}
const map: Record<string, unknown[]> = {}
for (const row of rows) {
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
// Return PitchData format + version metadata
return NextResponse.json({
company: (map.company || [])[0] || null,
team: map.team || [],
financials: map.financials || [],
market: map.market || [],
competitors: map.competitors || [],
features: map.features || [],
milestones: map.milestones || [],
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
_version: { name: ver.rows[0].name, status: ver.rows[0].status },
})
}
+71 -1
View File
@@ -1,8 +1,43 @@
'use client'
import { motion } from 'framer-motion'
import { useState, FormEvent } from 'react'
type Status = 'idle' | 'submitting' | 'sent' | 'error'
export default function AuthPage() {
const [email, setEmail] = useState('')
const [status, setStatus] = useState<Status>('idle')
const [message, setMessage] = useState<string | null>(null)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!email.trim()) return
setStatus('submitting')
setMessage(null)
try {
const res = await fetch('/api/auth/request-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() }),
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
setStatus('sent')
setMessage(data.message || 'If this email was invited, a fresh access link has been sent.')
} else {
setStatus('error')
setMessage(data.error || 'Something went wrong. Please try again.')
}
} catch {
setStatus('error')
setMessage('Network error. Please try again.')
}
}
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
{/* Background gradient */}
@@ -35,9 +70,44 @@ export default function AuthPage() {
<p className="text-white/50 text-sm leading-relaxed mb-6">
This interactive pitch deck is available by invitation only.
Please check your email for an access link.
If you were invited, enter your email below and we&apos;ll send you a fresh access link.
</p>
{status === 'sent' ? (
<div className="text-left bg-indigo-500/10 border border-indigo-500/20 rounded-lg p-4 mb-5">
<p className="text-indigo-200/90 text-sm leading-relaxed">
{message}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="text-left mb-5">
<label htmlFor="email" className="block text-white/60 text-xs mb-2 uppercase tracking-wide">
Email address
</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === 'submitting'}
placeholder="you@example.com"
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-white/90 text-sm placeholder:text-white/20 focus:outline-none focus:border-indigo-400/50 focus:bg-white/[0.06] transition-colors disabled:opacity-50"
/>
{status === 'error' && message && (
<p className="mt-2 text-rose-300/80 text-xs">{message}</p>
)}
<button
type="submit"
disabled={status === 'submitting' || !email.trim()}
className="w-full mt-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-400 hover:to-purple-400 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg px-4 py-3 transition-all"
>
{status === 'submitting' ? 'Sending…' : 'Send access link'}
</button>
</form>
)}
<div className="border-t border-white/[0.06] pt-5">
<p className="text-white/30 text-xs">
Questions? Contact us at{' '}
@@ -128,9 +128,95 @@ export default function EditScenarioPage() {
<div className="space-y-3">
{items.map(a => {
const isEdited = edits[a.id] !== undefined
// Detect arrays of objects for structured editing
const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null
if (isObjectArray) {
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as unknown as Record<string, unknown>[])
const cols = Object.keys(rows[0] || {})
return (
<div key={a.id} className="border border-white/[0.06] rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-white/[0.02]">
<div>
<span className="text-sm text-white/90">{a.label_en || a.label_de}</span>
<span className="text-xs text-white/40 font-mono ml-2">{a.key}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const newRow: Record<string, unknown> = {}
cols.forEach(c => { newRow[c] = typeof rows[0][c] === 'number' ? 0 : '' })
const updated = [...rows, newRow]
setEdit(a.id, JSON.stringify(updated))
}}
className="text-[10px] px-2 py-1 rounded bg-white/[0.06] text-white/60 hover:text-white hover:bg-white/[0.1]"
>
+ Row
</button>
{isEdited && (
<button
onClick={() => saveAssumption(a)}
disabled={savingId === a.id}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-[10px] px-2.5 py-1 rounded flex items-center gap-1 disabled:opacity-50"
>
<Save className="w-3 h-3" /> Save
</button>
)}
</div>
</div>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/[0.06]">
{cols.map(c => (
<th key={c} className="text-left py-2 px-3 text-white/40 font-medium uppercase tracking-wider">{c}</th>
))}
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
{cols.map(c => (
<td key={c} className="py-1.5 px-3">
<input
type={typeof row[c] === 'number' ? 'number' : 'text'}
value={row[c] as string | number}
onChange={e => {
const updated = rows.map((r, i) => {
if (i !== ri) return r
const val = typeof r[c] === 'number' ? Number(e.target.value) || 0 : e.target.value
return { ...r, [c]: val }
})
setEdit(a.id, JSON.stringify(updated))
}}
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono py-0.5 focus:outline-none"
/>
</td>
))}
<td className="py-1.5 px-1">
<button
onClick={() => {
const updated = rows.filter((_, i) => i !== ri)
setEdit(a.id, JSON.stringify(updated))
}}
className="text-white/30 hover:text-rose-400 p-1"
title="Remove row"
>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const currentValue = isEdited
? edits[a.id]
: a.value_type === 'timeseries'
: typeof a.value === 'object'
? JSON.stringify(a.value)
: String(a.value)
@@ -16,6 +16,9 @@ interface InvestorDetail {
last_login_at: string | null
login_count: number
created_at: string
assigned_version_id: string | null
version_name: string | null
version_status: string | null
}
sessions: Array<{
id: string
@@ -60,6 +63,11 @@ export default function InvestorDetailPage() {
const [company, setCompany] = useState('')
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [versions, setVersions] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
fetch('/api/admin/versions').then(r => r.json()).then(d => setVersions((d.versions || []).filter((v: { status: string }) => v.status === 'committed')))
}, [])
function flashToast(msg: string) {
setToast(msg)
@@ -236,6 +244,40 @@ export default function InvestorDetailPage() {
</div>
</div>
{/* Version assignment */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
<div className="flex items-center gap-3">
<select
value={inv.assigned_version_id || ''}
onChange={async (e) => {
const versionId = e.target.value || null
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigned_version_id: versionId }),
})
setBusy(false)
if (res.ok) { flashToast('Version updated'); load() }
else { flashToast('Update failed') }
}}
disabled={busy}
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Default (base tables)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
<span className="text-xs text-white/40">
{inv.assigned_version_id
? `Investor sees version "${inv.version_name || ''}"`
: 'Investor sees default pitch data'}
</span>
</div>
</section>
{/* Audit log for this investor */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>
@@ -15,6 +15,8 @@ interface Investor {
created_at: string
slides_viewed: number
last_activity: string | null
assigned_version_id: string | null
version_name: string | null
}
const STATUS_STYLES: Record<string, string> = {
@@ -139,6 +141,7 @@ export default function InvestorsPage() {
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium text-right">Logins</th>
<th className="py-3 px-4 font-medium text-right">Slides</th>
<th className="py-3 px-4 font-medium">Version</th>
<th className="py-3 px-4 font-medium">Last login</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
@@ -166,6 +169,13 @@ export default function InvestorsPage() {
</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
<td className="py-3 px-4">
{inv.version_name ? (
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/15 text-purple-300 border border-purple-500/30">{inv.version_name}</span>
) : (
<span className="text-xs text-white/30">Default</span>
)}
</td>
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
</td>
@@ -0,0 +1,116 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface FieldDiff {
key: string
before: unknown
after: unknown
}
interface RowDiff {
status: 'added' | 'removed' | 'changed' | 'unchanged'
fields: FieldDiff[]
}
interface TableDiff {
tableName: string
rows: RowDiff[]
hasChanges: boolean
}
interface DiffData {
versionA: { id: string; name: string }
versionB: { id: string; name: string }
diffs: TableDiff[]
total_changes: number
}
const STATUS_COLORS: Record<string, string> = {
added: 'bg-green-500/10 border-green-500/20',
removed: 'bg-rose-500/10 border-rose-500/20',
changed: 'bg-amber-500/10 border-amber-500/20',
}
export default function DiffPage() {
const { id, otherId } = useParams<{ id: string; otherId: string }>()
const [data, setData] = useState<DiffData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id || !otherId) return
setLoading(true)
fetch(`/api/admin/versions/${id}/diff/${otherId}`)
.then(r => r.json())
.then(setData)
.finally(() => setLoading(false))
}, [id, otherId])
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!data) return <div className="text-rose-400">Failed to load diff</div>
return (
<div className="space-y-6">
<Link href={`/pitch-admin/versions/${id}`} className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to version
</Link>
<div>
<h1 className="text-2xl font-semibold text-white mb-1">Diff</h1>
<p className="text-sm text-white/50">
<span className="text-indigo-300">{data.versionA.name}</span>
{' → '}
<span className="text-purple-300">{data.versionB.name}</span>
{' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''}
</p>
</div>
{data.diffs.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center text-white/50">
No differences found
</div>
) : (
<div className="space-y-4">
{data.diffs.map(table => (
<details key={table.tableName} open className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<summary className="px-5 py-3 cursor-pointer flex items-center justify-between hover:bg-white/[0.02]">
<span className="text-sm font-semibold text-white capitalize">{table.tableName.replace(/_/g, ' ')}</span>
<span className="text-xs text-white/40">
{table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''}
</span>
</summary>
<div className="px-5 pb-4 space-y-2">
{table.rows.filter(r => r.status !== 'unchanged').map((row, i) => (
<div key={i} className={`rounded-lg border p-3 ${STATUS_COLORS[row.status] || ''}`}>
<div className="flex items-center gap-2 mb-2">
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
row.status === 'added' ? 'text-green-300' :
row.status === 'removed' ? 'text-rose-300' :
'text-amber-300'
}`}>{row.status}</span>
</div>
{row.fields.length > 0 && (
<div className="space-y-1">
{row.fields.map(f => (
<div key={f.key} className="text-xs font-mono grid grid-cols-12 gap-2">
<span className="col-span-3 text-white/60 truncate">{f.key}</span>
<span className="col-span-4 text-rose-300/80 truncate">{JSON.stringify(f.before)}</span>
<span className="col-span-1 text-white/30 text-center"></span>
<span className="col-span-4 text-green-300/80 truncate">{JSON.stringify(f.after)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</details>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,524 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react'
import BilingualField from '@/components/pitch-admin/editors/BilingualField'
import FormField from '@/components/pitch-admin/editors/FormField'
import ArrayField from '@/components/pitch-admin/editors/ArrayField'
import RowTable from '@/components/pitch-admin/editors/RowTable'
import CardList from '@/components/pitch-admin/editors/CardList'
const TABLE_LABELS: Record<string, string> = {
company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
competitors: 'Competitors', features: 'Features', milestones: 'Milestones',
metrics: 'Metrics', funding: 'Funding', products: 'Products',
fm_scenarios: 'FM Scenarios', fm_assumptions: 'FM Assumptions',
}
const TABLE_NAMES = Object.keys(TABLE_LABELS)
interface Version {
id: string; name: string; description: string | null
status: 'draft' | 'committed'; parent_id: string | null; committed_at: string | null
}
type R = Record<string, unknown>
export default function VersionEditorPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [version, setVersion] = useState<Version | null>(null)
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
const [activeTab, setActiveTab] = useState('company')
const [loading, setLoading] = useState(true)
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [jsonMode, setJsonMode] = useState(false)
const [jsonText, setJsonText] = useState('')
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
const load = useCallback(async () => {
setLoading(true)
const res = await fetch(`/api/admin/versions/${id}`)
if (res.ok) { const d = await res.json(); setVersion(d.version); setAllData(d.data) }
setLoading(false)
}, [id])
useEffect(() => { if (id) load() }, [id, load])
// Sync JSON text when switching tabs or toggling JSON mode
useEffect(() => {
if (jsonMode) setJsonText(JSON.stringify(allData[activeTab] || [], null, 2))
}, [activeTab, jsonMode, allData])
function updateData(newData: unknown[]) {
setAllData(prev => ({ ...prev, [activeTab]: newData }))
setDirty(true)
}
function updateRecord(index: number, key: string, value: unknown) {
const arr = [...(allData[activeTab] as R[] || [])]
arr[index] = { ...arr[index], [key]: value }
updateData(arr)
}
// For single-record tables (company, funding)
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
async function saveTable() {
let data: unknown
if (jsonMode) {
try { data = JSON.parse(jsonText) } catch { flashToast('Invalid JSON'); return }
} else {
data = allData[activeTab]
}
setSaving(true)
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
setSaving(false)
if (res.ok) {
setDirty(false)
if (jsonMode) setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(data) ? data : [data] }))
flashToast('Saved')
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Save failed') }
}
async function commitVersion() {
if (!confirm('Commit this version? It becomes immutable.')) return
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion() {
const name = prompt('Name for the new draft:')
if (!name) return
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
})
if (res.ok) { const d = await res.json(); router.push(`/pitch-admin/versions/${d.version.id}`) }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!version) return <div className="text-rose-400">Version not found</div>
const isDraft = version.status === 'draft'
const data = allData[activeTab] || []
const single = (data as R[])[0] || {} as R
function renderEditor() {
if (jsonMode) {
return (
<textarea
value={jsonText}
onChange={e => { setJsonText(e.target.value); setDirty(true) }}
readOnly={!isDraft}
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
style={{ minHeight: '400px' }}
spellCheck={false}
/>
)
}
switch (activeTab) {
case 'company':
return (
<div className="space-y-4 p-4">
<FormField label="Company Name" value={single.name as string || ''} onChange={v => updateSingle('name', v)} />
<div className="grid grid-cols-2 gap-4">
<FormField label="Legal Form" value={single.legal_form as string || ''} onChange={v => updateSingle('legal_form', v)} placeholder="GmbH" />
<FormField label="Founding Date" value={single.founding_date as string || ''} onChange={v => updateSingle('founding_date', v)} type="date" />
</div>
<BilingualField label="Tagline" valueDe={single.tagline_de as string || ''} valueEn={single.tagline_en as string || ''} onChangeDe={v => updateSingle('tagline_de', v)} onChangeEn={v => updateSingle('tagline_en', v)} />
<BilingualField label="Mission" valueDe={single.mission_de as string || ''} valueEn={single.mission_en as string || ''} onChangeDe={v => updateSingle('mission_de', v)} onChangeEn={v => updateSingle('mission_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<FormField label="Website" value={single.website as string || ''} onChange={v => updateSingle('website', v)} type="url" />
<FormField label="HQ City" value={single.hq_city as string || ''} onChange={v => updateSingle('hq_city', v)} />
</div>
</div>
)
case 'team':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="role_en"
addLabel="Add team member"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<BilingualField label="Role" valueDe={item.role_de as string || ''} valueEn={item.role_en as string || ''} onChangeDe={v => update('role_de', v)} onChangeEn={v => update('role_en', v)} />
<BilingualField label="Bio" valueDe={item.bio_de as string || ''} valueEn={item.bio_en as string || ''} onChangeDe={v => update('bio_de', v)} onChangeEn={v => update('bio_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<FormField label="Equity %" value={item.equity_pct as number || 0} onChange={v => update('equity_pct', v)} type="number" />
<FormField label="LinkedIn" value={item.linkedin_url as string || ''} onChange={v => update('linkedin_url', v)} type="url" />
</div>
<ArrayField label="Expertise" values={(item.expertise as string[]) || []} onChange={v => update('expertise', v)} />
</div>
)}
/>
</div>
)
case 'financials':
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'year', label: 'Year', type: 'number' },
{ key: 'revenue_eur', label: 'Revenue (EUR)', type: 'number' },
{ key: 'costs_eur', label: 'Costs (EUR)', type: 'number' },
{ key: 'mrr_eur', label: 'MRR (EUR)', type: 'number' },
{ key: 'arr_eur', label: 'ARR (EUR)', type: 'number' },
{ key: 'customers_count', label: 'Customers', type: 'number' },
{ key: 'employees_count', label: 'Employees', type: 'number' },
{ key: 'burn_rate_eur', label: 'Burn (EUR)', type: 'number' },
]}
addLabel="Add year"
/>
</div>
)
case 'market':
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'market_segment', label: 'Segment' },
{ key: 'label', label: 'Label' },
{ key: 'value_eur', label: 'Value (EUR)', type: 'number' },
{ key: 'growth_rate_pct', label: 'Growth %', type: 'number' },
{ key: 'source', label: 'Source' },
]}
/>
</div>
)
case 'competitors':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="website"
addLabel="Add competitor"
renderCard={(item, update) => (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Website" value={item.website as string || ''} onChange={v => update('website', v)} type="url" />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="Customers" value={item.customers_count as number || 0} onChange={v => update('customers_count', v)} type="number" />
<FormField label="Pricing Range" value={item.pricing_range as string || ''} onChange={v => update('pricing_range', v)} />
</div>
<ArrayField label="Strengths" values={(item.strengths as string[]) || []} onChange={v => update('strengths', v)} />
<ArrayField label="Weaknesses" values={(item.weaknesses as string[]) || []} onChange={v => update('weaknesses', v)} />
</div>
)}
/>
</div>
)
case 'features':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="feature_name_en"
subtitleKey="category"
addLabel="Add feature"
renderCard={(item, update) => (
<div className="space-y-3">
<BilingualField label="Feature Name" valueDe={item.feature_name_de as string || ''} valueEn={item.feature_name_en as string || ''} onChangeDe={v => update('feature_name_de', v)} onChangeEn={v => update('feature_name_en', v)} />
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
<div className="grid grid-cols-5 gap-3">
<FormField label="BreakPilot" value={!!item.breakpilot} onChange={v => update('breakpilot', v)} type="checkbox" />
<FormField label="Proliance" value={!!item.proliance} onChange={v => update('proliance', v)} type="checkbox" />
<FormField label="DataGuard" value={!!item.dataguard} onChange={v => update('dataguard', v)} type="checkbox" />
<FormField label="heyData" value={!!item.heydata} onChange={v => update('heydata', v)} type="checkbox" />
<FormField label="Differentiator" value={!!item.is_differentiator} onChange={v => update('is_differentiator', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'milestones':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="title_en"
subtitleKey="milestone_date"
addLabel="Add milestone"
renderCard={(item, update) => (
<div className="space-y-3">
<BilingualField label="Title" valueDe={item.title_de as string || ''} valueEn={item.title_en as string || ''} onChangeDe={v => update('title_de', v)} onChangeEn={v => update('title_en', v)} />
<BilingualField label="Description" valueDe={item.description_de as string || ''} valueEn={item.description_en as string || ''} onChangeDe={v => update('description_de', v)} onChangeEn={v => update('description_en', v)} multiline />
<div className="grid grid-cols-3 gap-4">
<FormField label="Date" value={item.milestone_date as string || ''} onChange={v => update('milestone_date', v)} />
<FormField label="Status" value={item.status as string || ''} onChange={v => update('status', v)} type="select" options={[
{ value: 'completed', label: 'Completed' }, { value: 'in_progress', label: 'In Progress' }, { value: 'planned', label: 'Planned' },
]} />
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
</div>
</div>
)}
/>
</div>
)
case 'metrics':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="metric_name"
subtitleKey="value"
addLabel="Add metric"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Metric Key" value={item.metric_name as string || ''} onChange={v => update('metric_name', v)} />
<BilingualField label="Label" valueDe={item.label_de as string || ''} valueEn={item.label_en as string || ''} onChangeDe={v => update('label_de', v)} onChangeEn={v => update('label_en', v)} />
<div className="grid grid-cols-3 gap-4">
<FormField label="Value" value={item.value as string || ''} onChange={v => update('value', v)} />
<FormField label="Unit" value={item.unit as string || ''} onChange={v => update('unit', v)} />
<FormField label="Is Live" value={!!item.is_live} onChange={v => update('is_live', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'funding':
return (
<div className="space-y-4 p-4">
<FormField label="Round Name" value={single.round_name as string || ''} onChange={v => updateSingle('round_name', v)} />
<div className="grid grid-cols-3 gap-4">
<FormField label="Amount (EUR)" value={single.amount_eur as number || 0} onChange={v => updateSingle('amount_eur', v)} type="number" />
<FormField label="Instrument" value={single.instrument as string || ''} onChange={v => updateSingle('instrument', v)} />
<FormField label="Target Date" value={single.target_date as string || ''} onChange={v => updateSingle('target_date', v)} type="date" />
</div>
<FormField label="Status" value={single.status as string || ''} onChange={v => updateSingle('status', v)} type="select" options={[
{ value: 'planned', label: 'Planned' }, { value: 'in_progress', label: 'In Progress' }, { value: 'completed', label: 'Completed' },
]} />
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Use of Funds</label>
<RowTable
rows={(single.use_of_funds as R[]) || []}
onChange={v => updateSingle('use_of_funds', v)}
columns={[
{ key: 'category', label: 'Category' },
{ key: 'percentage', label: '%', type: 'number' },
{ key: 'label_de', label: 'Label DE' },
{ key: 'label_en', label: 'Label EN' },
]}
/>
</div>
</div>
)
case 'products':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="hardware"
addLabel="Add product"
renderCard={(item, update) => (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Hardware" value={item.hardware as string || ''} onChange={v => update('hardware', v)} />
</div>
<div className="grid grid-cols-3 gap-4">
<FormField label="HW Cost (EUR)" value={item.hardware_cost_eur as number || 0} onChange={v => update('hardware_cost_eur', v)} type="number" />
<FormField label="Monthly Price (EUR)" value={item.monthly_price_eur as number || 0} onChange={v => update('monthly_price_eur', v)} type="number" />
<FormField label="Operating Cost (EUR)" value={item.operating_cost_eur as number || 0} onChange={v => update('operating_cost_eur', v)} type="number" />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="LLM Model" value={item.llm_model as string || ''} onChange={v => update('llm_model', v)} />
<FormField label="LLM Size" value={item.llm_size as string || ''} onChange={v => update('llm_size', v)} />
</div>
<BilingualField label="LLM Capability" valueDe={item.llm_capability_de as string || ''} valueEn={item.llm_capability_en as string || ''} onChangeDe={v => update('llm_capability_de', v)} onChangeEn={v => update('llm_capability_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<ArrayField label="Features (DE)" values={(item.features_de as string[]) || []} onChange={v => update('features_de', v)} />
<ArrayField label="Features (EN)" values={(item.features_en as string[]) || []} onChange={v => update('features_en', v)} />
</div>
<FormField label="Popular" value={!!item.is_popular} onChange={v => update('is_popular', v)} type="checkbox" />
</div>
)}
/>
</div>
)
case 'fm_scenarios':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="description"
addLabel="Add scenario"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Description" value={item.description as string || ''} onChange={v => update('description', v)} />
<div className="grid grid-cols-2 gap-4">
<FormField label="Color" value={item.color as string || '#6366f1'} onChange={v => update('color', v)} type="color" />
<FormField label="Default" value={!!item.is_default} onChange={v => update('is_default', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'fm_assumptions':
// Reuse the inline table approach from the FM editor (already works well for this)
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'key', label: 'Key' },
{ key: 'label_de', label: 'Label DE' },
{ key: 'label_en', label: 'Label EN' },
{ key: 'category', label: 'Category' },
{ key: 'unit', label: 'Unit' },
]}
addLabel="Add assumption"
/>
<p className="text-[10px] text-white/30 mt-2">Note: values, min/max/step are best edited via "Edit as JSON" mode for complex types.</p>
</div>
)
default:
return <div className="p-4 text-white/40">No editor for this table</div>
}
}
return (
<div className="space-y-6">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-semibold text-white">{version.name}</h1>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
isDraft ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-green-500/15 text-green-300 border-green-500/30'
}`}>{version.status}</span>
</div>
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
</div>
<div className="flex items-center gap-2">
<Link
href={`/pitch-preview/${id}`}
target="_blank"
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<Eye className="w-4 h-4" /> Preview
</Link>
{isDraft && (
<button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
<Lock className="w-4 h-4" /> Commit
</button>
)}
<button onClick={forkVersion} 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">
<GitFork className="w-4 h-4" /> Fork
</button>
{version.parent_id && (
<Link href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg">
Diff
</Link>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 overflow-x-auto pb-1">
{TABLE_NAMES.map(t => (
<button
key={t}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
activeTab === t
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60'
}`}
>
{TABLE_LABELS[t]}
</button>
))}
</div>
{/* Editor */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setJsonMode(!jsonMode)}
className={`text-[10px] px-2 py-1 rounded flex items-center gap-1 transition-colors ${
jsonMode ? 'bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.04] text-white/40 hover:text-white/60'
}`}
>
<Code className="w-3 h-3" /> {jsonMode ? 'Form' : 'JSON'}
</button>
{isDraft && (
<button
onClick={saveTable}
disabled={saving || !dirty}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
</div>
{renderEditor()}
</div>
{!isDraft && <p className="text-xs text-white/30 text-center">Committed read-only. Fork to edit.</p>}
{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,120 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface VersionOption {
id: string
name: string
status: string
}
export default function NewVersionPage() {
const router = useRouter()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [parentId, setParentId] = useState<string>('')
const [versions, setVersions] = useState<VersionOption[]>([])
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch('/api/admin/versions')
.then(r => r.json())
.then(d => setVersions(d.versions || []))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSubmitting(true)
const res = await fetch('/api/admin/versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || undefined,
parent_id: parentId || undefined,
}),
})
setSubmitting(false)
if (res.ok) {
const d = await res.json()
router.push(`/pitch-admin/versions/${d.version.id}`)
} else {
const d = await res.json().catch(() => ({}))
setError(d.error || 'Creation failed')
}
}
return (
<div className="max-w-xl">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
<h1 className="text-2xl font-semibold text-white mb-2">Create Version</h1>
<p className="text-sm text-white/50 mb-6">
A new draft will be created with a full copy of all pitch data.
Choose a parent to fork from, or leave empty to snapshot the current base tables.
</p>
<form onSubmit={handleSubmit} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name <span className="text-rose-400">*</span>
</label>
<input
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g. Conservative Q4, Series A Ready"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Optional notes about this version"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Fork from</label>
<select
value={parentId}
onChange={e => setParentId(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Base tables (current pitch data)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name} ({v.status})</option>
))}
</select>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Link href="/pitch-admin/versions" className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</Link>
<button
type="submit"
disabled={submitting}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{submitting ? 'Creating…' : 'Create draft'}
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { GitBranch, Plus, Lock, Pencil, Trash2, GitFork, Users } from 'lucide-react'
interface Version {
id: string
name: string
description: string | null
parent_id: string | null
status: 'draft' | 'committed'
created_by_name: string | null
created_by_email: string | null
committed_at: string | null
created_at: string
assigned_count: number
}
export default function VersionsPage() {
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
async function load() {
setLoading(true)
const res = await fetch('/api/admin/versions')
if (res.ok) { const d = await res.json(); setVersions(d.versions) }
setLoading(false)
}
useEffect(() => { load() }, [])
async function commitVersion(id: string) {
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
setBusy(null)
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion(id: string) {
const name = prompt('Name for the new draft:')
if (!name) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
setBusy(null)
if (res.ok) { flashToast('Forked'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function deleteVersion(id: string, name: string) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}`, { method: 'DELETE' })
setBusy(null)
if (res.ok) { flashToast('Deleted'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Pitch Versions</h1>
<p className="text-sm text-white/50 mt-1">
{versions.length} version{versions.length !== 1 ? 's' : ''} each is a complete snapshot of all pitch data
</p>
</div>
<Link
href="/pitch-admin/versions/new"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> New Version
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
) : versions.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center">
<GitBranch className="w-12 h-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60 mb-4">No versions yet. Create your first version to snapshot the current pitch data.</p>
<Link
href="/pitch-admin/versions/new"
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Create First Version
</Link>
</div>
) : (
<div className="space-y-3">
{versions.map(v => {
const parent = v.parent_id ? versions.find(p => p.id === v.parent_id) : null
return (
<div key={v.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.12] transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Link href={`/pitch-admin/versions/${v.id}`} className="text-base font-semibold text-white hover:text-indigo-300">
{v.name}
</Link>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
v.status === 'committed'
? 'bg-green-500/15 text-green-300 border-green-500/30'
: 'bg-amber-500/15 text-amber-300 border-amber-500/30'
}`}>
{v.status}
</span>
{v.assigned_count > 0 && (
<span className="text-[9px] px-2 py-0.5 rounded-full bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
<Users className="w-3 h-3" /> {v.assigned_count}
</span>
)}
</div>
{v.description && <p className="text-sm text-white/50 mb-1">{v.description}</p>}
<div className="flex items-center gap-3 text-xs text-white/40">
<span>by {v.created_by_name || v.created_by_email || 'system'}</span>
<span>{new Date(v.created_at).toLocaleDateString()}</span>
{parent && (
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" /> from {parent.name}
</span>
)}
{v.committed_at && <span>committed {new Date(v.committed_at).toLocaleDateString()}</span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Link
href={`/pitch-admin/versions/${v.id}`}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
title="Edit"
>
<Pencil className="w-4 h-4" />
</Link>
{v.status === 'draft' && (
<button
onClick={() => commitVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-green-500/15 hover:text-green-300 disabled:opacity-30"
title="Commit"
>
<Lock className="w-4 h-4" />
</button>
)}
<button
onClick={() => forkVersion(v.id)}
disabled={busy === v.id}
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"
title="Fork"
>
<GitFork className="w-4 h-4" />
</button>
<button
onClick={() => deleteVersion(v.id, v.name)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Quick diff link if has parent */}
{v.parent_id && (
<div className="mt-3 pt-3 border-t border-white/[0.04]">
<Link
href={`/pitch-admin/versions/${v.id}/diff/${v.parent_id}`}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
Compare with parent
</Link>
</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>
)
}
@@ -0,0 +1,79 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { Language, PitchData } from '@/lib/types'
import PitchDeck from '@/components/PitchDeck'
export default function PreviewPage() {
const { versionId } = useParams<{ versionId: string }>()
const [data, setData] = useState<PitchData | null>(null)
const [versionMeta, setVersionMeta] = useState<{ name: string; status: string } | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lang, setLang] = useState<Language>('de')
const toggleLanguage = useCallback(() => {
setLang(prev => prev === 'de' ? 'en' : 'de')
}, [])
useEffect(() => {
if (!versionId) return
setLoading(true)
fetch(`/api/preview-data/${versionId}`)
.then(async r => {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'Failed to load')
return r.json()
})
.then(d => {
if (d._version) {
setVersionMeta(d._version)
delete d._version
}
setData(d)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [versionId])
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
<div className="text-center">
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/40 text-sm">Loading preview...</p>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
<div className="text-center max-w-md px-6">
<p className="text-rose-400 mb-2">Preview Error</p>
<p className="text-white/40 text-sm">{error || 'No data found for this version'}</p>
<p className="text-white/30 text-xs mt-4">Make sure you are logged in as an admin.</p>
</div>
</div>
)
}
// Render PitchDeck with no investor (no watermark, no audit) — admin preview only
// The banner at the top indicates this is a preview
return (
<div className="relative">
{/* Preview banner */}
<div className="fixed top-0 left-0 right-0 z-[100] bg-amber-500/90 text-black text-center py-1.5 text-xs font-semibold tracking-wide">
PREVIEW: {versionMeta?.name ?? 'Loading...'} {versionMeta?.status === 'draft' ? 'Draft' : 'Committed'}
</div>
<PitchDeck
lang={lang}
onToggleLanguage={toggleLanguage}
investor={null}
onLogout={() => {}}
previewData={data}
/>
</div>
)
}
+4 -3
View File
@@ -286,10 +286,11 @@ export default function ChatFAB({
}
// If FAQ matched and has a goto_slide, add a GOTO marker to the response
if (faqMatch?.goto_slide) {
const gotoIdx = SLIDE_ORDER.indexOf(faqMatch.goto_slide)
const topMatch = faqMatches[0]
if (topMatch?.goto_slide) {
const gotoIdx = SLIDE_ORDER.indexOf(topMatch.goto_slide)
if (gotoIdx >= 0) {
const suffix = `\n\n[GOTO:${faqMatch.goto_slide}]`
const suffix = `\n\n[GOTO:${topMatch.goto_slide}]`
content += suffix
setMessages(prev => {
const updated = [...prev]
+36 -2
View File
@@ -41,16 +41,30 @@ import GTMSlide from './slides/GTMSlide'
import RegulatorySlide from './slides/RegulatorySlide'
import EngineeringSlide from './slides/EngineeringSlide'
import AIPipelineSlide from './slides/AIPipelineSlide'
import USPSlide from './slides/USPSlide'
import DisclaimerSlide from './slides/DisclaimerSlide'
import ExecutiveSummarySlide from './slides/ExecutiveSummarySlide'
import RegulatoryLandscapeSlide from './slides/RegulatoryLandscapeSlide'
import CapTableSlide from './slides/CapTableSlide'
import SavingsSlide from './slides/SavingsSlide'
import SDKDemoSlide from './slides/SDKDemoSlide'
import StrategySlide from './slides/StrategySlide'
import FinanzplanSlide from './slides/FinanzplanSlide'
import GlossarySlide from './slides/GlossarySlide'
interface PitchDeckProps {
lang: Language
onToggleLanguage: () => void
investor: Investor | null
onLogout: () => void
previewData?: PitchData | null
}
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
const { data, loading, error } = usePitchData()
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
const fetched = usePitchData()
const data = previewData || fetched.data
const loading = previewData ? false : fetched.loading
const error = previewData ? null : fetched.error
const nav = useSlideNavigation()
const [fabOpen, setFabOpen] = useState(false)
@@ -128,12 +142,18 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }
isPresenting={presenter.state !== 'idle'}
/>
)
case 'executive-summary':
return <ExecutiveSummarySlide lang={lang} data={data} />
case 'cover':
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
case 'problem':
return <ProblemSlide lang={lang} />
case 'solution':
return <SolutionSlide lang={lang} />
case 'usp':
return <USPSlide lang={lang} />
case 'regulatory-landscape':
return <RegulatoryLandscapeSlide lang={lang} />
case 'product':
return <ProductSlide lang={lang} products={data.products} />
case 'how-it-works':
@@ -152,6 +172,10 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }
return <FinancialsSlide lang={lang} investorId={investor?.id || null} />
case 'the-ask':
return <TheAskSlide lang={lang} funding={data.funding} />
case 'cap-table':
return <CapTableSlide lang={lang} />
case 'customer-savings':
return <SavingsSlide lang={lang} />
case 'ai-qa':
return <AIQASlide lang={lang} />
case 'annex-assumptions':
@@ -166,6 +190,16 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }
return <EngineeringSlide lang={lang} />
case 'annex-aipipeline':
return <AIPipelineSlide lang={lang} />
case 'annex-sdk-demo':
return <SDKDemoSlide lang={lang} />
case 'annex-strategy':
return <StrategySlide lang={lang} />
case 'annex-finanzplan':
return <FinanzplanSlide lang={lang} />
case 'annex-glossary':
return <GlossarySlide lang={lang} />
case 'legal-disclaimer':
return <DisclaimerSlide lang={lang} />
default:
return null
}
@@ -9,6 +9,7 @@ import {
FileText,
TrendingUp,
ShieldCheck,
GitBranch,
LogOut,
Menu,
X,
@@ -22,6 +23,7 @@ interface AdminShellProps {
const NAV = [
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
{ 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/admins', label: 'Admins', icon: ShieldCheck },
@@ -43,7 +45,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
}
return (
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
<div className="h-screen bg-[#0a0a1a] text-white flex overflow-hidden">
{/* Sidebar */}
<aside
className={`fixed lg:static inset-y-0 left-0 z-40 w-64 bg-black/40 backdrop-blur-xl border-r border-white/[0.06]
@@ -111,7 +113,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex flex-col min-w-0 min-h-0">
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<button
onClick={() => setOpen(true)}
@@ -0,0 +1,56 @@
'use client'
import { useState } from 'react'
import { X, Plus } from 'lucide-react'
interface ArrayFieldProps {
label: string
values: string[]
onChange: (v: string[]) => void
placeholder?: string
}
export default function ArrayField({ label, values, onChange, placeholder }: ArrayFieldProps) {
const [input, setInput] = useState('')
function add() {
const v = input.trim()
if (v && !values.includes(v)) {
onChange([...values, v])
setInput('')
}
}
function remove(idx: number) {
onChange(values.filter((_, i) => i !== idx))
}
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{values.map((v, i) => (
<span key={i} className="inline-flex items-center gap-1 bg-indigo-500/15 text-indigo-300 text-xs px-2 py-1 rounded-lg border border-indigo-500/20">
{v}
<button onClick={() => remove(i)} className="hover:text-rose-300">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
placeholder={placeholder || 'Type and press Enter'}
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20"
/>
<button onClick={add} className="bg-white/[0.06] hover:bg-white/[0.1] text-white/60 p-1.5 rounded-lg">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,69 @@
'use client'
interface BilingualFieldProps {
label: string
valueDe: string
valueEn: string
onChangeDe: (v: string) => void
onChangeEn: (v: string) => void
multiline?: boolean
placeholder?: string
}
export default function BilingualField({
label, valueDe, valueEn, onChangeDe, onChangeEn, multiline, placeholder,
}: BilingualFieldProps) {
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-white/40 font-semibold">DE</span>
</div>
{multiline ? (
<textarea
value={valueDe || ''}
onChange={e => onChangeDe(e.target.value)}
rows={3}
placeholder={placeholder}
className={`${inputClass} resize-none`}
/>
) : (
<input
type="text"
value={valueDe || ''}
onChange={e => onChangeDe(e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
</div>
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-white/40 font-semibold">EN</span>
</div>
{multiline ? (
<textarea
value={valueEn || ''}
onChange={e => onChangeEn(e.target.value)}
rows={3}
placeholder={placeholder}
className={`${inputClass} resize-none`}
/>
) : (
<input
type="text"
value={valueEn || ''}
onChange={e => onChangeEn(e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,115 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical } from 'lucide-react'
interface CardListProps {
items: Record<string, unknown>[]
onChange: (items: Record<string, unknown>[]) => void
titleKey: string
subtitleKey?: string
renderCard: (item: Record<string, unknown>, update: (key: string, value: unknown) => void) => React.ReactNode
newItemTemplate?: Record<string, unknown>
addLabel?: string
}
export default function CardList({
items, onChange, titleKey, subtitleKey, renderCard, newItemTemplate, addLabel,
}: CardListProps) {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
function updateItem(idx: number, key: string, value: unknown) {
onChange(items.map((item, i) => i === idx ? { ...item, [key]: value } : item))
}
function addItem() {
const newItem = newItemTemplate || (() => {
const template: Record<string, unknown> = {}
if (items.length > 0) {
Object.keys(items[0]).forEach(k => {
const sample = items[0][k]
template[k] = Array.isArray(sample) ? [] : typeof sample === 'number' ? 0 : typeof sample === 'boolean' ? false : ''
})
}
if ('sort_order' in template) template.sort_order = items.length
return template
})()
onChange([...items, newItem])
setExpandedIdx(items.length)
}
function removeItem(idx: number) {
if (!confirm('Remove this item?')) return
onChange(items.filter((_, i) => i !== idx))
if (expandedIdx === idx) setExpandedIdx(null)
}
function moveUp(idx: number) {
if (idx === 0) return
const copy = [...items]
;[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]]
onChange(copy)
setExpandedIdx(idx - 1)
}
function moveDown(idx: number) {
if (idx >= items.length - 1) return
const copy = [...items]
;[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]]
onChange(copy)
setExpandedIdx(idx + 1)
}
return (
<div className="space-y-2">
{items.map((item, idx) => {
const isExpanded = expandedIdx === idx
const title = String(item[titleKey] || `Item ${idx + 1}`)
const subtitle = subtitleKey ? String(item[subtitleKey] || '') : ''
return (
<div key={idx} className="border border-white/[0.06] rounded-xl overflow-hidden">
<button
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] text-left"
>
<div className="flex items-center gap-1 text-white/30">
<button
onClick={e => { e.stopPropagation(); moveUp(idx) }}
className="hover:text-white/60 p-0.5"
title="Move up"
>
<GripVertical className="w-3 h-3" />
</button>
</div>
{isExpanded ? <ChevronDown className="w-4 h-4 text-white/40" /> : <ChevronRight className="w-4 h-4 text-white/40" />}
<div className="flex-1 min-w-0">
<span className="text-sm text-white/90 font-medium truncate block">{title}</span>
{subtitle && <span className="text-xs text-white/40 truncate block">{subtitle}</span>}
</div>
<span className="text-[9px] text-white/30 font-mono">#{idx + 1}</span>
<button
onClick={e => { e.stopPropagation(); removeItem(idx) }}
className="text-white/30 hover:text-rose-400 p-1"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</button>
{isExpanded && (
<div className="px-4 pb-4 pt-1 border-t border-white/[0.04] space-y-4">
{renderCard(item, (key, value) => updateItem(idx, key, value))}
</div>
)}
</div>
)
})}
<button
onClick={addItem}
className="w-full flex items-center justify-center gap-2 py-2.5 text-xs text-white/50 hover:text-white border border-dashed border-white/[0.1] hover:border-white/[0.2] rounded-xl transition-colors"
>
<Plus className="w-3.5 h-3.5" /> {addLabel || 'Add item'}
</button>
</div>
)
}
@@ -0,0 +1,69 @@
'use client'
interface FormFieldProps {
label: string
value: string | number | boolean
onChange: (v: string | number | boolean) => void
type?: 'text' | 'number' | 'date' | 'url' | 'checkbox' | 'select' | 'color'
placeholder?: string
options?: { value: string; label: string }[]
hint?: string
}
export default function FormField({
label, value, onChange, type = 'text', placeholder, options, hint,
}: FormFieldProps) {
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
{type === 'checkbox' ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/40"
/>
<span className="text-sm text-white/70">{placeholder || label}</span>
</label>
) : type === 'select' && options ? (
<select
value={String(value)}
onChange={e => onChange(e.target.value)}
className={inputClass}
>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : type === 'color' ? (
<div className="flex items-center gap-2">
<input
type="color"
value={String(value) || '#6366f1'}
onChange={e => onChange(e.target.value)}
className="w-10 h-10 rounded-lg border border-white/10 cursor-pointer bg-transparent"
/>
<input
type="text"
value={String(value)}
onChange={e => onChange(e.target.value)}
className={`${inputClass} flex-1`}
placeholder="#6366f1"
/>
</div>
) : (
<input
type={type}
value={value as string | number}
onChange={e => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
{hint && <p className="text-[10px] text-white/30 mt-1">{hint}</p>}
</div>
)
}
@@ -0,0 +1,92 @@
'use client'
import { Plus, Trash2 } from 'lucide-react'
interface RowTableProps {
rows: Record<string, unknown>[]
onChange: (rows: Record<string, unknown>[]) => void
columns?: { key: string; label: string; type?: 'text' | 'number' }[]
addLabel?: string
}
export default function RowTable({ rows, onChange, columns, addLabel }: RowTableProps) {
// Auto-detect columns from first row if not provided
const cols = columns || (rows.length > 0
? Object.keys(rows[0]).filter(k => k !== 'id' && k !== 'sort_order').map(k => ({
key: k,
label: k.replace(/_/g, ' '),
type: (typeof rows[0][k] === 'number' ? 'number' : 'text') as 'text' | 'number',
}))
: [])
function updateCell(rowIdx: number, key: string, value: string) {
const col = cols.find(c => c.key === key)
const parsedValue = col?.type === 'number' ? (Number(value) || 0) : value
onChange(rows.map((r, i) => i === rowIdx ? { ...r, [key]: parsedValue } : r))
}
function addRow() {
const newRow: Record<string, unknown> = {}
cols.forEach(c => { newRow[c.key] = c.type === 'number' ? 0 : '' })
// Carry over id-like fields
if (rows.length > 0 && 'id' in rows[0]) {
newRow.id = (rows.length + 1)
}
if (rows.length > 0 && 'sort_order' in rows[0]) {
newRow.sort_order = rows.length
}
onChange([...rows, newRow])
}
function removeRow(idx: number) {
onChange(rows.filter((_, i) => i !== idx))
}
if (cols.length === 0) return <div className="text-white/40 text-sm">No columns detected</div>
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.08]">
{cols.map(c => (
<th key={c.key} className="text-left py-2 px-2 text-[10px] text-white/40 font-medium uppercase tracking-wider whitespace-nowrap">
{c.label}
</th>
))}
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
{cols.map(c => (
<td key={c.key} className="py-1 px-2">
<input
type={c.type || 'text'}
value={(row[c.key] as string | number) ?? ''}
onChange={e => updateCell(ri, c.key, e.target.value)}
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono text-xs py-1 focus:outline-none min-w-[60px]"
/>
</td>
))}
<td className="py-1 px-1">
<button onClick={() => removeRow(ri)} className="text-white/30 hover:text-rose-400 p-1" title="Remove">
<Trash2 className="w-3 h-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addRow}
className="mt-2 text-xs text-white/50 hover:text-white flex items-center gap-1 px-2 py-1 rounded hover:bg-white/[0.04]"
>
<Plus className="w-3 h-3" /> {addLabel || 'Add row'}
</button>
</div>
)
}
+107 -63
View File
@@ -18,11 +18,14 @@ import {
Activity,
Shield,
Cpu,
MessageSquare,
Eye,
Gauge,
Network,
Sparkles,
Scale,
BookOpen,
Gavel,
Globe,
} from 'lucide-react'
interface AIPipelineSlideProps {
@@ -37,10 +40,10 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
const [activeTab, setActiveTab] = useState<PipelineTab>('rag')
const heroStats = [
{ value: '38+', label: de ? 'Indexierte Verordnungen' : 'Indexed Regulations', sub: 'DSGVO · AI Act · NIS2 · CRA · BDSG · DSA · ...', color: 'text-indigo-400' },
{ value: '6.259', label: de ? 'Extrahierte Controls' : 'Extracted Controls', sub: de ? '79% Source-Match · 9 Verordnungen' : '79% source match · 9 regulations', color: 'text-purple-400' },
{ value: '6', label: de ? 'Qdrant Collections' : 'Qdrant Collections', sub: de ? 'Legal Corpus · DSFA · Recht · Templates · ...' : 'Legal Corpus · DSFA · Law · Templates · ...', color: 'text-emerald-400' },
{ value: '325+', label: de ? 'Abgeleitete Pflichten' : 'Derived Obligations', sub: de ? 'NIS2 · DSGVO · AI Act · CRA · ...' : 'NIS2 · GDPR · AI Act · CRA · ...', color: 'text-amber-400' },
{ value: '75+', label: de ? 'Rechtsquellen' : 'Legal Sources', sub: de ? 'EU-Verordnungen · DACH-Gesetze · Frameworks' : 'EU regulations · DACH laws · Frameworks', color: 'text-indigo-400' },
{ value: '70k+', label: de ? 'Unique Controls' : 'Unique Controls', sub: de ? 'Prüfbare Compliance-Anforderungen' : 'Auditable compliance requirements', color: 'text-purple-400' },
{ value: '47k+', label: de ? 'Extrahierte Pflichten' : 'Extracted Obligations', sub: de ? 'Aus Gesetzestexten abgeleitet' : 'Derived from legal texts', color: 'text-emerald-400' },
{ value: '6', label: de ? 'Pipeline-Versionen' : 'Pipeline Versions', sub: de ? 'Kontinuierliche Verbesserung' : 'Continuous improvement', color: 'text-amber-400' },
]
const tabs: { id: PipelineTab; label: string; icon: typeof Brain }[] = [
@@ -49,59 +52,105 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
{ id: 'quality', label: de ? 'QA & Infrastruktur' : 'QA & Infrastructure', icon: Gauge },
]
// RAG Pipeline content
// Source categories for investors
const sourceCategories = [
{
icon: Globe,
color: 'text-blue-400',
bg: 'bg-blue-500/10 border-blue-500/20',
title: de ? 'EU-Verordnungen (~15)' : 'EU Regulations (~15)',
why: de
? 'Bindende Vorgaben fuer alle EU-Unternehmen — Verstoesse fuehren zu Bussgeldern bis 4% des Jahresumsatzes.'
: 'Binding requirements for all EU companies — violations lead to fines up to 4% of annual revenue.',
examples: 'DSGVO · AI Act · NIS2 · CRA · MiCA · DSA · Maschinenverordnung · Batterieverordnung',
},
{
icon: Scale,
color: 'text-purple-400',
bg: 'bg-purple-500/10 border-purple-500/20',
title: de ? 'DACH-Gesetze (~20)' : 'DACH Laws (~20)',
why: de
? 'Nationale Umsetzungen und eigenstaendige Gesetze — oft strenger als EU-Mindeststandards.'
: 'National implementations and standalone laws — often stricter than EU minimum standards.',
examples: 'BDSG · TKG · GwG · HGB · BGB · UrhG · GewO · KRITIS-DachG · AT ABGB · AT KSchG',
},
{
icon: BookOpen,
color: 'text-emerald-400',
bg: 'bg-emerald-500/10 border-emerald-500/20',
title: de ? 'Frameworks & Standards (~15)' : 'Frameworks & Standards (~15)',
why: de
? 'Branchenstandards definieren den Stand der Technik — Aufsichtsbehoerden erwarten deren Einhaltung.'
: 'Industry standards define state of the art — regulators expect compliance with them.',
examples: 'NIST 800-53 · OWASP ASVS · OWASP SAMM · ENISA ICS · NIST Zero Trust · CISA Secure by Design',
},
{
icon: Gavel,
color: 'text-amber-400',
bg: 'bg-amber-500/10 border-amber-500/20',
title: de ? 'DSFA-Leitlinien & Urteile' : 'DPIA Guidelines & Rulings',
why: de
? 'Urteile zeigen wie Gerichte Gesetze auslegen — entscheidend fuer praezise Compliance-Beratung statt generischer Antworten.'
: 'Court rulings show how laws are interpreted — critical for precise compliance advice instead of generic answers.',
examples: de
? '16 Bundeslaender DSFA-Leitlinien · BAG-Urteile · Datenschutzkonferenz-Beschluesse'
: '16 federal state DPIA guidelines · Labor court rulings · Data protection conference decisions',
},
]
// RAG Pipeline steps
const ragPipelineSteps = [
{
icon: FileText,
color: 'text-blue-400',
bg: 'bg-blue-500/10 border-blue-500/20',
title: de ? '1. Ingestion & QA' : '1. Ingestion & QA',
title: de ? '1. Dokument-Ingestion' : '1. Document Ingestion',
items: de
? ['110+ Verordnungen und Gesetze (EU + DACH)', 'Strukturelles Chunking an Artikel/Absatz-Grenzen', '25.000+ extrahierte Prüfaspekte', 'Deduplizierung + Cross-Regulation Harmonisierung']
: ['110+ laws and regulations (EU + DACH)', 'Structural chunking at article/paragraph boundaries', '25,000+ extracted audit aspects', 'Deduplication + cross-regulation harmonization'],
? ['75+ Rechtsquellen aus EU, Deutschland und Oesterreich', 'Strukturelles Chunking an Artikel- und Absatz-Grenzen', 'Automatische Lizenz-Klassifikation (frei / Zitat / geschuetzt)', 'Geschuetzte Normen (ISO, BSI) werden vollstaendig reformuliert']
: ['75+ legal sources from EU, Germany and Austria', 'Structural chunking at article and paragraph boundaries', 'Automatic license classification (free / citation / restricted)', 'Protected standards (ISO, BSI) are fully reformulated'],
},
{
icon: Cpu,
color: 'text-purple-400',
bg: 'bg-purple-500/10 border-purple-500/20',
title: de ? '2. Embedding & LLM' : '2. Embedding & LLM',
title: de ? '2. Control-Extraktion' : '2. Control Extraction',
items: de
? ['BGE-M3 Multilingual (1024-dim, lokal)', '120B LLM auf OVH (via LiteLLM, OpenAI-kompatibel)', '1000B LLM auf SysEleven (BSI-zertifiziert)', 'CrossEncoder Re-Ranking + HyDE']
: ['BGE-M3 multilingual (1024-dim, local)', '120B LLM on OVH (via LiteLLM, OpenAI-compatible)', '1000B LLM on SysEleven (BSI-certified)', 'CrossEncoder re-ranking + HyDE'],
? ['LLM extrahiert Pflichten und Anforderungen aus jedem Textabschnitt', '6 Pipeline-Versionen mit kontinuierlicher Qualitaetsverbesserung', 'Obligation Extraction: 47.000+ einzelne Pflichten identifiziert', 'Atomic Control Composition: Pflichten werden zu pruefbaren Controls']
: ['LLM extracts obligations and requirements from each text section', '6 pipeline versions with continuous quality improvement', 'Obligation extraction: 47,000+ individual duties identified', 'Atomic control composition: duties become auditable controls'],
},
{
icon: Database,
color: 'text-emerald-400',
bg: 'bg-emerald-500/10 border-emerald-500/20',
title: de ? '3. Vektorspeicher' : '3. Vector Store',
title: de ? '3. Deduplizierung & Speicherung' : '3. Deduplication & Storage',
items: de
? ['Qdrant Vector DB (Hetzner, API-Key gesichert)', '6 Collections: CE, Recht, Gesetze, Datenschutz, DSFA, Templates', 'MinIO Object Storage (Hetzner, S3-kompatibel, TLS)', '25.000+ Prüfaspekte · 110 Gesetze & Regularien · abgeleitete Pflichten']
: ['Qdrant Vector DB (Hetzner, API-key secured)', '6 Collections: CE, Law, Statutes, Privacy, DSFA, Templates', 'MinIO object storage (Hetzner, S3-compatible, TLS)', '25,000+ audit aspects · 110 laws & regulations · derived obligations'],
? ['97.000 generierte Controls → 70.000+ nach Deduplizierung', 'Embedding-basierte Aehnlichkeitserkennung (Cosine Similarity)', 'Cross-Regulation Harmonisierung: gleiche Pflicht aus verschiedenen Gesetzen wird zusammengefuehrt', 'Ziel: 25.00050.000 atomare Master Controls']
: ['97,000 generated controls → 70,000+ after deduplication', 'Embedding-based similarity detection (cosine similarity)', 'Cross-regulation harmonization: same obligation from different laws is merged', 'Target: 25,00050,000 atomic master controls'],
},
{
icon: Search,
color: 'text-indigo-400',
bg: 'bg-indigo-500/10 border-indigo-500/20',
title: de ? '4. Hybrid Search' : '4. Hybrid Search',
title: de ? '4. Hybrid Search & Beratung' : '4. Hybrid Search & Advisory',
items: de
? ['Multi-Collection-Suche mit Whitelist-Validierung', 'Deutsche Komposita-Zerlegung', 'Cross-Encoder Re-Ranking der Top-K Ergebnisse', 'Quellen-Attribution mit Artikel/Absatz-Referenz']
: ['Multi-collection search with whitelist validation', 'German compound word decomposition', 'Cross-encoder re-ranking of top-K results', 'Source attribution with article/paragraph reference'],
? ['Vektorsuche + Keyword-Suche ueber alle Rechtsquellen gleichzeitig', 'Cross-Encoder Re-Ranking fuer praezise Relevanz-Sortierung', 'Quellen-Attribution: Jede Antwort verweist auf Artikel und Absatz', 'Der Compliance-Agent antwortet mit Rechtsgrundlage — nicht mit Vermutungen']
: ['Vector search + keyword search across all legal sources simultaneously', 'Cross-encoder re-ranking for precise relevance sorting', 'Source attribution: Every answer references article and paragraph', 'The compliance agent answers with legal basis — not guesswork'],
},
]
// Multi-Agent System content — UCCA + Policy Engine
const agents = [
{ name: 'UCCA', soul: de ? 'Use-Case Compliance' : 'Use-Case Compliance', desc: de ? 'Policy Engine (45 Regeln) + Eskalation E0E3' : 'Policy engine (45 rules) + escalation E0E3', color: 'text-indigo-400' },
{ name: de ? 'Pflichten-Engine' : 'Obligations Engine', soul: de ? 'abgeleitete Pflichten' : 'derived obligations', desc: de ? 'Multi-Regulation: NIS2, DSGVO, AI Act, CRA, ...' : 'Multi-regulation: NIS2, GDPR, AI Act, CRA, ...', color: 'text-emerald-400' },
{ name: de ? 'Compliance-Berater' : 'Compliance Advisor', soul: de ? 'Legal RAG + LLM' : 'Legal RAG + LLM', desc: de ? 'Wizard-basierter Chatbot mit Qdrant-Kontext' : 'Wizard-based chatbot with Qdrant context', color: 'text-purple-400' },
{ name: de ? 'Dokument-Generator' : 'Document Generator', soul: de ? '20 Templates' : '20 templates', desc: de ? 'AGB, DSE, AV-Vertrag, Widerruf + 16 weitere' : 'T&C, Privacy Policy, DPA, Withdrawal + 16 more', color: 'text-amber-400' },
{ name: de ? 'DSFA-Agent' : 'DSFA Agent', soul: de ? 'Art. 35 DSGVO' : 'Art. 35 GDPR', desc: de ? 'Risikobewertung mit Legal Context Injection' : 'Risk assessment with legal context injection', color: 'text-red-400' },
{ name: de ? 'Schulungs-Engine' : 'Training Engine', soul: de ? 'Academy + TTS' : 'Academy + TTS', desc: de ? '28 Module · Piper TTS · Automatische Videos' : '28 modules · Piper TTS · Automatic videos', color: 'text-blue-400' },
{ name: de ? 'Pflichten-Engine' : 'Obligations Engine', soul: de ? '47.000+ Pflichten' : '47,000+ obligations', desc: de ? 'Multi-Regulation: NIS2, DSGVO, AI Act, CRA, ...' : 'Multi-regulation: NIS2, GDPR, AI Act, CRA, ...', color: 'text-emerald-400' },
{ name: de ? 'Compliance-Berater' : 'Compliance Advisor', soul: de ? 'Legal RAG + LLM' : 'Legal RAG + LLM', desc: de ? 'Chatbot mit 75+ Rechtsquellen als Wissenbasis' : 'Chatbot with 75+ legal sources as knowledge base', color: 'text-purple-400' },
{ name: de ? 'Dokument-Generator' : 'Document Generator', soul: de ? '7+ Templates' : '7+ templates', desc: de ? 'AGB, DSE, AV-Vertrag, DSFA, FRIA, BV + weitere' : 'T&C, Privacy Policy, DPA, DPIA, FRIA, Works Agreement + more', color: 'text-amber-400' },
{ name: de ? 'DSFA-Agent' : 'DPIA Agent', soul: de ? 'Art. 35 DSGVO' : 'Art. 35 GDPR', desc: de ? 'Risikobewertung mit 16 Bundeslaender-Leitlinien' : 'Risk assessment with 16 federal state guidelines', color: 'text-red-400' },
{ name: de ? 'Control-Pipeline' : 'Control Pipeline', soul: de ? '70.000+ Controls' : '70,000+ controls', desc: de ? 'Automatische Extraktion aus neuen Rechtsquellen' : 'Automatic extraction from new legal sources', color: 'text-blue-400' },
]
const agentInfra = [
{ icon: Shield, label: de ? 'Policy Engine' : 'Policy Engine', desc: de ? 'Deterministisch · LLM ist NICHT Wahrheitsquelle' : 'Deterministic · LLM is NOT source of truth' },
{ icon: Brain, label: de ? 'LLM-Schicht' : 'LLM Layer', desc: de ? '120B (OVH) + 1000B (SysEleven BSI) · EU-only' : '120B (OVH) + 1000B (SysEleven BSI) · EU-only' },
{ icon: Brain, label: de ? 'LLM-Schicht' : 'LLM Layer', desc: de ? 'Claude + lokale Modelle · EU-only Hosting' : 'Claude + local models · EU-only hosting' },
{ icon: Network, label: 'LiteLLM Gateway', desc: de ? 'OpenAI-kompatibel · Multi-Provider Routing' : 'OpenAI-compatible · Multi-provider routing' },
{ icon: Activity, label: de ? 'Eskalation E0E3' : 'Escalation E0E3', desc: de ? 'Auto-Approve → Team-Lead → DSB → DSB+Legal' : 'Auto-approve → Team lead → DPO → DPO+Legal' },
]
@@ -113,32 +162,32 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
color: 'text-emerald-400',
title: de ? 'Control Quality Pipeline' : 'Control Quality Pipeline',
items: de
? ['6.259 Controls extrahiert (79% Source-Match)', '3.301 Duplikate entfernt (Phase 5 Normalisierung)', '90+ QA-Skripte: Deduplizierung, Match-Validierung', 'Canonical Controls JSON-Schema-Validierung in CI']
: ['6,259 controls extracted (79% source match)', '3,301 duplicates removed (Phase 5 normalization)', '90+ QA scripts: deduplication, match validation', 'Canonical Controls JSON schema validation in CI'],
? ['97.000 Controls generiert, 70.000+ nach Deduplizierung', '6 Pipeline-Versionen mit steigender Extraktionsqualitaet', 'Automatische Lizenz-Pruefung: geschuetzte Normen werden reformuliert', 'Jeder Control hat Quellen-Referenz auf Artikel und Absatz']
: ['97,000 controls generated, 70,000+ after deduplication', '6 pipeline versions with increasing extraction quality', 'Automatic license check: protected standards are reformulated', 'Every control has source reference to article and paragraph'],
},
{
icon: Eye,
color: 'text-indigo-400',
title: de ? 'RAG Quality & Monitoring' : 'RAG Quality & Monitoring',
title: de ? 'Kontinuierliche Erweiterung' : 'Continuous Expansion',
items: de
? ['PDF-QA-Pipeline: 86% Artikel-Extraktion', 'Multi-Collection-Whitelist-Validierung', 'Qdrant-Deduplizierung: 8-Stufen-Bereinigung', 'Fallback-Handling: RAG-Fehler brechen nie Hauptfunktion']
: ['PDF QA pipeline: 86% article extraction', 'Multi-collection whitelist validation', 'Qdrant deduplication: 8-step cleanup', 'Fallback handling: RAG failures never break main function'],
? ['Neue Gesetze werden automatisch ingestiert und verarbeitet', 'Pipeline erkennt Ueberschneidungen mit bestehenden Controls', 'Cross-Regulation Mapping: gleiche Pflicht aus DSGVO und BDSG wird verknuepft', 'Wachsender Wissensvorsprung gegenueber manueller Compliance-Beratung']
: ['New laws are automatically ingested and processed', 'Pipeline detects overlaps with existing controls', 'Cross-regulation mapping: same obligation from GDPR and BDSG is linked', 'Growing knowledge advantage over manual compliance consulting'],
},
{
icon: Sparkles,
color: 'text-purple-400',
title: de ? 'CI/CD & Testing' : 'CI/CD & Testing',
items: de
? ['Gitea Actions: Lint → Tests → Validierung bei jedem Push', 'Go-Tests (AI SDK) + Python-Tests (Backend + Crawler + Gateway)', 'Coolify Auto-Deploy mit Health-Check-Monitoring', 'arm64 → amd64 Cross-Build fuer Hetzner Production']
: ['Gitea Actions: Lint → Tests → Validation on every push', 'Go tests (AI SDK) + Python tests (Backend + Crawler + Gateway)', 'Coolify auto-deploy with health check monitoring', 'arm64 → amd64 cross-build for Hetzner production'],
? ['Gitea Actions: Lint → Tests → Validierung bei jedem Push', 'Go-Tests (AI SDK) + Python-Tests (Backend + Pipeline)', 'Coolify Auto-Deploy mit Health-Check-Monitoring', 'arm64 → amd64 Cross-Build fuer Hetzner Production']
: ['Gitea Actions: Lint → Tests → Validation on every push', 'Go tests (AI SDK) + Python tests (Backend + Pipeline)', 'Coolify auto-deploy with health check monitoring', 'arm64 → amd64 cross-build for Hetzner production'],
},
{
icon: Zap,
color: 'text-amber-400',
title: de ? 'LLM-Infrastruktur' : 'LLM Infrastructure',
title: de ? 'Infrastruktur' : 'Infrastructure',
items: de
? ['120B Modell auf OVH via LiteLLM (OpenAI-kompatibel)', '1000B Modell auf SysEleven (BSI-zertifiziert)', 'Isolierte Namespaces pro Kunde · Keine US-Provider', 'BGE-M3 Embedding lokal · Lazy Model Loading']
: ['120B model on OVH via LiteLLM (OpenAI-compatible)', '1000B model on SysEleven (BSI-certified)', 'Isolated namespaces per customer · No US providers', 'BGE-M3 embedding local · Lazy model loading'],
? ['Qdrant Vektordatenbank fuer semantische Suche', 'BGE-M3 Multilingual Embedding (lokal gehostet)', 'MinIO Object Storage (S3-kompatibel, TLS-verschluesselt)', '100% EU-Cloud · Keine US-Provider · BSI-konforme Hosting-Partner']
: ['Qdrant vector database for semantic search', 'BGE-M3 multilingual embedding (locally hosted)', 'MinIO object storage (S3-compatible, TLS-encrypted)', '100% EU cloud · No US providers · BSI-compliant hosting partners'],
},
]
@@ -194,15 +243,32 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
<FadeInView delay={0.2} key={activeTab}>
{activeTab === 'rag' && (
<div>
{/* Source Categories — Why each matters */}
<div className="grid md:grid-cols-2 gap-2 mb-4">
{sourceCategories.map((cat, idx) => {
const Icon = cat.icon
return (
<div key={idx} className={`border rounded-xl p-2.5 ${cat.bg}`}>
<div className="flex items-center gap-2 mb-1.5">
<Icon className={`w-4 h-4 ${cat.color}`} />
<h3 className="text-xs font-bold text-white">{cat.title}</h3>
</div>
<p className="text-[10px] text-white/50 mb-1.5 leading-relaxed">{cat.why}</p>
<p className="text-[9px] text-white/25 font-mono leading-tight">{cat.examples}</p>
</div>
)
})}
</div>
{/* Pipeline Flow Visualization */}
<div className="flex items-center justify-center gap-1 mb-4 flex-wrap">
<div className="flex items-center justify-center gap-1 flex-wrap">
{[
{ icon: FileText, label: '38+ PDFs' },
{ icon: Layers, label: 'QA + Chunking' },
{ icon: Cpu, label: 'BGE-M3' },
{ icon: Database, label: '6 Collections' },
{ icon: Search, label: 'Hybrid Search' },
{ icon: Brain, label: '120B / 1000B' },
{ icon: FileText, label: de ? '75+ Quellen' : '75+ Sources' },
{ icon: Layers, label: de ? 'Chunking & Lizenz' : 'Chunking & License' },
{ icon: Cpu, label: de ? 'LLM-Extraktion' : 'LLM Extraction' },
{ icon: Database, label: de ? '70k+ Controls' : '70k+ Controls' },
{ icon: Search, label: de ? 'Hybrid Search' : 'Hybrid Search' },
{ icon: Brain, label: de ? 'Beratung' : 'Advisory' },
].map((step, idx, arr) => (
<div key={idx} className="flex items-center gap-1">
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-white/[0.05] border border-white/[0.08]">
@@ -213,28 +279,6 @@ export default function AIPipelineSlide({ lang }: AIPipelineSlideProps) {
</div>
))}
</div>
{/* Pipeline Steps */}
<div className="grid md:grid-cols-2 gap-3">
{ragPipelineSteps.map((step, idx) => {
const Icon = step.icon
return (
<div key={idx} className={`border rounded-xl p-3 ${step.bg}`}>
<div className="flex items-center gap-2 mb-2">
<Icon className={`w-4 h-4 ${step.color}`} />
<h3 className="text-xs font-bold text-white">{step.title}</h3>
</div>
<ul className="space-y-1">
{step.items.map((item, iidx) => (
<li key={iidx} className="flex items-start gap-1.5 text-[11px] text-white/50">
<span className={`w-1 h-1 rounded-full mt-1.5 ${step.color} bg-current shrink-0`} />
{item}
</li>
))}
</ul>
</div>
)
})}
</div>
</div>
)}
@@ -1,6 +1,7 @@
'use client'
import { Language } from '@/lib/types'
import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
@@ -212,6 +213,7 @@ export default function CapTableSlide({ lang }: CapTableSlideProps) {
</GlassCard>
</FadeInView>
</div>
<ProjectionFooter lang={lang} />
</div>
)
}
@@ -277,7 +277,7 @@ const PRICING_COMPARISON: CompetitorPricing[] = [
{
name: 'ComplAI',
flag: '🇩🇪',
model: 'Cloud (BSI DE / OVH FR)',
model: 'Cloud (BSI DE / FR)',
publicPricing: true,
tiers: [
{ name: { de: 'Startup/<10', en: 'Startup/<10' }, price: 'ab €300/mo', annual: 'ab €3.600/yr', notes: { de: '14-Tage-Test, Kreditkarte', en: '14-day trial, credit card' } },
@@ -0,0 +1,103 @@
'use client'
import { Language } from '@/lib/types'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import { Shield, Lock } from 'lucide-react'
interface DisclaimerSlideProps {
lang: Language
}
export default function DisclaimerSlide({ lang }: DisclaimerSlideProps) {
const de = lang === 'de'
return (
<div className="max-w-4xl mx-auto">
<FadeInView className="text-center mb-6">
<h2 className="text-3xl md:text-4xl font-bold mb-2">
<GradientText>{de ? 'Rechtlicher Hinweis' : 'Legal Notice'}</GradientText>
</h2>
</FadeInView>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
{/* Disclaimer */}
<FadeInView delay={0.1}>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-indigo-400" />
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">
{de ? 'Haftungsausschluss' : 'Disclaimer'}
</h3>
</div>
<div className="space-y-3 text-xs text-white/40 leading-relaxed">
<p>
{de
? 'Dieses Dokument wird vorgelegt von Benjamin Boenisch, wohnhaft in Konstanz, Deutschland (nachfolgend „Gründer"). Der Gründer beabsichtigt die Gründung der BreakPilot GmbH im dritten Quartal 2026. Zum Zeitpunkt der Erstellung dieses Dokuments ist die Gesellschaft weder gegründet noch im Handelsregister eingetragen. Der Gründer handelt ausschließlich als Privatperson im Rahmen der Gründungsvorbereitung.'
: 'This document is presented by Benjamin Boenisch, residing in Konstanz, Germany (hereinafter "Founder"). The Founder intends to establish BreakPilot GmbH in Q3 2026. At the time of this document, the company is neither founded nor registered in the commercial register. The Founder acts exclusively as a private individual in preparation of the founding.'}
</p>
<p>
{de
? 'Dieses Dokument stellt weder ein Angebot zum Verkauf noch eine Aufforderung zur Abgabe eines Angebots zum Erwerb von Wertpapieren, Gesellschaftsanteilen oder sonstigen Vermögensanlagen dar. Es handelt sich nicht um einen Wertpapierprospekt im Sinne des VermAnlG oder der EU-Prospektverordnung. Jede etwaige künftige Beteiligung begründet sich ausschließlich auf gesonderten, rechtlich geprüften Beteiligungsverträgen.'
: 'This document constitutes neither an offer to sell nor a solicitation of an offer to acquire securities, company shares or other financial instruments. It is not a securities prospectus within the meaning of the VermAnlG or the EU Prospectus Regulation. Any future participation shall be based exclusively on separate, legally reviewed participation agreements.'}
</p>
<p>
{de
? 'Dieses Dokument enthält zukunftsgerichtete Aussagen, die auf gegenwärtigen Erwartungen und Annahmen beruhen. Solche Aussagen beinhalten Risiken und Ungewissheiten, die dazu führen können, dass tatsächliche Ergebnisse wesentlich von den dargestellten Erwartungen abweichen. Sämtliche Finanzangaben in dieser Präsentation sind Planzahlen und stellen keine Garantie für künftige Ergebnisse dar.'
: 'This document contains forward-looking statements based on current expectations and assumptions. Such statements involve risks and uncertainties that may cause actual results to differ materially from stated expectations. All financial figures in this presentation are projections and do not constitute a guarantee of future results.'}
</p>
<p>
{de
? 'Sämtliche Angaben wurden mit Sorgfalt zusammengestellt, erheben jedoch keinen Anspruch auf Vollständigkeit oder Richtigkeit. Der Gründer übernimmt keine Haftung für die Aktualität, Korrektheit oder Vollständigkeit der Informationen, sofern kein vorsätzliches oder grob fahrlässiges Verschulden vorliegt. Eine Beteiligung an einem jungen Unternehmen ist mit erheblichen Risiken verbunden, einschließlich des Risikos eines Totalverlusts des eingesetzten Kapitals.'
: 'All information has been compiled with care but makes no claim to completeness or accuracy. The Founder assumes no liability for the timeliness, correctness or completeness of the information, unless there is intentional or grossly negligent fault. An investment in a young company involves significant risks, including the risk of total loss of invested capital.'}
</p>
</div>
</div>
</FadeInView>
{/* Confidentiality */}
<FadeInView delay={0.2}>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-purple-400" />
<h3 className="text-sm font-bold text-purple-400 uppercase tracking-wider">
{de ? 'Vertraulichkeit' : 'Confidentiality'}
</h3>
</div>
<div className="space-y-3 text-xs text-white/40 leading-relaxed">
<p>
{de
? 'Dieses Dokument ist vertraulich und wurde ausschließlich für den namentlich eingeladenen Empfänger erstellt. Durch die Kenntnisnahme erklärt sich der Empfänger mit folgenden Bedingungen einverstanden:'
: 'This document is confidential and has been prepared exclusively for the personally invited recipient. By accessing this document, the recipient agrees to the following terms:'}
</p>
<p>
{de
? '(a) Geheimhaltung — Der Empfänger verpflichtet sich, den Inhalt vertraulich zu behandeln und nicht an Dritte weiterzugeben, zu kopieren oder zugänglich zu machen. Ausgenommen sind Berater (Rechtsanwälte, Steuerberater), die berufsrechtlich zur Verschwiegenheit verpflichtet sind.'
: '(a) Confidentiality — The recipient undertakes to treat the content confidentially and not to disclose, copy or make it accessible to third parties. Excluded are advisors (lawyers, tax advisors) who are professionally bound to secrecy.'}
</p>
<p>
{de
? '(b) Zweckbindung — Die Informationen dürfen ausschließlich zur Bewertung einer möglichen Beteiligung verwendet werden. Jede anderweitige Nutzung ist untersagt.'
: '(b) Purpose limitation — The information may only be used for the purpose of evaluating a possible participation. Any other use is prohibited.'}
</p>
<p>
{de
? '(c) Geltungsdauer — Diese Vertraulichkeitsverpflichtung gilt für drei (3) Jahre ab Übermittlung, unabhängig davon, ob eine Beteiligung zustande kommt. Es gilt deutsches Recht. Gerichtsstand ist Konstanz, Deutschland.'
: '(c) Duration — This confidentiality obligation applies for three (3) years from transmission, regardless of whether a participation materializes. German law applies. Place of jurisdiction is Konstanz, Germany.'}
</p>
</div>
</div>
</FadeInView>
<FadeInView delay={0.3}>
<p className="text-center text-[10px] text-white/20">
{de ? 'Stand: April 2026 · Dieser Hinweis ersetzt keine Rechtsberatung.' : 'As of: April 2026 · This notice does not replace legal advice.'}
</p>
</FadeInView>
</div>
</div>
)
}
@@ -38,8 +38,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
},
{
value: '10',
label: de ? 'Docker Container' : 'Docker Containers',
sub: de ? 'Coolify → Hetzner (amd64)' : 'Coolify → Hetzner (amd64)',
label: de ? 'Services' : 'Services',
sub: de ? 'orca → Hetzner (amd64)' : 'orca → Hetzner (amd64)',
color: 'text-emerald-400',
borderColor: 'border-emerald-500/30',
},
@@ -51,9 +51,9 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
borderColor: 'border-purple-500/30',
},
{
value: '14',
label: 'Dockerfiles',
sub: de ? 'Vollstaendig containerisiert' : 'Fully containerized',
value: '5',
label: de ? 'Infra-Komponenten' : 'Infra Components',
sub: 'orca (Rust) · infisical · pg · qdrant',
color: 'text-amber-400',
borderColor: 'border-amber-500/30',
},
@@ -69,17 +69,17 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
{
icon: GitBranch,
label: 'Gitea + Actions',
desc: de ? 'Self-hosted Git + CI/CD · Lint → Tests → Validierung' : 'Self-hosted Git + CI/CD · Lint → Tests → Validation',
desc: de ? 'Self-hosted Git + CI/CD · Lint → Tests → Image-Build' : 'Self-hosted Git + CI/CD · Lint → Tests → Image build',
},
{
icon: Workflow,
label: 'Coolify',
desc: de ? 'Auto-Deploy bei Push · Docker Compose auf Hetzner · Health Checks' : 'Auto-deploy on push · Docker Compose on Hetzner · Health checks',
label: 'orca',
desc: de ? 'Single-Binary Orchestrator (Rust) · Webhook-Deploy · Auto-TLS · Raft' : 'Single-binary orchestrator (Rust) · Webhook deploys · Auto-TLS · Raft',
},
{
icon: Container,
label: 'Docker Compose',
desc: de ? 'arm64 → amd64 Build-Pipeline · Multi-Stage Builds' : 'arm64 → amd64 build pipeline · Multi-stage builds',
label: 'Private Registry',
desc: de ? 'registry.meghsakha.com · Signed Images · Tag pro Commit (:SHA + :latest)' : 'registry.meghsakha.com · Signed images · Per-commit tags (:SHA + :latest)',
},
{
icon: ShieldCheck,
@@ -88,13 +88,13 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
},
{
icon: Database,
label: 'HashiCorp Vault',
desc: de ? 'Secrets Management · Auto-Rotation · PKI' : 'Secrets Management · Auto-Rotation · PKI',
label: 'Infisical',
desc: de ? 'Secrets Management · Rotation · RBAC · End-to-End verschlüsselt' : 'Secrets Management · Rotation · RBAC · End-to-end encrypted',
},
{
icon: Server,
label: de ? 'EU-Cloud Infrastruktur' : 'EU Cloud Infrastructure',
desc: de ? 'Hetzner · SysEleven (BSI) · OVH · PostgreSQL · Qdrant' : 'Hetzner · SysEleven (BSI) · OVH · PostgreSQL · Qdrant',
desc: de ? 'Hetzner · SysEleven (BSI) · PostgreSQL · Qdrant' : 'Hetzner · SysEleven (BSI) · PostgreSQL · Qdrant',
},
]
@@ -120,8 +120,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
color: 'text-emerald-400',
dotColor: 'bg-emerald-400',
services: de
? ['PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'MinIO Object Storage']
: ['PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'MinIO Object Storage'],
? ['orca (Rust) Orchestrator', 'Infisical Secrets', 'PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'Private Registry']
: ['orca (Rust) Orchestrator', 'Infisical Secrets', 'PostgreSQL 17 (Hetzner)', 'Qdrant Vector DB', 'DSMS/IPFS Node + Gateway', 'Private Registry'],
},
]
@@ -261,8 +261,8 @@ export default function EngineeringSlide({ lang }: EngineeringSlideProps) {
<div className="mt-3 pt-3 border-t border-white/5">
<p className="text-[10px] text-white/20 text-center">
{de
? '100% EU-Cloud · Hetzner + SysEleven (BSI) + OVH · Keine US-Anbieter · Volle Datenkontrolle'
: '100% EU Cloud · Hetzner + SysEleven (BSI) + OVH · No US Providers · Full Data Control'}
? '100% EU-Cloud · Hetzner + SysEleven (BSI) · Keine US-Anbieter · Volle Datenkontrolle'
: '100% EU Cloud · Hetzner + SysEleven (BSI) · No US Providers · Full Data Control'}
</p>
</div>
</GlassCard>
@@ -179,7 +179,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
<li><strong>SAST + DAST + SBOM</strong> ${de ? '\\u2014 Vollumf\\u00e4ngliche Sicherheitstests bei jeder Code-\\u00c4nderung' : '\\u2014 Full security testing on every code change'}</li>
<li><strong>${de ? 'KI-gest\\u00fctztes Pentesting' : 'AI-powered Pentesting'}</strong> ${de ? '\\u2014 Kontinuierlich statt einmal im Jahr' : '\\u2014 Continuous instead of once a year'}</li>
<li><strong>CE-Software-Risikobeurteilung</strong> ${de ? '\\u2014 F\\u00fcr Maschinenverordnung und Produktsicherheit' : '\\u2014 For Machinery Regulation and product safety'}</li>
<li><strong>Jira-Integration</strong> ${de ? '\\u2014 Findings als Tickets mit Implementierungsvorschl\\u00e4gen' : '\\u2014 Findings as tickets with implementation suggestions'}</li>
<li><strong>Issue-Tracker-Integration</strong> ${de ? '\\u2014 Findings als Tickets mit Implementierungsvorschl\\u00e4gen' : '\\u2014 Findings as tickets with implementation suggestions'}</li>
<li><strong>Audit-Trail</strong> ${de ? '\\u2014 L\\u00fcckenloser Nachweis von Erkennung bis Behebung' : '\\u2014 Complete evidence from detection to remediation'}</li>
</ul>
</div>
@@ -191,7 +191,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
<li><strong>Audit Manager</strong> ${de ? '\\u2014 Abweichungen End-to-End: Rollen, Stichtage, Eskalation' : '\\u2014 Deviations end-to-end: roles, deadlines, escalation'}</li>
<li><strong>Compliance LLM</strong> ${de ? '\\u2014 GPT f\\u00fcr Text und Audio, sicher in der EU gehostet' : '\\u2014 GPT for text and audio, securely hosted in EU'}</li>
<li><strong>Academy</strong> ${de ? '\\u2014 Online-Schulungen f\\u00fcr GF und Mitarbeiter' : '\\u2014 Online training for management and employees'}</li>
<li><strong>${de ? 'BSI-Cloud DE / OVH FR' : 'BSI Cloud DE / OVH FR'}</strong> ${de ? '\\u2014 Keine US-SaaS, Jitsi, Matrix, volle Integration' : '\\u2014 No US SaaS, Jitsi, Matrix, full integration'}</li>
<li><strong>${de ? 'BSI-Cloud DE / FR' : 'BSI Cloud DE / FR'}</strong> ${de ? '\\u2014 Keine US-SaaS, Jitsi, Matrix, volle Integration' : '\\u2014 No US SaaS, Jitsi, Matrix, full integration'}</li>
</ul>
</div>
</div>
@@ -208,7 +208,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
<div class="card bottom-card">
<div class="section-title">${de ? 'Gesch\\u00e4ftsmodell' : 'Business Model'}</div>
<ul>
<li><strong>SaaS Cloud</strong> ${de ? '\\u2014 BSI DE / OVH FR, mitarbeiterbasiert' : '\\u2014 BSI DE / OVH FR, employee-based'}</li>
<li><strong>SaaS Cloud</strong> ${de ? '\\u2014 BSI DE / FR, mitarbeiterbasiert' : '\\u2014 BSI DE / FR, employee-based'}</li>
<li><strong>${de ? 'Modular w\\u00e4hlbar' : 'Modular choice'}</strong> ${de ? '\\u2014 Einzelne Module oder Full Compliance' : '\\u2014 Single modules or full compliance'}</li>
<li><strong>${de ? 'ROI ab Tag 1' : 'ROI from day 1'}</strong> ${de ? '\\u2014 Kunde spart 50.000+ EUR/Jahr' : '\\u2014 Customer saves EUR 50,000+/year'}</li>
</ul>
@@ -416,7 +416,7 @@ export default function ExecutiveSummarySlide({ lang, data }: ExecutiveSummarySl
de ? 'Audit Manager — Abweichungen End-to-End mit Eskalation' : 'Audit Manager — deviations end-to-end with escalation',
de ? 'Compliance LLM — GPT für Text und Audio, EU-gehostet' : 'Compliance LLM — GPT for text and audio, EU-hosted',
de ? 'Academy — Online-Schulungen für GF und Mitarbeiter' : 'Academy — online training for management and employees',
de ? 'BSI-Cloud DE / OVH FR' : 'BSI Cloud DE / OVH FR',
de ? 'BSI-Cloud DE / FR' : 'BSI Cloud DE / FR',
].map((item, idx) => (
<p key={idx} className="text-xs text-white/60 pl-3 relative">
<span className="absolute left-0 top-1 w-1.5 h-1.5 rounded-full bg-cyan-400/60" />
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import ProjectionFooter from '../ui/ProjectionFooter'
import { useFinancialModel } from '@/lib/hooks/useFinancialModel'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
@@ -293,6 +294,7 @@ export default function FinancialsSlide({ lang, investorId }: FinancialsSlidePro
</FadeInView>
</div>
</div>
<ProjectionFooter lang={lang} />
</div>
)
}
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
@@ -513,11 +514,7 @@ export default function FinanzplanSlide({ lang }: FinanzplanSlideProps) {
</GlassCard>
)}
<p className="text-center text-[9px] text-white/40 mt-2">
{de
? 'Doppelklick auf blaue Zellen zum Bearbeiten · Gründung: 01.08.2026'
: 'Double-click blue cells to edit · Founding: 01.08.2026'}
</p>
<ProjectionFooter lang={lang} />
</div>
)
}
@@ -27,8 +27,8 @@ export default function HowItWorksSlide({ lang }: HowItWorksSlideProps) {
</FadeInView>
<div className="relative max-w-4xl mx-auto">
{/* Connection Line */}
<div className="absolute left-8 top-12 bottom-12 w-px bg-gradient-to-b from-blue-500 via-purple-500 to-green-500 hidden md:block" />
{/* Connection Line — behind icons (z-0), icons have z-10 with opaque bg */}
<div className="absolute left-8 top-20 bottom-20 w-px bg-gradient-to-b from-blue-500/40 via-purple-500/40 to-green-500/40 hidden md:block z-0" />
<div className="space-y-8">
{i.howItWorks.steps.map((step, idx) => {
@@ -42,7 +42,7 @@ export default function HowItWorksSlide({ lang }: HowItWorksSlideProps) {
className="flex items-start gap-6 relative"
>
<div className={`
w-16 h-16 rounded-2xl bg-white/[0.06] border border-white/10
w-16 h-16 rounded-2xl bg-[#0c0c1d] border border-white/10
flex items-center justify-center shrink-0 relative z-10
${stepColors[idx]}
`}>
@@ -24,9 +24,9 @@ const MODULES = [
{ icon: UserCheck, color: '#14b8a6', de: 'Consent Management', en: 'Consent Management', descDe: 'Einwilligungen, Cookie-Banner, Widerruf', descEn: 'Consent, cookie banner, withdrawal' },
{ icon: AlertTriangle, color: '#f59e0b', de: 'Notfallpläne', en: 'Incident Response', descDe: 'Datenschutzvorfälle, Meldepflichten, Eskalation', descEn: 'Data breaches, reporting obligations, escalation' },
{ icon: Brain, color: '#a855f7', de: 'Compliance LLM', en: 'Compliance LLM', descDe: 'GPT für Text und Audio — sicher in der EU', descEn: 'GPT for text and audio — securely in EU' },
{ icon: Shield, color: '#8b5cf6', de: 'Cookie-Generator', en: 'Cookie Generator', descDe: 'Cookie-Banner, Consent-Konfiguration', descEn: 'Cookie banner, consent configuration' },
{ icon: Shield, color: '#8b5cf6', de: 'Tender Matching', en: 'Tender Matching', descDe: 'Kundenanfragen (RFQ) gegen Codebase prüfen', descEn: 'Verify customer RFQs against codebase' },
{ icon: GraduationCap, color: '#ec4899', de: 'Academy', en: 'Academy', descDe: 'Online-Schulungen für GF und Mitarbeiter', descEn: 'Online training for management and employees' },
{ icon: Puzzle, color: '#0ea5e9', de: 'Integration in Kundenprozesse', en: 'Process Integration', descDe: 'Ticketsysteme, Workflows', descEn: 'Ticket systems, workflows' },
{ icon: Puzzle, color: '#0ea5e9', de: 'AI Act Compliance', en: 'AI Act Compliance', descDe: 'UCCA, Use-Case-Bewertung, Betriebsratsmodul', descEn: 'UCCA, use case assessment, works council module' },
{ icon: CheckCircle2, color: '#22c55e', de: 'Sichere Kommunikation', en: 'Secure Communication', descDe: 'Chat + Video mit AI Notetaker', descEn: 'Chat + video with AI notetaker' },
]
@@ -109,7 +109,6 @@ export default function ProductSlide({ lang }: ProductSlideProps) {
<p className="text-[10px] text-white/50 leading-relaxed">{i.product.cloudDesc}</p>
<div className="flex gap-2 mt-2">
<span className="text-[9px] bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">BSI DE</span>
<span className="text-[9px] bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">OVH FR</span>
<span className="text-[9px] bg-blue-500/15 text-blue-300 px-2 py-0.5 rounded-full">{de ? 'Fix oder flexibel' : 'Fixed or flexible'}</span>
</div>
</div>
@@ -90,32 +90,38 @@ export default function RegulatoryLandscapeSlide({ lang }: RegulatoryLandscapeSl
{/* Matrix */}
<FadeInView delay={0.5}>
<GlassCard hover={false} className="p-4 overflow-x-auto">
{/* Category Legend */}
<div className="flex flex-wrap gap-3 mb-4 justify-center">
{CATEGORIES.map((cat) => (
<div key={cat.id} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: cat.color }} />
<span className="text-[10px] text-white/50">{categoryLabels[cat.id]}</span>
</div>
))}
</div>
{/* Matrix Grid */}
<div className="space-y-1.5">
{/* Header row */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '140px repeat(8, 1fr) 50px' }}>
<div className="text-[9px] text-white/30 uppercase tracking-wider pl-1">
{/* Staggered header rows — odd columns on top row, even on bottom */}
<div className="grid items-end gap-1" style={{ gridTemplateColumns: '140px repeat(8, 1fr) 70px' }}>
<div className="text-[9px] text-white/70 uppercase tracking-wider pl-1 font-semibold">
{lang === 'de' ? 'Branche' : 'Industry'}
</div>
{CATEGORIES.map((cat) => {
const CatIcon = cat.icon
return (
<div key={cat.id} className="flex justify-center">
<CatIcon className="w-3.5 h-3.5 opacity-50" style={{ color: cat.color }} />
</div>
)
})}
<div className="text-[9px] text-white/30 text-center">#</div>
{CATEGORIES.map((cat, idx) => (
<div key={cat.id} className="text-center">
{idx % 2 === 0 ? (
<span className="text-[8px] font-semibold uppercase tracking-wider" style={{ color: cat.color }}>
{categoryLabels[cat.id]}
</span>
) : null}
</div>
))}
<div className="text-[8px] text-indigo-400 text-center font-semibold uppercase tracking-wider">
{lang === 'de' ? 'Regulatorien' : 'Regulations'}
</div>
</div>
<div className="grid items-start gap-1" style={{ gridTemplateColumns: '140px repeat(8, 1fr) 70px' }}>
<div />
{CATEGORIES.map((cat, idx) => (
<div key={cat.id} className="text-center">
{idx % 2 === 1 ? (
<span className="text-[8px] font-semibold uppercase tracking-wider" style={{ color: cat.color }}>
{categoryLabels[cat.id]}
</span>
) : null}
</div>
))}
<div />
</div>
{/* Industry rows */}
@@ -125,7 +131,7 @@ export default function RegulatoryLandscapeSlide({ lang }: RegulatoryLandscapeSl
<div
key={industry.id}
className="grid items-center gap-1 py-1.5 rounded-lg hover:bg-white/[0.04] transition-colors"
style={{ gridTemplateColumns: '140px repeat(8, 1fr) 50px' }}
style={{ gridTemplateColumns: '140px repeat(8, 1fr) 70px' }}
>
<div className="flex items-center gap-2 pl-1">
<Icon className="w-3.5 h-3.5 text-white/40" />
+82 -62
View File
@@ -3,7 +3,7 @@
import { motion } from 'framer-motion'
import { Language, PitchTeamMember } from '@/lib/types'
import { t } from '@/lib/i18n'
import { User, Linkedin } from 'lucide-react'
import { User, Linkedin, Github } from 'lucide-react'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import Image from 'next/image'
@@ -13,89 +13,109 @@ interface TeamSlideProps {
team: PitchTeamMember[]
}
function equityDisplay(pct: number | string | null | undefined): string {
const n = Number(pct)
if (!Number.isFinite(n)) return '—'
return Number.isInteger(n) ? `${n}%` : `${n.toFixed(1)}%`
}
function detectProfileLink(url: string | null | undefined): { icon: typeof Linkedin | typeof Github; label: string } | null {
if (!url) return null
if (url.includes('github.com')) return { icon: Github, label: 'GitHub' }
if (url.includes('linkedin.com')) return { icon: Linkedin, label: 'LinkedIn' }
return { icon: Linkedin, label: 'Profile' }
}
export default function TeamSlide({ lang, team }: TeamSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<FadeInView className="text-center mb-8">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.team.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.team.subtitle}</p>
</FadeInView>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{team.map((member, idx) => (
<motion.div
key={member.id}
initial={{ opacity: 0, x: idx === 0 ? -40 : 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + idx * 0.2, duration: 0.6 }}
className="bg-white/[0.08] backdrop-blur-xl border border-white/10 rounded-3xl p-8"
>
<div className="flex items-start gap-5">
{/* Avatar — Foto oder Fallback */}
{member.photo_url ? (
<div className="w-20 h-20 rounded-2xl overflow-hidden shrink-0 shadow-lg">
<Image
src={member.photo_url}
alt={member.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600
flex items-center justify-center shrink-0 shadow-lg">
<User className="w-10 h-10 text-white" />
</div>
)}
<div className="grid md:grid-cols-2 gap-6 max-w-5xl mx-auto items-stretch">
{team.map((member, idx) => {
const link = detectProfileLink(member.linkedin_url)
const LinkIcon = link?.icon
return (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + idx * 0.15, duration: 0.5 }}
className="bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] rounded-2xl p-6 flex flex-col hover:border-indigo-500/20 transition-colors"
>
{/* Header: avatar + name + role */}
<div className="flex items-center gap-4 mb-5">
{member.photo_url ? (
<div className="w-16 h-16 rounded-2xl overflow-hidden shrink-0 shadow-lg">
<Image
src={member.photo_url}
alt={member.name}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/20">
<User className="w-8 h-8 text-white" />
</div>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-bold text-white">{member.name}</h3>
{member.linkedin_url && (
<a
href={member.linkedin_url}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-white/30 hover:text-[#0A66C2] transition-colors"
title="LinkedIn"
>
<Linkedin className="w-4 h-4" />
</a>
)}
</div>
<p className="text-indigo-400 text-sm font-medium mb-3">
{lang === 'de' ? member.role_de : member.role_en}
</p>
<p className="text-sm text-white/50 leading-relaxed mb-4">
{lang === 'de' ? member.bio_de : member.bio_en}
</p>
{/* Equity */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-white/40">{i.team.equity}:</span>
<span className="text-sm font-bold text-white">{member.equity_pct}%</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<h3 className="text-xl font-bold text-white truncate">{member.name}</h3>
{link && LinkIcon && (
<a
href={member.linkedin_url!}
target="_blank"
rel="noopener noreferrer"
className="text-white/30 hover:text-indigo-300 transition-colors"
title={link.label}
>
<LinkIcon className="w-4 h-4" />
</a>
)}
</div>
<p className="text-indigo-400 text-sm font-medium">
{lang === 'de' ? member.role_de : member.role_en}
</p>
</div>
{/* Expertise Tags */}
<div className="flex flex-wrap gap-1.5">
{/* Equity pill in top-right */}
<div className="text-right shrink-0">
<div className="text-[10px] uppercase tracking-wider text-white/30">{i.team.equity}</div>
<div className="text-base font-bold text-white tabular-nums">{equityDisplay(member.equity_pct)}</div>
</div>
</div>
{/* Bio */}
<p className="text-sm text-white/60 leading-relaxed mb-5 flex-1">
{lang === 'de' ? member.bio_de : member.bio_en}
</p>
{/* Expertise tags */}
{(member.expertise || []).length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-white/[0.06]">
{(member.expertise || []).map((skill, sidx) => (
<span
key={sidx}
className="text-xs px-2.5 py-1 rounded-full bg-indigo-500/10 text-indigo-300 border border-indigo-500/20"
className="text-[11px] px-2.5 py-1 rounded-full bg-indigo-500/10 text-indigo-300 border border-indigo-500/20"
>
{skill}
</span>
))}
</div>
</div>
</div>
</motion.div>
))}
)}
</motion.div>
)
})}
</div>
</div>
)
@@ -3,6 +3,7 @@
import { motion } from 'framer-motion'
import { Language, PitchFunding } from '@/lib/types'
import { t } from '@/lib/i18n'
import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import AnimatedCounter from '../ui/AnimatedCounter'
@@ -147,6 +148,7 @@ export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
</div>
</GlassCard>
</FadeInView>
<ProjectionFooter lang={lang} />
</div>
)
}
+160
View File
@@ -0,0 +1,160 @@
'use client'
import { Language } from '@/lib/types'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import {
FileCheck,
Code,
Zap,
Shield,
GitPullRequest,
ArrowLeftRight,
} from 'lucide-react'
interface USPSlideProps {
lang: Language
}
export default function USPSlide({ lang }: USPSlideProps) {
const de = lang === 'de'
const subtitle = de
? 'Die erste Plattform, die Compliance-Dokumente und tatsächliche Code-Umsetzung verbindet'
: 'The first platform that connects compliance documents with actual code implementation'
const complianceItems = de
? ['DSGVO-Dokumente', 'Audit-Management', 'RFQ-Anforderungen', 'CE-Bewertungen']
: ['GDPR documents', 'Audit management', 'RFQ requirements', 'CE assessments']
const codeItems = de
? ['SAST / DAST / SBOM', 'Pentesting', 'Issue-Tracker', 'Auto-Fixes']
: ['SAST / DAST / SBOM', 'Pentesting', 'Issue tracker', 'Auto-fixes']
const capabilities = [
{
icon: GitPullRequest,
color: 'text-indigo-400',
label: de ? 'RFQ-Prüfung' : 'RFQ Verification',
desc: de
? 'Kunden-Anforderungsdokumente werden automatisiert gegen die aktuelle Source-Code-Umsetzung 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.',
},
{
icon: ArrowLeftRight,
color: 'text-purple-400',
label: de ? 'Bidirektional' : 'Bidirectional',
desc: 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.',
},
{
icon: Zap,
color: 'text-amber-400',
label: de ? 'Prozess-Compliance' : 'Process Compliance',
desc: 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.',
},
{
icon: Shield,
color: 'text-emerald-400',
label: de ? 'Kontinuierlich' : 'Continuous',
desc: 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.',
},
]
return (
<div>
<FadeInView className="text-center mb-8">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>USP</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-3xl mx-auto">{subtitle}</p>
</FadeInView>
<FadeInView delay={0.2}>
<div className="relative max-w-6xl mx-auto" style={{ height: '580px' }}>
{/* CENTER: Large circle */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" style={{ width: '440px', height: '440px' }}>
<div className="absolute inset-0 rounded-full border-2 border-dashed border-indigo-500/20 animate-[spin_20s_linear_infinite]" />
<div className="absolute inset-4 rounded-full border border-white/[0.06] bg-white/[0.015]" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-xl shadow-indigo-500/30">
<span className="text-3xl font-black text-white">&#x221E;</span>
</div>
</div>
<div className="absolute left-7 top-1/2 -translate-y-1/2 w-[120px] z-10">
<div className="flex items-center gap-1.5 mb-2">
<FileCheck className="w-4 h-4 text-indigo-400" />
<span className="text-sm font-bold text-indigo-400">Compliance</span>
</div>
<ul className="space-y-1.5">
{complianceItems.map((item, idx) => (
<li key={idx} className="flex items-center gap-1.5 text-sm text-white/50">
<span className="w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0" />
{item}
</li>
))}
</ul>
</div>
<div className="absolute right-7 top-1/2 -translate-y-1/2 w-[120px] z-10">
<div className="flex items-center gap-1.5 mb-2">
<Code className="w-4 h-4 text-purple-400" />
<span className="text-sm font-bold text-purple-400">Code</span>
</div>
<ul className="space-y-1.5">
{codeItems.map((item, idx) => (
<li key={idx} className="flex items-center gap-1.5 text-sm text-white/50">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 shrink-0" />
{item}
</li>
))}
</ul>
</div>
<div className="absolute top-[12%] left-[12%] text-indigo-400/60 text-lg z-20">&#x25C0;</div>
<div className="absolute top-[12%] right-[12%] text-purple-400/60 text-lg z-20">&#x25B6;</div>
<div className="absolute bottom-[12%] left-[12%] text-amber-400/60 text-lg z-20">&#x25C0;</div>
<div className="absolute bottom-[12%] right-[12%] text-emerald-400/60 text-lg z-20">&#x25B6;</div>
</div>
{/* 4 CORNER CARDS */}
{capabilities.map((cap, idx) => {
const Icon = cap.icon
const posClass = idx === 0 ? 'top-0 left-0'
: idx === 1 ? 'top-0 right-0'
: idx === 2 ? 'bottom-0 left-0'
: 'bottom-0 right-0'
return (
<div key={idx} className={`absolute ${posClass} w-[290px]`}>
<GlassCard hover={false} className="p-4" delay={0}>
<div className="flex items-center gap-2 mb-2">
<Icon className={`w-5 h-5 ${cap.color}`} />
<h3 className={`text-base font-bold ${cap.color}`}>{cap.label}</h3>
</div>
<p className="text-sm text-white/50 leading-relaxed">{cap.desc}</p>
</GlassCard>
</div>
)
})}
{/* SVG connection lines */}
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0" preserveAspectRatio="none" viewBox="0 0 100 100">
<line x1="30" y1="25" x2="22" y2="15" stroke="rgba(99,102,241,0.15)" strokeWidth="0.3" strokeDasharray="1 1" />
<line x1="70" y1="25" x2="78" y2="15" stroke="rgba(168,85,247,0.15)" strokeWidth="0.3" strokeDasharray="1 1" />
<line x1="30" y1="75" x2="22" y2="85" stroke="rgba(245,158,11,0.15)" strokeWidth="0.3" strokeDasharray="1 1" />
<line x1="70" y1="75" x2="78" y2="85" stroke="rgba(16,185,129,0.15)" strokeWidth="0.3" strokeDasharray="1 1" />
</svg>
</div>
</FadeInView>
</div>
)
}
@@ -0,0 +1,20 @@
'use client'
import { Language } from '@/lib/types'
interface ProjectionFooterProps {
lang: Language
}
export default function ProjectionFooter({ lang }: ProjectionFooterProps) {
const de = lang === 'de'
return (
<div className="mt-3 pt-2 border-t border-white/5">
<p className="text-[9px] text-white/20 text-center italic">
{de
? 'Alle Finanzdaten sind Planzahlen und stellen keine Garantie für künftige Ergebnisse dar (Stand: Q2 2026)'
: 'All financial data are projections and do not constitute a guarantee of future results (as of: Q2 2026)'}
</p>
</div>
)
}
+42 -38
View File
@@ -13,12 +13,13 @@ const translations = {
'Cover',
'Das Problem',
'Die Lösung',
'USP',
'Regulatorische Landschaft',
'Modularer Baukasten',
'So funktioniert\'s',
'Markt',
'Geschäftsmodell',
'Traction',
'Meilensteine',
'Wettbewerb',
'Team',
'Finanzen',
@@ -36,6 +37,7 @@ const translations = {
'Anhang: Strategie',
'Anhang: Finanzplan',
'Glossar',
'Rechtlicher Hinweis',
],
executiveSummary: {
title: 'Executive Summary',
@@ -43,12 +45,12 @@ const translations = {
problem: 'Das Problem',
problemText: 'Unternehmen insbesondere im Maschinenbau stehen vor einem strategischen Dilemma: Um wettbewerbsfähig zu bleiben, müssen sie KI einsetzen. Gleichzeitig können oder wollen sie keine US-basierten KI-Anbieter in ihre sensibelsten Systeme integrieren. Wer auf US-SaaS verzichtet, verliert den Anschluss an die KI-Transformation. Wer sie nutzt, riskiert den Abfluss kritischer Daten und regulatorische Unsicherheit. Parallel dazu werden über 30.000 Unternehmen in Deutschland durch neue EU-Regulierungen wie AI Act, Data Act, CRA und NIS2 massiv belastet unabhängig von ihrer Größe oder digitalen Reife. Das Ergebnis: Entscheidungsblockade statt Innovation.',
solution: 'Unsere Lösung',
solutionText: 'Breakpilot ersetzt punktuelle Audits durch kontinuierliche, automatisierte Compliance und Security. Bei jeder Code-Änderung werden SAST, DAST, SBOM und Pentests automatisch ausgeführt. VVT, TOMs, DSFA, Löschfristen und CE-Risikobeurteilungen werden fortlaufend generiert. Audit-Abweichungen End-to-End: Rollen, Fristen, Tickets, Nachweise, Eskalation bis zur GF. Nahtlose Integration in bestehende Workflows (z.\u00a0B. Jira). BSI-Cloud DE oder OVH FR. Ergebnis: kontinuierliche Compliance statt punktueller Prüfungen.',
solutionText: 'Breakpilot ersetzt punktuelle Audits durch kontinuierliche, automatisierte Compliance und Security. Bei jeder Code-Änderung werden SAST, DAST, SBOM und Pentests automatisch ausgeführt. VVT, TOMs, DSFA, Löschfristen und CE-Risikobeurteilungen werden fortlaufend generiert. Audit-Abweichungen End-to-End: Rollen, Fristen, Tickets, Nachweise, Eskalation bis zur GF. Nahtlose Integration in bestehende Workflows über den Issue-Tracker deiner Wahl. BSI-Cloud DE. Ergebnis: kontinuierliche Compliance statt punktueller Prüfungen.',
roi: 'Kundenersparnis',
roiText: 'Kunden zahlen ca. 50.000 EUR/Jahr und sparen: 30.000 EUR Pentests, 20.000 EUR CE-Beurteilungen, Auditmanager-Kosten und Strafrisiko. ROI ab Tag 1.',
market: 'Markt',
businessModel: 'Geschäftsmodell',
businessModelText: 'Kunden zahlen ~50.000 EUR/Jahr und sparen 50.000+ EUR (Pentests, CE-Beurteilungen, Auditmanager). ROI ab Tag 1. BSI-Cloud DE oder OVH FR.',
businessModelText: 'Kunden zahlen ~50.000 EUR/Jahr und sparen 50.000+ EUR (Pentests, CE-Beurteilungen, Auditmanager). ROI ab Tag 1. BSI-Cloud DE.',
keyMetrics: 'Kennzahlen',
documents: 'Originaldokumente',
controls: 'Prüfaspekte',
@@ -63,31 +65,31 @@ const translations = {
uspText: 'Einzige Plattform mit kontinuierlicher Code-Security, automatischer Compliance-Dokumentation und CE-Software-Risikobeurteilung — auf deutscher oder französischer Cloud.',
},
cover: {
tagline: 'Compliance & Code-Security für den Maschinenbau',
tagline: 'Compliance & Code-Security',
subtitle: 'Pre-Seed · Q4 2026',
cta: 'Pitch starten',
},
problem: {
title: 'Das Problem',
subtitle: 'Maschinenbauer wollen KI nutzen — aber nicht um den Preis ihrer Datensouveränität',
subtitle: 'Deutsche und europäische Unternehmen wollen KI nutzen — aber nicht um den Preis ihrer Datensouveränität',
cards: [
{
title: 'KI-Dilemma',
stat: 'Abgehängt',
desc: 'Maschinenbauer wollen KI nutzen, aber keinen Microsoft Copilot oder Claude auf ihr Herzstück lassen. Angst vor Datenmissbrauch durch US-Konzerne ist real. Wer US-SaaS meidet, bleibt von der KI-Revolution abgeschnitten.',
desc: 'Produzierende Unternehmen brauchen KI, um wettbewerbsfähig zu bleiben. Aber Microsoft Copilot, ChatGPT oder Claude an den eigenen Quellcode und die Konstruktionsdaten zu lassen, kommt für die meisten nicht in Frage. Wer auf US-KI verzichtet, verliert den Anschluss. Wer sie nutzt, riskiert seine Datensouveränität.',
},
{
title: 'Patriots Act',
title: 'Patriot Act + FISA 702',
stat: 'Kein Schutz',
desc: 'Die Alternative: Alles zu AWS, Google oder Microsoft schieben. Aber selbst europäische Server der US-Player können über den Patriots Act abgesaugt werden. Deutsche KMU sitzen in der Falle.',
desc: 'Selbst wer EU-Server bei AWS, Google oder Microsoft bucht, ist nicht geschützt. US-Gesetze wie FISA 702 und der Cloud Act gelten extraterritorial — US-Behörden können auf Daten zugreifen, egal wo der Server steht. Das Schrems-II-Urteil des EuGH hat das bestätigt.',
},
{
title: 'Regulierungs-Tsunami',
stat: '50.000+ EUR/Jahr',
desc: 'AI Act, NIS2, CRA, DSGVO, Maschinenverordnung — 5+ Gesetze gleichzeitig. Pentests und CE-Zertifizierungen kosten 50.000+ EUR/Jahr, prüfen aber nur einmal. KMU mit 10-500 MA haben weder Personal noch Budget.',
stat: 'Nicht tragbar',
desc: 'Seit 2024 greifen AI Act, NIS2 und Cyber Resilience Act — zusätzlich zu DSGVO, Data Act, Maschinenverordnung und Lieferkettengesetz. Europäische Unternehmen tragen damit Compliance-Kosten, die US- und Asien-Konkurrenten nicht haben. Gleichzeitig steigen Kosten durch Rohstoffengpässe und geopolitische Krisen. KMU können das nicht mehr stemmen.',
},
],
quote: 'Maschinenbauer brauchen eine KI-Lösung, die in Deutschland läuft, ihren Code schützt und Compliance automatisiert — ohne ihre Daten an US-Konzerne zu geben.',
quote: 'Produzierende Unternehmen brauchen eine KI-Lösung, die in Europa läuft, ihren Code schützt und Compliance automatisiert — ohne ihre Daten an US-Konzerne zu geben.',
},
solution: {
title: 'Die Lösung',
@@ -95,7 +97,7 @@ const translations = {
pillars: [
{
title: 'Kontinuierliche Code-Security',
desc: 'SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung — nicht einmal im Jahr. Findings direkt als Jira-Tickets mit Implementierungsvorschlägen. 30.000+ EUR/Jahr Pentest-Kosten gespart.',
desc: 'SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung — nicht einmal im Jahr. Findings direkt als Tickets im Issue-Tracker deiner Wahl, mit Implementierungsvorschlägen. 15.000+ EUR pro Jahr und Anwendung an Pentest-Kosten gespart.',
icon: 'scan',
},
{
@@ -105,7 +107,7 @@ const translations = {
},
{
title: 'Deutsche Cloud, volle Integration',
desc: 'BSI-zertifizierte Cloud in DE oder OVH in FR. Jitsi (Video), Matrix (Chat), KI-Aufgabenerstellung aus Audio. Keine US-SaaS im Source Code. Optional Mac Mini für maximale Privacy.',
desc: 'BSI-zertifizierte Cloud in Deutschland. Live-Support über Jitsi (Video) und Matrix (Chat). Keine US-SaaS im Source Code. Optional Mac Mini/Studio für maximale Privacy.',
icon: 'server',
},
],
@@ -148,7 +150,7 @@ const translations = {
pricingTitle: 'Pricing nach Unternehmensgröße',
pricingSubtitle: 'Mitarbeiterbasiert — validiert am Markt',
cloud: 'Cloud-Lösung (Standard)',
cloudDesc: 'BSI-Cloud DE oder OVH FR. Für alle Unternehmensgrößen.',
cloudDesc: 'BSI-Cloud DE. Für alle Unternehmensgrößen.',
privacy: 'Privacy-Hardware (optional)',
privacyDesc: 'Mac Mini / Studio für Kleinstunternehmen (<10 MA) mit absolutem Privacy-Bedarf.',
},
@@ -158,7 +160,7 @@ const translations = {
steps: [
{
title: 'Cloud-Vertrag abschließen',
desc: 'BSI-zertifizierte Cloud in Deutschland oder OVH in Frankreich. Fixe oder flexible Kosten. Für Kleinstunternehmen optional: Mac Mini vorkonfiguriert.',
desc: 'BSI-zertifizierte Cloud in Deutschland. Fixe oder flexible Kosten.',
},
{
title: 'Code-Repos verbinden',
@@ -176,13 +178,13 @@ const translations = {
},
market: {
title: 'Marktchance',
subtitle: 'Der Maschinenbau braucht Compliance & Code-Security',
subtitle: 'Compliance & Code-Security für produzierende Unternehmen',
tam: 'TAM',
sam: 'SAM',
som: 'SOM',
tamLabel: 'Total Addressable Market',
samLabel: 'Serviceable Addressable Market',
somLabel: 'Serviceable Obtainable Market',
somLabel: 'Serviceable Obtainable Market (nur Maschinen- und Anlagenbauer als Kernmarkt betrachtet)',
source: 'Quelle',
growth: 'Wachstum p.a.',
},
@@ -204,8 +206,8 @@ const translations = {
savingsTotal: 'Ersparnis',
},
traction: {
title: 'Traction & Meilensteine',
subtitle: 'Unser bisheriger Fortschritt',
title: 'Meilensteine',
subtitle: 'Was wir bereits erreicht haben — und was als Nächstes kommt',
completed: 'Abgeschlossen',
inProgress: 'In Arbeit',
planned: 'Geplant',
@@ -308,12 +310,13 @@ const translations = {
'Cover',
'The Problem',
'The Solution',
'USP',
'Regulatory Landscape',
'Modular Toolkit',
'How It Works',
'Market',
'Business Model',
'Traction',
'Milestones',
'Competition',
'Team',
'Financials',
@@ -331,6 +334,7 @@ const translations = {
'Appendix: Strategy',
'Appendix: Financial Plan',
'Glossary',
'Legal Notice',
],
executiveSummary: {
title: 'Executive Summary',
@@ -338,12 +342,12 @@ const translations = {
problem: 'The Problem',
problemText: 'Many companies, especially in manufacturing, want to use AI — but refuse to let American AI providers access their core IP. Those avoiding US SaaS are cut off from the AI revolution. Those using these providers accept that data may be processed in the US. Meanwhile, new EU regulations (AI Act, Data Act, CRA, NIS2 etc.) affect over 30,000 companies in Germany alone — regardless of size.',
solution: 'Our Solution',
solutionText: 'Continuous code security instead of annual spot checks: SAST, DAST, SBOM, pentesting on every change. RoPA, TOMs, DPIA, retention policies, CE risk assessment automatically. Audit deviations end-to-end: roles, deadlines, tickets, evidence, escalation to management. Jira integration. Academy. BSI cloud DE or OVH FR.',
solutionText: 'Continuous code security instead of annual spot checks: SAST, DAST, SBOM, pentesting on every change. RoPA, TOMs, DPIA, retention policies, CE risk assessment automatically. Audit deviations end-to-end: roles, deadlines, tickets, evidence, escalation to management. Issue tracker of your choice. Academy. BSI cloud DE.',
roi: 'Customer Savings',
roiText: 'Customers pay ~EUR 50,000/year and save: EUR 30,000 pentests, EUR 20,000 CE assessments, audit manager costs and penalty risk. ROI from day 1.',
market: 'Market',
businessModel: 'Business Model',
businessModelText: 'Customers pay ~EUR 50,000/year and save EUR 50,000+ (pentests, CE assessments, audit managers). ROI from day 1. BSI cloud DE or OVH FR.',
businessModelText: 'Customers pay ~EUR 50,000/year and save EUR 50,000+ (pentests, CE assessments, audit managers). ROI from day 1. BSI cloud DE.',
keyMetrics: 'Key Metrics',
documents: 'Original Documents',
controls: 'Audit Aspects',
@@ -358,31 +362,31 @@ const translations = {
uspText: 'Only platform with continuous code security, automatic compliance documentation and CE software risk assessment — on German or French cloud.',
},
cover: {
tagline: 'Compliance & Code Security for Machine Manufacturers',
tagline: 'Compliance & Code Security',
subtitle: 'Pre-Seed · Q4 2026',
cta: 'Start Pitch',
},
problem: {
title: 'The Problem',
subtitle: 'Machine manufacturers want AI — but not at the cost of their data sovereignty',
subtitle: 'German and European companies want AI — but not at the cost of their data sovereignty',
cards: [
{
title: 'AI Dilemma',
stat: 'Left Behind',
desc: 'Machine manufacturers want to use AI but refuse Microsoft Copilot or Claude on their core IP. Fear of data misuse by US corporations is real. Those avoiding US SaaS are cut off from the AI revolution.',
desc: 'Manufacturing companies need AI to stay competitive. But letting Microsoft Copilot, ChatGPT or Claude access their source code and engineering data is out of the question for most. Those avoiding US AI fall behind. Those using it risk their data sovereignty.',
},
{
title: 'Patriot Act',
title: 'Patriot Act + FISA 702',
stat: 'No Protection',
desc: 'The alternative: push everything to AWS, Google or Microsoft. But even European servers of US players can be accessed via the Patriot Act. German SMEs are trapped.',
desc: 'Even booking EU servers at AWS, Google or Microsoft offers no protection. US laws like FISA 702 and the Cloud Act apply extraterritorially — US authorities can access data regardless of server location. The Schrems II ruling by the CJEU confirmed this.',
},
{
title: 'Regulation Tsunami',
stat: 'EUR 50,000+/yr',
desc: 'AI Act, NIS2, CRA, GDPR, Machinery Regulation — 5+ laws simultaneously. Pentests and CE certifications cost EUR 50,000+/year but only check once. SMEs with 10-500 employees lack staff and budget.',
stat: 'Unsustainable',
desc: 'Since 2024, the AI Act, NIS2 and Cyber Resilience Act apply — on top of GDPR, Data Act, Machinery Regulation and Supply Chain Act. European companies bear compliance costs that US and Asian competitors do not face. At the same time, costs rise from raw material shortages and geopolitical crises. SMEs can no longer handle this alone.',
},
],
quote: 'Machine manufacturers need an AI solution that runs in Germany, protects their code and automates compliance — without giving their data to US corporations.',
quote: 'Manufacturing companies need an AI solution that runs in Europe, protects their code and automates compliance — without giving their data to US corporations.',
},
solution: {
title: 'The Solution',
@@ -390,7 +394,7 @@ const translations = {
pillars: [
{
title: 'Continuous Code Security',
desc: 'SAST, DAST, SBOM and pentesting on every code change — not once a year. Findings as Jira tickets with implementation suggestions. EUR 30,000+/year pentest costs saved.',
desc: 'SAST, DAST, SBOM and pentesting on every code change — not once a year. Findings as tickets in the issue tracker of your choice, with implementation suggestions. EUR 15,000+ per year per application in pentest costs saved.',
icon: 'scan',
},
{
@@ -400,7 +404,7 @@ const translations = {
},
{
title: 'German Cloud, Full Integration',
desc: 'BSI-certified cloud in DE or OVH in FR. Jitsi (video), Matrix (chat), AI task creation from audio. No US SaaS in source code. Optional Mac Mini for maximum privacy.',
desc: 'BSI-certified cloud in Germany. Live support via Jitsi (video) and Matrix (chat). No US SaaS in source code. Optional Mac Mini/Studio for maximum privacy.',
icon: 'server',
},
],
@@ -443,7 +447,7 @@ const translations = {
pricingTitle: 'Pricing by Company Size',
pricingSubtitle: 'Employee-based — market validated',
cloud: 'Cloud Solution (Standard)',
cloudDesc: 'BSI cloud DE or OVH FR. For all company sizes.',
cloudDesc: 'BSI cloud DE. For all company sizes.',
privacy: 'Privacy Hardware (optional)',
privacyDesc: 'Mac Mini / Studio for micro businesses (<10 employees) with absolute privacy needs.',
},
@@ -453,7 +457,7 @@ const translations = {
steps: [
{
title: 'Sign Cloud Contract',
desc: 'BSI-certified cloud in Germany or OVH in France. Fixed or flexible costs. For micro businesses optionally: pre-configured Mac Mini.',
desc: 'BSI-certified cloud in Germany. Fixed or flexible costs.',
},
{
title: 'Connect Code Repos',
@@ -471,13 +475,13 @@ const translations = {
},
market: {
title: 'Market Opportunity',
subtitle: 'Machine manufacturing needs compliance & code security',
subtitle: 'Compliance & code security for manufacturing companies',
tam: 'TAM',
sam: 'SAM',
som: 'SOM',
tamLabel: 'Total Addressable Market',
samLabel: 'Serviceable Addressable Market',
somLabel: 'Serviceable Obtainable Market',
somLabel: 'Serviceable Obtainable Market (machine & plant manufacturers as core market only)',
source: 'Source',
growth: 'Growth p.a.',
},
@@ -499,8 +503,8 @@ const translations = {
savingsTotal: 'Total Savings',
},
traction: {
title: 'Traction & Milestones',
subtitle: 'Our progress so far',
title: 'Milestones',
subtitle: 'What we have achieved — and what comes next',
completed: 'Completed',
inProgress: 'In Progress',
planned: 'Planned',
+84 -32
View File
@@ -17,8 +17,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['module', 'modules', 'funktionen', 'features', 'umfang', 'scope', 'wieviele', 'how many'],
question_de: 'Welche Module hat die Plattform?',
question_en: 'What modules does the platform have?',
answer_de: '65+ Compliance-Module in zwei Saeulen: Unternehmens-Compliance — alle Datenkategorien, Verarbeitungen, Dienstleister und Auftragsverarbeiter erfassen, automatische Dokumentenerstellung (AGB, DSE, Cookie Banner, Nutzungsbedingungen), DSR-Prozess, Dokumentenversionierung, Rollenverwaltung, Academy und Schulungen, Audit und Nachweismanagement. Code/CE-Seite — Repo-Scanning (SAST + DAST), Pentesting, CE Software Risk Assessment (IACE), automatische SBOM-Generierung, Jira/Atlassian-Integration mit konkreten Code-Änderungsvorschlaegen.',
answer_en: 'modular compliance platform in two pillars: Company-side compliance — capture all data categories, processes, providers and processors, auto-generate legal documents (Terms of Service, Privacy Policy, Cookie Banner, Terms of Use), DSR process, document versioning, role management, academy and training, audit and evidence management. Code/CE side — repo scanning (SAST + DAST), pentesting, CE Software Risk Assessment (IACE), automatic SBOM generation, Jira/Atlassian integration with specific code change suggestions.',
answer_de: '65+ Compliance-Module in zwei Saeulen: Unternehmens-Compliance — alle Datenkategorien, Verarbeitungen, Dienstleister und Auftragsverarbeiter erfassen, automatische Dokumentenerstellung (AGB, DSE, Cookie Banner, Nutzungsbedingungen), DSR-Prozess, Dokumentenversionierung, Rollenverwaltung, Academy und Schulungen, Audit und Nachweismanagement. Code/CE-Seite — Repo-Scanning (SAST + DAST), Pentesting, CE Software Risk Assessment (IACE), automatische SBOM-Generierung, Integration in den Issue-Tracker deiner Wahl mit konkreten Code-Änderungsvorschlaegen.',
answer_en: 'modular compliance platform in two pillars: Company-side compliance — capture all data categories, processes, providers and processors, auto-generate legal documents (Terms of Service, Privacy Policy, Cookie Banner, Terms of Use), DSR process, document versioning, role management, academy and training, audit and evidence management. Code/CE side — repo scanning (SAST + DAST), pentesting, CE Software Risk Assessment (IACE), automatic SBOM generation, integration with the issue tracker of your choice with specific code change suggestions.',
goto_slide: 'solution',
priority: 8,
},
@@ -37,8 +37,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['code', 'ce', 'scanning', 'sast', 'dast', 'sbom', 'iace', 'firmware', 'repo', 'repository', 'devsecops'],
question_de: 'Wie funktioniert die Code- und CE-Compliance?',
question_en: 'How does code and CE compliance work?',
answer_de: 'Die Code/CE-Seite umfasst vollständiges Repo-Scanning mit SAST und DAST, automatisiertes Pentesting sowie CE Software Risk Assessment (IACE) — auch für Elektronik-Produkte. Gefundene Schwachstellen werden direkt als Jira/Atlassian-Tickets mit konkreten Code-Änderungsvorschlaegen erstellt. Die KI implementiert Fixes automatisch. Zusaetzlich wird eine vollständige SBOM (Software Bill of Materials) generiert.',
answer_en: 'The code/CE side includes full repo scanning with SAST and DAST, automated pentesting, and CE Software Risk Assessment (IACE) — also for electronics products. Findings are directly created as Jira/Atlassian tickets with specific code change suggestions. The AI implements fixes automatically. Additionally, a complete SBOM (Software Bill of Materials) is generated.',
answer_de: 'Die Code/CE-Seite umfasst vollständiges Repo-Scanning mit SAST und DAST, automatisiertes Pentesting sowie CE Software Risk Assessment (IACE) — auch für Elektronik-Produkte. Gefundene Schwachstellen werden direkt als Tickets im Issue-Tracker deiner Wahl mit konkreten Code-Änderungsvorschlaegen erstellt. Die KI implementiert Fixes automatisch. Zusaetzlich wird eine vollständige SBOM (Software Bill of Materials) generiert.',
answer_en: 'The code/CE side includes full repo scanning with SAST and DAST, automated pentesting, and CE Software Risk Assessment (IACE) — also for electronics products. Findings are directly created as tickets in the issue tracker of your choice with specific code change suggestions. The AI implements fixes automatically. Additionally, a complete SBOM (Software Bill of Materials) is generated.',
goto_slide: 'how-it-works',
priority: 8,
},
@@ -58,8 +58,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['llm', 'modell', 'model', 'ki', 'ai', 'kuenstliche intelligenz', 'artificial intelligence', 'welches', 'which', '1000b'],
question_de: 'Welches KI-Modell nutzt ihr?',
question_en: 'Which AI model do you use?',
answer_de: 'Unser Haupt-LLM hat 1000 Milliarden Parameter und läuft ausschliesslich bei BSI-zertifizierten Hostern in Deutschland und Frankreich — SysEleven, OVH und Hetzner. Keine amerikanischen Anbieter, keine Daten verlassen die Server. Jeder Kunde bekommt einen isolierten Namespace. Für die Mac Mini/Studio Variante setzen wir kleinere Modelle ein, die lokale Dokumentenverarbeitung und RAG-Abfragen uebernehmen.',
answer_en: 'Our main LLM has 1,000 billion parameters and runs exclusively at BSI-certified hosters in Germany and France — SysEleven, OVH and Hetzner. No American providers, no data leaves the servers. Each customer gets an isolated namespace. For the Mac Mini/Studio variant, we use smaller models for local document processing and RAG queries.',
answer_de: 'Unser Haupt-LLM hat 1000 Milliarden Parameter und läuft ausschliesslich bei BSI-zertifizierten Hostern in Deutschland und Frankreich — SysEleven und Hetzner. Keine amerikanischen Anbieter, keine Daten verlassen die Server. Jeder Kunde bekommt einen isolierten Namespace. Für die Mac Mini/Studio Variante setzen wir kleinere Modelle ein, die lokale Dokumentenverarbeitung und RAG-Abfragen uebernehmen.',
answer_en: 'Our main LLM has 1,000 billion parameters and runs exclusively at BSI-certified hosters in Germany and France — SysEleven and Hetzner. No American providers, no data leaves the servers. Each customer gets an isolated namespace. For the Mac Mini/Studio variant, we use smaller models for local document processing and RAG queries.',
goto_slide: 'product',
priority: 8,
},
@@ -68,8 +68,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['hosting', 'europa', 'europe', 'eu', 'deutschland', 'germany', 'frankreich', 'france', 'bsi', 'syseleven', 'ovh', 'hetzner', 'souveraenitaet', 'sovereignty', 'american', 'us', 'usa'],
question_de: 'Wo werden die Daten gehostet?',
question_en: 'Where is the data hosted?',
answer_de: 'Ausschliesslich in Deutschland und Frankreich bei BSI-zertifizierten Anbietern: SysEleven, OVH und Hetzner. Keine amerikanischen Cloud-Provider, kein AWS, kein Azure, kein GCP. Die Daten verlassen niemals die europäischen Server. Jeder Kunde erhaelt einen vollständig isolierten Namespace — es gibt keine geteilte Datenverarbeitung zwischen Mandanten.',
answer_en: 'Exclusively in Germany and France at BSI-certified providers: SysEleven, OVH and Hetzner. No American cloud providers, no AWS, no Azure, no GCP. Data never leaves the European servers. Each customer receives a fully isolated namespace — there is no shared data processing between tenants.',
answer_de: 'Ausschliesslich in Deutschland und Frankreich bei BSI-zertifizierten Anbietern: SysEleven und Hetzner. Keine amerikanischen Cloud-Provider, kein AWS, kein Azure, kein GCP. Die Daten verlassen niemals die europäischen Server. Jeder Kunde erhaelt einen vollständig isolierten Namespace — es gibt keine geteilte Datenverarbeitung zwischen Mandanten.',
answer_en: 'Exclusively in Germany and France at BSI-certified providers: SysEleven and Hetzner. No American cloud providers, no AWS, no Azure, no GCP. Data never leaves the European servers. Each customer receives a fully isolated namespace — there is no shared data processing between tenants.',
goto_slide: 'annex-architecture',
priority: 9,
},
@@ -103,12 +103,12 @@ export const PRESENTER_FAQ: FAQEntry[] = [
priority: 8,
},
{
id: 'tech-jira-integration',
keywords: ['jira', 'atlassian', 'integration', 'ticket', 'tickets', 'issue', 'issues', 'fix', 'fixes', 'code change'],
question_de: 'Wie funktioniert die Jira/Atlassian-Integration?',
question_en: 'How does the Jira/Atlassian integration work?',
answer_de: 'Wenn das Code-Scanning (SAST/DAST) oder Pentesting Schwachstellen findet, erstellt die Plattform automatisch Jira-Tickets mit exakten Code-Änderungsvorschlaegen welche Datei, welche Zeile, welcher Fix. Die KI kann den Fix auch direkt implementieren. So schliesst sich der Kreis von Finding zu Fix vollständig automatisiert.',
answer_en: 'When code scanning (SAST/DAST) or pentesting finds vulnerabilities, the platform automatically creates Jira tickets with exact code change suggestions which file, which line, which fix. The AI can also implement the fix directly. This closes the loop from finding to fix fully automated.',
id: 'tech-issue-tracker-integration',
keywords: ['jira', 'atlassian', 'linear', 'issue tracker', 'integration', 'ticket', 'tickets', 'issue', 'issues', 'fix', 'fixes', 'code change'],
question_de: 'Wie funktioniert die Issue-Tracker-Integration?',
question_en: 'How does the issue tracker integration work?',
answer_de: 'Wenn das Code-Scanning (SAST/DAST) oder Pentesting Schwachstellen findet, erstellt die Plattform automatisch Tickets im Issue-Tracker deiner Wahl — Jira, GitLab, Linear, Gitea — mit exakten Code-Änderungsvorschlaegen, welche Datei, welche Zeile, welcher Fix. Die KI kann den Fix auch direkt implementieren. So schliesst sich der Kreis von Finding zu Fix vollständig automatisiert.',
answer_en: 'When code scanning (SAST/DAST) or pentesting finds vulnerabilities, the platform automatically creates tickets in the issue tracker of your choice — Jira, GitLab, Linear, Gitea — with exact code change suggestions: which file, which line, which fix. The AI can also implement the fix directly. This closes the loop from finding to fix fully automated.',
goto_slide: 'how-it-works',
priority: 7,
},
@@ -137,8 +137,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['meeting', 'recorder', 'aufzeichnung', 'recording', 'nvidia', 'transkription', 'transcription', 'protokoll', 'minutes', 'tasks', 'aufgaben'],
question_de: 'Wie funktioniert der Meeting-Recorder?',
question_en: 'How does the meeting recorder work?',
answer_de: 'Unser NVIDIA-basierter Meeting-Recorder zeichnet Besprechungen auf, transkribiert sie automatisch und extrahiert Aufgaben und Beschluesse. Diese werden direkt als Jira-Tasks angelegt — inklusive Zuweisung, Deadline und Kontext aus dem Meeting. So geht nichts mehr verloren.',
answer_en: 'Our NVIDIA-based meeting recorder records meetings, transcribes them automatically and extracts tasks and decisions. These are directly created as Jira tasks — including assignment, deadline and context from the meeting. Nothing gets lost.',
answer_de: 'Unser NVIDIA-basierter Meeting-Recorder zeichnet Besprechungen auf, transkribiert sie automatisch und extrahiert Aufgaben und Beschluesse. Diese werden direkt als Tasks im Issue-Tracker deiner Wahl angelegt — inklusive Zuweisung, Deadline und Kontext aus dem Meeting. So geht nichts mehr verloren.',
answer_en: 'Our NVIDIA-based meeting recorder records meetings, transcribes them automatically and extracts tasks and decisions. These are directly created as tasks in the issue tracker of your choice — including assignment, deadline and context from the meeting. Nothing gets lost.',
priority: 6,
},
{
@@ -210,8 +210,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['proliance', 'dataguard', 'heydata', 'vergleich', 'comparison', 'versus'],
question_de: 'Warum können Proliance und DataGuard das nicht?',
question_en: 'Why can\'t Proliance and DataGuard do this?',
answer_de: 'Proliance, DataGuard und heyData fokussieren auf organisatorische DSGVO-Compliance — Verarbeitungsverzeichnisse, Datenschutzerklaerungen, Schulungen. Keiner bietet Code-Scanning, CE-Risikobewertung, Pentesting oder automatische Jira-Integration mit Code-Fixes. Sie machen das Unternehmen teilweise compliant, aber nicht die Produkte. Und keiner hostet die KI ausschliesslich in Europa.',
answer_en: 'Proliance, DataGuard and heyData focus on organizational GDPR compliance — records of processing, privacy policies, training. None offer code scanning, CE risk assessment, pentesting or automatic Jira integration with code fixes. They make the company partially compliant, but not the products. And none host the AI exclusively in Europe.',
answer_de: 'Proliance, DataGuard und heyData fokussieren auf organisatorische DSGVO-Compliance — Verarbeitungsverzeichnisse, Datenschutzerklaerungen, Schulungen. Keiner bietet Code-Scanning, CE-Risikobewertung, Pentesting oder automatische Issue-Tracker-Integration mit Code-Fixes. Sie machen das Unternehmen teilweise compliant, aber nicht die Produkte. Und keiner hostet die KI ausschliesslich in Europa.',
answer_en: 'Proliance, DataGuard and heyData focus on organizational GDPR compliance — records of processing, privacy policies, training. None offer code scanning, CE risk assessment, pentesting or automatic issue tracker integration with code fixes. They make the company partially compliant, but not the products. And none host the AI exclusively in Europe.',
goto_slide: 'competition',
priority: 8,
},
@@ -232,8 +232,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['cloud', 'mini', 'studio', 'unterschied', 'difference', 'vergleich', 'comparison', 'tier', 'tiers', 'varianten', 'variants'],
question_de: 'Cloud oder Hardware?',
question_en: 'Cloud or hardware?',
answer_de: 'Cloud ist der Standard: BSI-zertifiziert in Deutschland oder OVH in Frankreich. Fixe oder flexible Kosten, modulare Module, Jira-Integration, Matrix/Jitsi, Compliance-LLM. Für Kleinstunternehmen unter 10 Mitarbeitern mit absolutem Privacy-Bedarf bieten wir optional einen vorkonfigurierten Mac Mini mit kleineren lokalen LLMs.',
answer_en: 'Cloud is the standard: BSI-certified in Germany or OVH in France. Fixed or flexible costs, modular modules, Jira integration, Matrix/Jitsi, compliance LLM. For micro businesses under 10 employees with absolute privacy needs, we optionally offer a pre-configured Mac Mini with smaller local LLMs.',
answer_de: 'Cloud ist der Standard: BSI-zertifiziert in Deutschland oder Frankreich. Fixe oder flexible Kosten, modulare Module, Integration in den Issue-Tracker deiner Wahl, Matrix/Jitsi, Compliance-LLM. Für Kleinstunternehmen unter 10 Mitarbeitern mit absolutem Privacy-Bedarf bieten wir optional einen vorkonfigurierten Mac Mini oder Mac Studio mit kleineren lokalen LLMs.',
answer_en: 'Cloud is the standard: BSI-certified in Germany or France. Fixed or flexible costs, modular modules, integration with the issue tracker of your choice, Matrix/Jitsi, compliance LLM. For micro businesses under 10 employees with absolute privacy needs, we optionally offer a pre-configured Mac Mini or Mac Studio with smaller local LLMs.',
goto_slide: 'product',
priority: 8,
},
@@ -252,8 +252,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['unit economics', 'marge', 'margin', 'ltv', 'cac', 'amortisation', 'amortization'],
question_de: 'Wie sind die Unit Economics?',
question_en: 'What are the unit economics?',
answer_de: 'Bruttomarge über 80% beim Cloud-Produkt — keine Hardware-Kosten. Die AI-First Architektur haelt die operativen Kosten pro Kunde extrem niedrig. Europaeisches Hosting bei SysEleven/OVH/Hetzner ist deutlich günstiger als AWS/Azure. LTV/CAC verbessert sich durch die Plattform-Stickiness: modulare Plattform schaffen natürlichen Lock-in.',
answer_en: 'Gross margin above 80% on the cloud product — no hardware costs. The AI-first architecture keeps operational costs per customer extremely low. European hosting at SysEleven/OVH/Hetzner is significantly cheaper than AWS/Azure. LTV/CAC improves through platform stickiness: all modules create natural lock-in.',
answer_de: 'Bruttomarge über 80% beim Cloud-Produkt — keine Hardware-Kosten. Die AI-First Architektur haelt die operativen Kosten pro Kunde extrem niedrig. Europaeisches Hosting bei SysEleven und Hetzner ist deutlich günstiger als AWS/Azure. LTV/CAC verbessert sich durch die Plattform-Stickiness: modulare Plattform schaffen natürlichen Lock-in.',
answer_en: 'Gross margin above 80% on the cloud product — no hardware costs. The AI-first architecture keeps operational costs per customer extremely low. European hosting at SysEleven and Hetzner is significantly cheaper than AWS/Azure. LTV/CAC improves through platform stickiness: all modules create natural lock-in.',
goto_slide: 'business-model',
priority: 7,
},
@@ -296,8 +296,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['use of funds', 'wofuer', 'what for', 'verwendung', 'allocation', 'mittelverwendung'],
question_de: 'Wofür wird das Kapital verwendet?',
question_en: 'What will the capital be used for?',
answer_de: 'Vier Bereiche: 1) Cloud-Infrastruktur — Skalierung der europäischen Server-Kapazitaet bei SysEleven/OVH/Hetzner. 2) Engineering — weitere Module und Integrationen. 3) Vertrieb — Pilotkunden bei Maschinenbauern, CE-Zertifizierern und produzierenden Unternehmen. 4) Reserve — regulatorische Anforderungen und Working Capital.',
answer_en: 'Four areas: 1) Cloud infrastructure — scaling European server capacity at SysEleven/OVH/Hetzner. 2) Engineering — additional modules and integrations. 3) Sales — pilot customers among machine builders, CE certifiers and producing companies. 4) Reserve — regulatory requirements and working capital.',
answer_de: 'Vier Bereiche: 1) Cloud-Infrastruktur — Skalierung der europäischen Server-Kapazitaet bei SysEleven/Hetzner. 2) Engineering — weitere Module und Integrationen. 3) Vertrieb — Pilotkunden bei Maschinenbauern, CE-Zertifizierern und produzierenden Unternehmen. 4) Reserve — regulatorische Anforderungen und Working Capital.',
answer_en: 'Four areas: 1) Cloud infrastructure — scaling European server capacity at SysEleven/Hetzner. 2) Engineering — additional modules and integrations. 3) Sales — pilot customers among machine builders, CE certifiers and producing companies. 4) Reserve — regulatory requirements and working capital.',
goto_slide: 'the-ask',
priority: 8,
},
@@ -328,8 +328,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['cra', 'cyber resilience', 'cyber resilience act', 'firmware', 'produktsicherheit', 'product security'],
question_de: 'Was ist der Cyber Resilience Act?',
question_en: 'What is the Cyber Resilience Act?',
answer_de: 'Der CRA verpflichtet Hersteller, Software in ihren Produkten abzusichern — über den gesamten Lebenszyklus. Für produzierende Unternehmen mit Firmware, embedded Software und Elektronik bedeutet das: Vulnerability Management, SBOM, Incident Reporting. Unsere Plattform automatisiert all das — vom Repo-Scan bis zum Jira-Ticket mit Code-Fix.',
answer_en: 'The CRA obligates manufacturers to secure software in their products — throughout the entire lifecycle. For producing companies with firmware, embedded software and electronics this means: vulnerability management, SBOM, incident reporting. Our platform automates all of this — from repo scan to Jira ticket with code fix.',
answer_de: 'Der CRA verpflichtet Hersteller, Software in ihren Produkten abzusichern — über den gesamten Lebenszyklus. Für produzierende Unternehmen mit Firmware, embedded Software und Elektronik bedeutet das: Vulnerability Management, SBOM, Incident Reporting. Unsere Plattform automatisiert all das — vom Repo-Scan bis zum Ticket im Issue-Tracker deiner Wahl mit Code-Fix.',
answer_en: 'The CRA obligates manufacturers to secure software in their products — throughout the entire lifecycle. For producing companies with firmware, embedded software and electronics this means: vulnerability management, SBOM, incident reporting. Our platform automates all of this — from repo scan to ticket in your issue tracker with code fix.',
goto_slide: 'annex-regulatory',
priority: 7,
},
@@ -384,8 +384,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['pentesting', 'penetrationstest', 'penetration test', 'security testing', 'pentests'],
question_de: 'Wie funktioniert das Pentesting?',
question_en: 'How does pentesting work?',
answer_de: 'Pentesting ist fester Bestandteil unserer Code/CE-Saule. Automatisierte Penetrationstests laufen gegen die Kunden-Anwendungen und -Infrastruktur. Gefundene Schwachstellen werden automatisch als Jira-Tickets mit konkreten Code-Änderungsvorschlaegen erstellt — die KI kann die Fixes direkt implementieren. So wird der gesamte Zyklus von Finding bis Fix automatisiert.',
answer_en: 'Pentesting is a core part of our code/CE pillar. Automated penetration tests run against customer applications and infrastructure. Found vulnerabilities are automatically created as Jira tickets with specific code change suggestions — the AI can implement fixes directly. This automates the entire cycle from finding to fix.',
answer_de: 'Pentesting ist fester Bestandteil unserer Code/CE-Saule. Automatisierte Penetrationstests laufen gegen die Kunden-Anwendungen und -Infrastruktur. Gefundene Schwachstellen werden automatisch als Tickets im Issue-Tracker deiner Wahl mit konkreten Code-Änderungsvorschlaegen erstellt — die KI kann die Fixes direkt implementieren. So wird der gesamte Zyklus von Finding bis Fix automatisiert.',
answer_en: 'Pentesting is a core part of our code/CE pillar. Automated penetration tests run against customer applications and infrastructure. Found vulnerabilities are automatically created as tickets in the issue tracker of your choice with specific code change suggestions — the AI can implement fixes directly. This automates the entire cycle from finding to fix.',
goto_slide: 'how-it-works',
priority: 7,
},
@@ -396,8 +396,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['demo', 'test', 'testen', 'try', 'ausprobieren', 'live', 'showcase'],
question_de: 'Kann ich eine Demo sehen?',
question_en: 'Can I see a demo?',
answer_de: 'Sehr gerne! Wir zeigen Ihnen die Cloud-Plattform live — inklusive Code-Scanning, Compliance-Module, KI-Analyse, Jira-Integration und den Meeting-Recorder. Ein Cloud-Demo-Zugang kann sofort bereitgestellt werden. Kontaktieren Sie uns für einen Termin.',
answer_en: 'Absolutely! We will show you the cloud platform live — including code scanning, compliance modules, AI analysis, Jira integration and the meeting recorder. A cloud demo access can be provisioned immediately. Contact us for an appointment.',
answer_de: 'Sehr gerne! Wir zeigen Ihnen die Cloud-Plattform live — inklusive Code-Scanning, Compliance-Module, KI-Analyse, Issue-Tracker-Integration und den Meeting-Recorder. Ein Cloud-Demo-Zugang kann sofort bereitgestellt werden. Kontaktieren Sie uns für einen Termin.',
answer_en: 'Absolutely! We will show you the cloud platform live — including code scanning, compliance modules, AI analysis, issue tracker integration and the meeting recorder. A cloud demo access can be provisioned immediately. Contact us for an appointment.',
priority: 6,
},
{
@@ -530,14 +530,66 @@ export const PRESENTER_FAQ: FAQEntry[] = [
priority: 7,
},
// === FISA 702 / DRITTLANDTRANSFER / EU-SOUVERAENITAET ===
{
id: 'fisa-702-what',
keywords: ['fisa', 'fisa 702', 'surveillance', 'ueberwachung', 'us-zugriff', 'us zugriff', 'nsa', 'prism', 'upstream', 'foreign intelligence'],
question_de: 'Was ist FISA 702 und warum ist das relevant?',
question_en: 'What is FISA 702 and why does it matter?',
answer_de: 'FISA 702 ist ein US-Ueberwachungsgesetz, das US-Behoerden erlaubt, gezielt Daten von Nicht-US-Personen im Ausland zu ueberwachen — ohne individuellen richterlichen Beschluss. Es gibt zwei Hauptprogramme: PRISM, bei dem US-Unternehmen wie Microsoft, Google und Amazon Daten an Behoerden herausgeben muessen, und Upstream, bei dem direkt auf Internet-Infrastruktur zugegriffen wird. Das betrifft jeden EU-Nutzer eines US-Dienstes. Genau deshalb wurde das Privacy Shield durch das Schrems-II-Urteil des EuGH gekippt. Fuer BreakPilot ist das hochrelevant: Jedes Unternehmen, das US-Cloud oder US-KI nutzt, hat ein strukturelles FISA-702-Risiko. Unsere EU-only Architektur eliminiert dieses Risiko vollstaendig.',
answer_en: 'FISA 702 is a US surveillance law that allows US authorities to target data of non-US persons abroad — without individual court orders. There are two main programs: PRISM, where US companies like Microsoft, Google and Amazon must hand over data to authorities, and Upstream, which directly taps internet infrastructure. This affects every EU user of a US service. This is exactly why the EU-US Privacy Shield was invalidated by the Schrems II ruling. For BreakPilot this is highly relevant: every company using US cloud or US AI has a structural FISA 702 risk. Our EU-only architecture eliminates this risk entirely.',
goto_slide: 'annex-regulatory',
priority: 9,
},
{
id: 'fisa-eu-cloud-myth',
keywords: ['eu cloud', 'eu-cloud', 'eu region', 'serverstandort', 'aws eu', 'azure eu', 'google eu', 'rechenzentrum', 'data center', 'frankfurt', 'ireland'],
question_de: 'Schuetzt eine EU-Cloud-Region bei US-Anbietern vor FISA 702?',
question_en: 'Does an EU cloud region at US providers protect against FISA 702?',
answer_de: 'Nein. Das ist einer der groessten Irrtuemer im Markt. Wenn der Cloud-Anbieter ein US-Unternehmen ist — also AWS, Microsoft Azure oder Google Cloud — dann unterliegt er US-Recht, egal wo der Server steht. FISA 702 und der Cloud Act gelten extraterritorial. Das bedeutet: Ein Server in Frankfurt bei AWS ist rechtlich genauso exponiert wie einer in Virginia. Unternehmen zahlen mehr fuer die EU-Region und wiegen sich in falscher Sicherheit. BreakPilot setzt deshalb konsequent auf EU-Anbieter ohne US-Muttergesellschaft — SysEleven und Hetzner, beide BSI-konform und ohne FISA-Exposition.',
answer_en: 'No. This is one of the biggest misconceptions in the market. If the cloud provider is a US company — AWS, Microsoft Azure or Google Cloud — it is subject to US law regardless of where the server is located. FISA 702 and the Cloud Act apply extraterritorially. A server in Frankfurt at AWS is legally just as exposed as one in Virginia. Companies pay more for the EU region and create a false sense of security. BreakPilot therefore exclusively uses EU providers without US parent companies — SysEleven and Hetzner, both BSI-compliant and without FISA exposure.',
goto_slide: 'annex-architecture',
priority: 9,
},
{
id: 'fisa-dsfa-contradiction',
keywords: ['dsfa', 'dpia', 'risikoakzeptanz', 'risk acceptance', 'restrisiko', 'residual risk', 'widerspruch', 'contradiction', 'schoenreden', 'fake compliance'],
question_de: 'Warum reicht eine DSFA alleine nicht aus, um US-Cloud-Risiken zu bewaeltigen?',
question_en: 'Why is a DPIA alone not enough to manage US cloud risks?',
answer_de: 'Das ist ein zentraler Widerspruch im aktuellen Compliance-Markt. Unternehmen erstellen eine Datenschutz-Folgenabschaetzung, dokumentieren das FISA-702-Risiko, treffen Massnahmen wie Verschluesselung und EU-Region — und akzeptieren dann das Restrisiko. Faktisch sagen sie damit: Wir wissen, dass US-Behoerden zugreifen koennten, und nehmen das in Kauf. Das ist kein Sicherheitsnachweis, sondern ein Rechtfertigungsinstrument. Die DSGVO erlaubt das ueber den risikobasierten Ansatz, aber es bleibt eine bewusste Risikoakzeptanz fuer ein technisch nicht loesbares Problem. BreakPilot geht einen fundamental anderen Weg: Wir eliminieren das Restrisiko strukturell, statt es zu dokumentieren. Kein US-Anbieter, keine FISA-Exposition, kein Restrisiko. Das ist der Unterschied zwischen Compliance simulieren und Compliance loesen.',
answer_en: 'This is a central contradiction in the current compliance market. Companies create a Data Protection Impact Assessment, document the FISA 702 risk, implement measures like encryption and EU region — and then accept the residual risk. In effect they are saying: we know US authorities could access the data, and we accept that. This is not a security proof but a justification instrument. GDPR allows this through the risk-based approach, but it remains a deliberate risk acceptance for a technically unsolvable problem. BreakPilot takes a fundamentally different approach: we structurally eliminate the residual risk instead of documenting it. No US provider, no FISA exposure, no residual risk. That is the difference between simulating compliance and solving compliance.',
goto_slide: 'annex-regulatory',
priority: 8,
},
{
id: 'fisa-market-opportunity',
keywords: ['marktchance', 'market opportunity', 'warum eu', 'why eu', 'eu-first', 'souveraenitaet', 'sovereignty', 'datensouveraenitaet', 'digital sovereignty', 'schrems', 'privacy shield'],
question_de: 'Warum ist FISA 702 eine Marktchance fuer BreakPilot?',
question_en: 'Why is FISA 702 a market opportunity for BreakPilot?',
answer_de: 'FISA 702 ist der zentrale Grund, warum EU-US-Datentransfers rechtlich schwierig sind, warum AI-Compliance ein riesiger Markt ist und warum unser EU-first-Ansatz strategischen Vorteil hat. Das Schrems-II-Urteil hat gezeigt, dass politische Loesungen wie Privacy Shield scheitern. Das neue EU-US Data Privacy Framework wird von Experten als naechstes Schrems-III angesehen. Jedes Mal wenn ein solches Abkommen kippt, stehen tausende Unternehmen vor dem Problem, ihre US-Dienste nicht mehr rechtskonform nutzen zu koennen. BreakPilot ist davon nicht betroffen — unsere Architektur ist strukturell unabhaengig von US-Recht. Das ist kein Nice-to-have, sondern konkrete Risikovermeidung fuer unsere Kunden.',
answer_en: 'FISA 702 is the central reason why EU-US data transfers are legally difficult, why AI compliance is a huge market, and why our EU-first approach has strategic advantage. The Schrems II ruling showed that political solutions like Privacy Shield fail. The new EU-US Data Privacy Framework is seen by experts as the next Schrems III. Every time such an agreement falls, thousands of companies face the problem of no longer being able to use their US services in compliance. BreakPilot is not affected — our architecture is structurally independent of US law. This is not a nice-to-have but concrete risk avoidance for our customers.',
goto_slide: 'market',
priority: 8,
},
{
id: 'fisa-breakpilot-architecture',
keywords: ['architektur', 'architecture', 'eu-only', 'kein us', 'no us', 'syseleven', 'hetzner', 'bsi', 'hosting', 'wo gehostet', 'where hosted'],
question_de: 'Wie schuetzt sich BreakPilot konkret gegen FISA 702?',
question_en: 'How does BreakPilot specifically protect against FISA 702?',
answer_de: 'Unsere gesamte Infrastruktur laeuft auf EU-Anbietern ohne US-Muttergesellschaft. Wir nutzen SysEleven und Hetzner — beide BSI-konform und in Deutschland. Unsere LLMs laufen lokal oder auf BSI-zertifizierten EU-Servern. Keine personenbezogenen Daten verlassen jemals die EU. Wir setzen keine US-KI-Dienste wie OpenAI, Anthropic Cloud oder Google AI ein. Isolierte Namespaces pro Kunde stellen sicher, dass Daten strikt getrennt sind. Die Schluesselhoheit liegt vollstaendig beim Kunden. Damit ist FISA 702 fuer unsere Kunden schlicht nicht anwendbar — es gibt keinen US-Anbieter in der Kette, der zur Herausgabe verpflichtet werden koennte.',
answer_en: 'Our entire infrastructure runs on EU providers without US parent companies. We use SysEleven and Hetzner — both BSI-compliant and located in Germany. Our LLMs run locally or on BSI-certified EU servers. No personal data ever leaves the EU. We do not use US AI services like OpenAI, Anthropic Cloud or Google AI. Isolated namespaces per customer ensure strict data separation. Key management is entirely under customer control. This makes FISA 702 simply inapplicable for our customers — there is no US provider in the chain that could be compelled to hand over data.',
goto_slide: 'annex-architecture',
priority: 9,
},
// === MODULE ===
{
id: 'modules-overview',
keywords: ['module', 'modules', 'baukasten', 'toolkit', '12 module', 'welche module', 'which modules', 'funktionen', 'features', 'leistungen'],
question_de: 'Welche 12 Module bietet ihr an?',
question_en: 'Which 12 modules do you offer?',
answer_de: 'Unsere Plattform besteht aus zwölf Modulen, die Kunden einzeln oder als Gesamtpaket nutzen können. Den Kern bildet das Code-Security-Modul mit SAST, DAST, SBOM-Analysen und kontinuierlichem Pentesting bei jeder Code-Änderung. Dazu kommt die CE-Software-Risikobeurteilung, die Hersteller für die CE-Kennzeichnung ihrer Produkte brauchen. Für die laufende Compliance-Dokumentation erstellen wir automatisch Verarbeitungsverzeichnisse, technisch-organisatorische Maßnahmen, Datenschutz-Folgenabschätzungen und Löschfristen. Der Audit Manager verwaltet Haupt- und Nebenabweichungen nach Audits vollständig End-to-End mit Stichtagen, Tickets und Eskalation. Darüber hinaus bieten wir Module für Betroffenenrechte, Einwilligungsmanagement, Notfallpläne bei Datenschutzvorfällen und einen Cookie-Generator. Das Compliance LLM ist ein eigenes Sprachmodell für Text und Audio, das sicher in der EU gehostet wird. Die Academy bietet Online-Schulungen für Geschäftsführung und Mitarbeiter. Abgerundet wird das Ganze durch die Integration in bestehende Kundenprozesse wie Jira und eine sichere Kommunikationslösung mit Chat, Video und einem KI-Assistenten für automatische Besprechungsnotizen.',
answer_en: 'Our platform consists of twelve modules that customers can use individually or as a complete package. The core is the code security module with SAST, DAST, SBOM analysis and continuous pentesting on every code change. Then there is the CE software risk assessment that manufacturers need for CE marking their products. For ongoing compliance documentation, we automatically generate records of processing activities, technical and organizational measures, data protection impact assessments and retention schedules. The audit manager handles major and minor deviations after audits completely end-to-end with deadlines, tickets and escalation. Beyond that, we offer modules for data subject rights, consent management, incident response for data breaches and a cookie generator. The compliance LLM is a dedicated language model for text and audio, securely hosted in the EU. The academy provides online training for management and employees. Everything is rounded off by integration into existing customer processes like Jira and a secure communication solution with chat, video and an AI assistant for automatic meeting notes.',
answer_de: 'Unsere Plattform besteht aus zwölf Modulen, die Kunden einzeln oder als Gesamtpaket nutzen können. Den Kern bildet das Code-Security-Modul mit SAST, DAST, SBOM-Analysen und kontinuierlichem Pentesting bei jeder Code-Änderung. Dazu kommt die CE-Software-Risikobeurteilung, die Hersteller für die CE-Kennzeichnung ihrer Produkte brauchen. Für die laufende Compliance-Dokumentation erstellen wir automatisch Verarbeitungsverzeichnisse, technisch-organisatorische Maßnahmen, Datenschutz-Folgenabschätzungen und Löschfristen. Der Audit Manager verwaltet Haupt- und Nebenabweichungen nach Audits vollständig End-to-End mit Stichtagen, Tickets und Eskalation. Darüber hinaus bieten wir Module für Betroffenenrechte, Einwilligungsmanagement, Notfallpläne bei Datenschutzvorfällen und einen Cookie-Generator. Das Compliance LLM ist ein eigenes Sprachmodell für Text und Audio, das sicher in der EU gehostet wird. Die Academy bietet Online-Schulungen für Geschäftsführung und Mitarbeiter. Abgerundet wird das Ganze durch die Integration in bestehende Kundenprozesse wie Jira, GitLab, Linear oder Gitea und eine sichere Kommunikationslösung mit Chat, Video und einem KI-Assistenten für automatische Besprechungsnotizen.',
answer_en: 'Our platform consists of twelve modules that customers can use individually or as a complete package. The core is the code security module with SAST, DAST, SBOM analysis and continuous pentesting on every code change. Then there is the CE software risk assessment that manufacturers need for CE marking their products. For ongoing compliance documentation, we automatically generate records of processing activities, technical and organizational measures, data protection impact assessments and retention schedules. The audit manager handles major and minor deviations after audits completely end-to-end with deadlines, tickets and escalation. Beyond that, we offer modules for data subject rights, consent management, incident response for data breaches and a cookie generator. The compliance LLM is a dedicated language model for text and audio, securely hosted in the EU. The academy provides online training for management and employees. Everything is rounded off by integration into existing customer processes like Jira, GitLab, Linear or Gitea and a secure communication solution with chat, video and an AI assistant for automatic meeting notes.',
goto_slide: 'product',
priority: 9,
},
+14 -14
View File
@@ -92,8 +92,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
duration: 70,
paragraphs: [
{
text_de: 'Unsere Lösung: Kontinuierliche Software-Compliance statt jährlicher Stichproben. SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung. Findings landen direkt als Jira-Tickets mit konkreten Implementierungsvorschlägen.',
text_en: 'Our solution: Continuous software compliance instead of annual spot checks. SAST, DAST, SBOM and pentesting on every code change. Findings land directly as Jira tickets with concrete implementation suggestions.',
text_de: 'Unsere Lösung: Kontinuierliche Software-Compliance statt jährlicher Stichproben. SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung. Findings landen direkt als Tickets im Issue-Tracker deiner Wahl mit konkreten Implementierungsvorschlägen.',
text_en: 'Our solution: Continuous software compliance instead of annual spot checks. SAST, DAST, SBOM and pentesting on every code change. Findings land directly as tickets in the issue tracker of your choice with concrete implementation suggestions.',
pause_after: 2000,
},
{
@@ -102,8 +102,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
pause_after: 2500,
},
{
text_de: 'Die Plattform läuft auf einer BSI-zertifizierten Cloud in Deutschland oder OVH in Frankreich. Jitsi für Video, Matrix für Chat, KI-Aufgabenerstellung aus Audiomitschnitten direkt in Kundensysteme. Keine US-SaaS im Source Code.',
text_en: 'The platform runs on a BSI-certified cloud in Germany or OVH in France. Jitsi for video, Matrix for chat, AI task creation from audio recordings directly into customer systems. No US SaaS in source code.',
text_de: 'Die Plattform läuft auf einer BSI-zertifizierten Cloud in Deutschland oder Frankreich. Live-Support über Jitsi (Video) und Matrix (Chat). Keine US-SaaS im Source Code.',
text_en: 'The platform runs on a BSI-certified cloud in Germany or France. Live support via Jitsi (video) and Matrix (chat). No US SaaS in source code.',
pause_after: 2500,
},
{
@@ -152,8 +152,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
pause_after: 2500,
},
{
text_de: 'Die Plattform läuft standardmäßig in der Cloud — BSI-zertifiziert in Deutschland oder OVH in Frankreich. Für Kleinstunternehmen unter 10 Mitarbeitern bieten wir optional einen vorkonfigurierten Mac Mini r absolute Privacy.',
text_en: 'The platform runs by default in the cloud — BSI-certified in Germany or OVH in France. For micro businesses under 10 employees, we optionally offer a pre-configured Mac Mini for absolute privacy.',
text_de: 'Die Plattform läuft standardmäßig in der Cloud — BSI-zertifiziert in Deutschland oder Frankreich. Für Kleinstunternehmen unter 10 Mitarbeitern bieten wir optional einen vorkonfigurierten Mac Mini oder Mac Studio fuer absolute Privacy.',
text_en: 'The platform runs by default in the cloud — BSI-certified in Germany or France. For micro businesses under 10 employees, we optionally offer a pre-configured Mac Mini or Mac Studio for absolute privacy.',
pause_after: 2000,
},
],
@@ -167,8 +167,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
duration: 50,
paragraphs: [
{
text_de: 'Schritt eins: Cloud-Vertrag abschließen. BSI-Cloud in Deutschland oder OVH in Frankreich — fixe oder flexible Kosten, keine US-Anbieter.',
text_en: 'Step one: sign a cloud contract. BSI cloud in Germany or OVH in France — fixed or flexible costs, no US providers.',
text_de: 'Schritt eins: Cloud-Vertrag abschließen. BSI-Cloud in Deutschland oder Frankreich — fixe oder flexible Kosten, keine US-Anbieter.',
text_en: 'Step one: sign a cloud contract. BSI cloud in Germany or France — fixed or flexible costs, no US providers.',
pause_after: 1500,
},
{
@@ -237,8 +237,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
pause_after: 2500,
},
{
text_de: 'Die Unit Economics: Bruttomarge über 80 Prozent. Cloud-native auf SysEleven, OVH und Hetzner — deutlich günstiger als AWS oder Azure. Mitarbeiterbasiertes Pricing, modular wählbar.',
text_en: 'The unit economics: gross margin above 80 percent. Cloud-native on SysEleven, OVH and Hetzner — significantly cheaper than AWS or Azure. Employee-based pricing, modular choice.',
text_de: 'Die Unit Economics: Bruttomarge über 80 Prozent. Cloud-native auf SysEleven und Hetzner — deutlich günstiger als AWS oder Azure. Mitarbeiterbasiertes Pricing, modular wählbar.',
text_en: 'The unit economics: gross margin above 80 percent. Cloud-native on SysEleven and Hetzner — significantly cheaper than AWS or Azure. Employee-based pricing, modular choice.',
pause_after: 1500,
},
],
@@ -342,8 +342,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
pause_after: 2000,
},
{
text_de: 'Infrastrukturkosten bleiben niedrig dank europäischer Provider. SysEleven, OVH und Hetzner kosten einen Bruchteil von AWS. Break-Even erreichen wir voraussichtlich Ende 2028.',
text_en: 'Infrastructure costs remain low thanks to European providers. SysEleven, OVH and Hetzner cost a fraction of AWS. We expect to reach break-even by end of 2028.',
text_de: 'Infrastrukturkosten bleiben niedrig dank europäischer Provider. SysEleven und Hetzner kosten einen Bruchteil von AWS. Break-Even erreichen wir voraussichtlich Ende 2028.',
text_en: 'Infrastructure costs remain low thanks to European providers. SysEleven and Hetzner cost a fraction of AWS. We expect to reach break-even by end of 2028.',
pause_after: 2000,
},
],
@@ -479,8 +479,8 @@ export const PRESENTER_SCRIPT: SlideScript[] = [
pause_after: 2000,
},
{
text_de: 'Infrastruktur: 100 Prozent EU-Cloud. PostgreSQL und Qdrant auf Hetzner, 120-Milliarden-Parameter-LLM auf OVH, 1000-Milliarden-Parameter-LLM auf SysEleven — BSI-zertifiziert. Keine US-Anbieter.',
text_en: 'Infrastructure: 100 percent EU cloud. PostgreSQL and Qdrant on Hetzner, 120 billion parameter LLM on OVH, 1 trillion parameter LLM on SysEleven — BSI certified. No US providers.',
text_de: 'Infrastruktur: 100 Prozent EU-Cloud. PostgreSQL und Qdrant auf Hetzner, LLMs auf SysEleven — BSI-zertifiziert. Keine US-Anbieter.',
text_en: 'Infrastructure: 100 percent EU cloud. PostgreSQL and Qdrant on Hetzner, LLMs on SysEleven — BSI certified. No US providers.',
pause_after: 1500,
},
],
+2
View File
@@ -6,6 +6,7 @@ export const SLIDE_ORDER: SlideId[] = [
'cover',
'problem',
'solution',
'usp',
'regulatory-landscape',
'product',
'how-it-works',
@@ -29,6 +30,7 @@ export const SLIDE_ORDER: SlideId[] = [
'annex-strategy',
'annex-finanzplan',
'annex-glossary',
'legal-disclaimer',
]
export const TOTAL_SLIDES = SLIDE_ORDER.length
+2
View File
@@ -227,6 +227,7 @@ export type SlideId =
| 'cover'
| 'problem'
| 'solution'
| 'usp'
| 'regulatory-landscape'
| 'product'
| 'how-it-works'
@@ -250,3 +251,4 @@ export type SlideId =
| 'annex-strategy'
| 'annex-finanzplan'
| 'annex-glossary'
| 'legal-disclaimer'
+102
View File
@@ -0,0 +1,102 @@
export interface FieldDiff {
key: string
before: unknown
after: unknown
}
export interface RowDiff {
status: 'added' | 'removed' | 'changed' | 'unchanged'
id?: string | number
fields: FieldDiff[]
before?: Record<string, unknown>
after?: Record<string, unknown>
}
export interface TableDiff {
tableName: string
rows: RowDiff[]
hasChanges: boolean
}
/**
* Diff two arrays of row objects. Matches rows by `id` field if present,
* otherwise by array position.
*/
export function diffTable(
tableName: string,
before: unknown[],
after: unknown[],
): TableDiff {
const beforeArr = (before || []) as Record<string, unknown>[]
const afterArr = (after || []) as Record<string, unknown>[]
const rows: RowDiff[] = []
// Build lookup by id if available
const hasIds = beforeArr.length > 0 && 'id' in (beforeArr[0] || {})
if (hasIds) {
const beforeMap = new Map(beforeArr.map(r => [String(r.id), r]))
const afterMap = new Map(afterArr.map(r => [String(r.id), r]))
const allIds = new Set([...beforeMap.keys(), ...afterMap.keys()])
for (const id of allIds) {
const b = beforeMap.get(id)
const a = afterMap.get(id)
if (!b && a) {
rows.push({ status: 'added', id: a.id as string, fields: [], after: a })
} else if (b && !a) {
rows.push({ status: 'removed', id: b.id as string, fields: [], before: b })
} else if (b && a) {
const fields = diffFields(b, a)
rows.push({
status: fields.length > 0 ? 'changed' : 'unchanged',
id: b.id as string,
fields,
before: b,
after: a,
})
}
}
} else {
// Positional comparison
const maxLen = Math.max(beforeArr.length, afterArr.length)
for (let i = 0; i < maxLen; i++) {
const b = beforeArr[i]
const a = afterArr[i]
if (!b && a) {
rows.push({ status: 'added', fields: [], after: a })
} else if (b && !a) {
rows.push({ status: 'removed', fields: [], before: b })
} else if (b && a) {
const fields = diffFields(b, a)
rows.push({
status: fields.length > 0 ? 'changed' : 'unchanged',
fields,
before: b,
after: a,
})
}
}
}
return {
tableName,
rows,
hasChanges: rows.some(r => r.status !== 'unchanged'),
}
}
function diffFields(before: Record<string, unknown>, after: Record<string, unknown>): FieldDiff[] {
const diffs: FieldDiff[] = []
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
for (const key of allKeys) {
const bVal = JSON.stringify(before[key] ?? null)
const aVal = JSON.stringify(after[key] ?? null)
if (bVal !== aVal) {
diffs.push({ key, before: before[key], after: after[key] })
}
}
return diffs
}
+76
View File
@@ -0,0 +1,76 @@
import pool from './db'
/**
* The 12 data tables tracked per version.
* Each maps to a pitch_version_data.table_name value.
*/
export const VERSION_TABLES = [
'company', 'team', 'financials', 'market', 'competitors',
'features', 'milestones', 'metrics', 'funding', 'products',
'fm_scenarios', 'fm_assumptions',
] as const
export type VersionTableName = typeof VERSION_TABLES[number]
/** Maps version table names to the actual DB table + ORDER BY */
const TABLE_QUERIES: Record<VersionTableName, string> = {
company: 'SELECT * FROM pitch_company LIMIT 1',
team: 'SELECT * FROM pitch_team ORDER BY sort_order',
financials: 'SELECT * FROM pitch_financials ORDER BY year',
market: 'SELECT * FROM pitch_market ORDER BY id',
competitors: 'SELECT * FROM pitch_competitors ORDER BY id',
features: 'SELECT * FROM pitch_features ORDER BY sort_order',
milestones: 'SELECT * FROM pitch_milestones ORDER BY sort_order',
metrics: 'SELECT * FROM pitch_metrics ORDER BY id',
funding: 'SELECT * FROM pitch_funding LIMIT 1',
products: 'SELECT * FROM pitch_products ORDER BY sort_order',
fm_scenarios: 'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name',
fm_assumptions: 'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order',
}
/**
* Snapshot all base tables into pitch_version_data for a given version.
*/
export async function snapshotBaseTables(versionId: string, adminId: string | null): Promise<void> {
const client = await pool.connect()
try {
for (const tableName of VERSION_TABLES) {
const { rows } = await client.query(TABLE_QUERIES[tableName])
await client.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (version_id, table_name) DO UPDATE SET data = $3, updated_at = NOW(), updated_by = $4`,
[versionId, tableName, JSON.stringify(rows), adminId],
)
}
} finally {
client.release()
}
}
/**
* Copy all version data from one version to another.
*/
export async function copyVersionData(fromVersionId: string, toVersionId: string, adminId: string | null): Promise<void> {
await pool.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
SELECT $1, table_name, data, $3
FROM pitch_version_data WHERE version_id = $2`,
[toVersionId, fromVersionId, adminId],
)
}
/**
* Load all version data as a map of table_name JSONB rows.
*/
export async function loadVersionData(versionId: string): Promise<Record<string, unknown[]>> {
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
const result: Record<string, unknown[]> = {}
for (const row of rows) {
result[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
return result
}
+123
View File
@@ -0,0 +1,123 @@
# BreakPilot Pitch MCP Server
MCP server that lets Claude Code directly manage pitch versions, invite investors, and assign versions — without touching the browser admin UI.
## What it does
11 tools exposed to Claude Code:
| Tool | Description |
|------|-------------|
| `list_versions` | List all pitch versions with status + investor counts |
| `create_version` | Create a draft (snapshot base tables or fork from parent) |
| `get_version` | Get full version detail with all 12 data table snapshots |
| `get_table_data` | Get one table's data (company, team, financials, market, etc.) |
| `update_table_data` | Replace a table's data in a draft version |
| `commit_version` | Lock a draft as immutable |
| `fork_version` | Create new draft by copying an existing version |
| `diff_versions` | Per-table diff between any two versions |
| `list_investors` | List all investors with stats + version assignments |
| `assign_version` | Assign a committed version to an investor |
| `invite_investor` | Send magic-link email to a new investor |
All actions go through the existing admin API at `pitch.breakpilot.com`, so they show up in the audit log.
## Setup
### 1. Build
```bash
cd pitch-deck/mcp-server
npm install
npm run build
```
### 2. Get the admin secret
The `PITCH_ADMIN_SECRET` is stored in orca secrets on the server. SSH in and retrieve it:
```bash
ssh breakpilot-infra-vm1
cat ~/orca/services/breakpilot-dsms/secrets.json | grep PITCH_ADMIN_SECRET
```
### 3. Configure
Edit `.mcp.json` in the **breakpilot-core** repo root (already created):
```json
{
"mcpServers": {
"pitch-versions": {
"command": "node",
"args": ["pitch-deck/mcp-server/dist/index.js"],
"env": {
"PITCH_API_URL": "https://pitch.breakpilot.com",
"PITCH_ADMIN_SECRET": "paste-your-secret-here"
}
}
}
}
```
> **Important:** `.mcp.json` contains a secret. It's already in `.gitignore` — never commit it.
### 4. Restart Claude Code
Exit and reopen Claude Code, or run `/mcp` to check it loaded:
```
/mcp
```
You should see `pitch-versions` listed with 11 tools.
## Usage examples
Just talk to Claude naturally:
- **"List all pitch versions"** → calls `list_versions`
- **"Create a new version called 'Series A Aggressive'"** → calls `create_version`
- **"Show me the company data from version X"** → calls `get_table_data`
- **"Update the company tagline to 'AI-Powered Compliance' in version X"** → calls `update_table_data`
- **"Commit version X"** → calls `commit_version`
- **"Fork version X into a new draft called 'Conservative'"** → calls `fork_version`
- **"Compare version X with version Y"** → calls `diff_versions`
- **"Assign version X to investor jane@vc.com"** → calls `assign_version`
- **"Invite john@fund.com from Big Fund"** → calls `invite_investor`
## Data tables
Each version stores 12 data tables as JSONB snapshots:
| Table | Content |
|-------|---------|
| `company` | Name, tagline (DE/EN), mission (DE/EN), website, HQ city |
| `team` | Members with roles, bios, equity, expertise (all bilingual) |
| `financials` | Year-by-year revenue, costs, MRR, ARR, customers, employees |
| `market` | TAM/SAM/SOM with values, growth rates, sources |
| `competitors` | Names, customer counts, pricing, strengths, weaknesses |
| `features` | Feature comparison matrix (BreakPilot vs competitors) |
| `milestones` | Timeline with dates, titles, descriptions, status (bilingual) |
| `metrics` | Key metrics with labels (bilingual) and values |
| `funding` | Round details, amount, instrument, use of funds breakdown |
| `products` | Product tiers with pricing, LLM specs, features (bilingual) |
| `fm_scenarios` | Financial model scenario names, colors, default flag |
| `fm_assumptions` | Per-scenario assumptions (growth rate, ARPU, churn, etc.) |
## Architecture
```
Claude Code ←stdio→ MCP Server ←HTTP→ pitch.breakpilot.com/api/admin/*
(local) (deployed on orca)
```
The MCP server is a thin HTTP client. All auth, validation, and audit logging happens on the server side. The bearer token authenticates as a CLI admin actor.
## Troubleshooting
**"PITCH_ADMIN_SECRET is required"** → The env var is missing in `.mcp.json`
**401 errors** → The secret is wrong or the pitch-deck container isn't running. Check: `curl -s -H "Authorization: Bearer YOUR_SECRET" https://pitch.breakpilot.com/api/admin/investors`
**MCP server not showing in `/mcp`** → Make sure you're in the `breakpilot-core` directory when you launch Claude Code (`.mcp.json` is project-scoped)
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "breakpilot-pitch-mcp",
"version": "1.0.0",
"description": "MCP server for managing BreakPilot pitch versions via Claude Code",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1"
},
"devDependencies": {
"typescript": "^5.7.2",
"@types/node": "^22.10.2"
}
}
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const API_URL = process.env.PITCH_API_URL || "https://pitch.breakpilot.com";
const API_SECRET = process.env.PITCH_ADMIN_SECRET || "";
if (!API_SECRET) {
console.error("PITCH_ADMIN_SECRET is required");
process.exit(1);
}
// --- HTTP client ---
async function api(
method: string,
path: string,
body?: unknown
): Promise<unknown> {
const url = `${API_URL}${path}`;
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${API_SECRET}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
data = text;
}
if (!res.ok) {
const msg =
typeof data === "object" && data && "error" in data
? (data as { error: string }).error
: `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
const TABLE_NAMES = [
"company",
"team",
"financials",
"market",
"competitors",
"features",
"milestones",
"metrics",
"funding",
"products",
"fm_scenarios",
"fm_assumptions",
] as const;
// --- MCP Server ---
const server = new McpServer({
name: "breakpilot-pitch",
version: "1.0.0",
});
// 1. list_versions
server.tool(
"list_versions",
"List all pitch versions with status, parent chain, and investor assignment counts",
{},
async () => {
const data = await api("GET", "/api/admin/versions");
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 2. create_version
server.tool(
"create_version",
"Create a new draft version. Optionally fork from a parent version ID, otherwise snapshots current base tables.",
{
name: z.string().describe("Version name, e.g. 'Conservative Q4'"),
description: z
.string()
.optional()
.describe("Optional description"),
parent_id: z
.string()
.uuid()
.optional()
.describe("UUID of parent version to fork from. Omit to snapshot base tables."),
},
async ({ name, description, parent_id }) => {
const data = await api("POST", "/api/admin/versions", {
name,
description,
parent_id,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 3. get_version
server.tool(
"get_version",
"Get full version detail including all 12 data table snapshots",
{
version_id: z.string().uuid().describe("Version UUID"),
},
async ({ version_id }) => {
const data = await api("GET", `/api/admin/versions/${version_id}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 4. get_table_data
server.tool(
"get_table_data",
"Get a specific table's data for a version. Tables: company, team, financials, market, competitors, features, milestones, metrics, funding, products, fm_scenarios, fm_assumptions",
{
version_id: z.string().uuid().describe("Version UUID"),
table_name: z
.enum(TABLE_NAMES)
.describe("Which data table to retrieve"),
},
async ({ version_id, table_name }) => {
const data = await api(
"GET",
`/api/admin/versions/${version_id}/data/${table_name}`
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 5. update_table_data
server.tool(
"update_table_data",
"Replace a table's data in a DRAFT version. Pass the full array of row objects. Single-record tables (company, funding) should still be wrapped in an array.",
{
version_id: z.string().uuid().describe("Version UUID (must be a draft)"),
table_name: z.enum(TABLE_NAMES).describe("Which data table to update"),
data: z
.string()
.describe(
"JSON string of the new data — an array of row objects. Example for company: [{\"name\":\"BreakPilot\",\"tagline_en\":\"...\"}]"
),
},
async ({ version_id, table_name, data: dataStr }) => {
let parsed: unknown;
try {
parsed = JSON.parse(dataStr);
} catch {
return {
content: [{ type: "text", text: "Error: invalid JSON in data parameter" }],
isError: true,
};
}
const result = await api(
"PUT",
`/api/admin/versions/${version_id}/data/${table_name}`,
{ data: parsed }
);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
}
);
// 6. commit_version
server.tool(
"commit_version",
"Commit a draft version, making it immutable and available for investor assignment",
{
version_id: z.string().uuid().describe("Draft version UUID to commit"),
},
async ({ version_id }) => {
const data = await api(
"POST",
`/api/admin/versions/${version_id}/commit`
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 7. fork_version
server.tool(
"fork_version",
"Create a new draft by forking an existing version (copies all data)",
{
version_id: z.string().uuid().describe("Version UUID to fork from"),
name: z.string().describe("Name for the new forked draft"),
},
async ({ version_id, name }) => {
const data = await api(
"POST",
`/api/admin/versions/${version_id}/fork`,
{ name }
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 8. diff_versions
server.tool(
"diff_versions",
"Compare two versions and see per-table diffs (added/removed/changed rows and fields)",
{
version_a: z.string().uuid().describe("First version UUID"),
version_b: z.string().uuid().describe("Second version UUID"),
},
async ({ version_a, version_b }) => {
const data = await api(
"GET",
`/api/admin/versions/${version_a}/diff/${version_b}`
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 9. list_investors
server.tool(
"list_investors",
"List all investors with their login stats, assigned version, and activity",
{},
async () => {
const data = await api("GET", "/api/admin/investors");
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 10. assign_version
server.tool(
"assign_version",
"Assign a committed version to an investor (determines what pitch data they see). Pass null to reset to default base tables.",
{
investor_id: z.string().uuid().describe("Investor UUID"),
version_id: z
.string()
.uuid()
.nullable()
.describe("Committed version UUID to assign, or null for default"),
},
async ({ investor_id, version_id }) => {
const data = await api("PATCH", `/api/admin/investors/${investor_id}`, {
assigned_version_id: version_id,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// 11. invite_investor
server.tool(
"invite_investor",
"Invite a new investor by email — sends a magic link for passwordless access to the pitch deck",
{
email: z.string().email().describe("Investor email address"),
name: z.string().optional().describe("Investor name"),
company: z.string().optional().describe("Investor company"),
},
async ({ email, name, company }) => {
const data = await api("POST", "/api/admin/invite", {
email,
name,
company,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
);
// --- Start ---
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error("MCP server error:", err);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}
+12 -1
View File
@@ -16,7 +16,7 @@ const PUBLIC_PATHS = [
]
// Paths gated on the admin session cookie
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin']
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin', '/pitch-preview', '/api/preview-data']
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
@@ -67,6 +67,17 @@ export async function middleware(request: NextRequest) {
}
}
// ----- Allow admins to access investor routes (e.g. /api/chat in preview) -----
const adminFallback = request.cookies.get('pitch_admin_session')?.value
if (adminFallback && secret) {
try {
await jwtVerify(adminFallback, new TextEncoder().encode(secret), { audience: ADMIN_AUDIENCE })
return NextResponse.next()
} catch {
// Invalid admin token, fall through to investor auth
}
}
// ----- Investor-gated routes (everything else) -----
const token = request.cookies.get('pitch_session')?.value
@@ -0,0 +1,191 @@
-- =========================================================
-- Pitch Deck: Core data tables + Financial Model
-- Run BEFORE 001_investor_auth.sql
-- =========================================================
-- Company info
CREATE TABLE IF NOT EXISTS pitch_company (
id SERIAL PRIMARY KEY,
name TEXT,
legal_form TEXT,
founding_date TEXT,
tagline_de TEXT,
tagline_en TEXT,
mission_de TEXT,
mission_en TEXT,
website TEXT,
hq_city TEXT
);
-- Team members
CREATE TABLE IF NOT EXISTS pitch_team (
id SERIAL PRIMARY KEY,
name TEXT,
role_de TEXT,
role_en TEXT,
bio_de TEXT,
bio_en TEXT,
equity_pct NUMERIC,
expertise TEXT[],
linkedin_url TEXT,
photo_url TEXT,
sort_order INT DEFAULT 0
);
-- Historical financials
CREATE TABLE IF NOT EXISTS pitch_financials (
id SERIAL PRIMARY KEY,
year INT,
revenue_eur BIGINT,
costs_eur BIGINT,
mrr_eur BIGINT,
burn_rate_eur BIGINT,
customers_count INT,
employees_count INT,
arr_eur BIGINT
);
-- Market segments (TAM/SAM/SOM)
CREATE TABLE IF NOT EXISTS pitch_market (
id SERIAL PRIMARY KEY,
market_segment TEXT,
label TEXT,
value_eur BIGINT,
growth_rate_pct NUMERIC,
source TEXT
);
-- Competitors
CREATE TABLE IF NOT EXISTS pitch_competitors (
id SERIAL PRIMARY KEY,
name TEXT,
customers_count INT,
pricing_range TEXT,
strengths TEXT[],
weaknesses TEXT[],
website TEXT
);
-- Feature comparison matrix
CREATE TABLE IF NOT EXISTS pitch_features (
id SERIAL PRIMARY KEY,
feature_name_de TEXT,
feature_name_en TEXT,
category TEXT,
breakpilot BOOLEAN,
proliance BOOLEAN,
dataguard BOOLEAN,
heydata BOOLEAN,
is_differentiator BOOLEAN,
sort_order INT DEFAULT 0
);
-- Milestones / timeline
CREATE TABLE IF NOT EXISTS pitch_milestones (
id SERIAL PRIMARY KEY,
milestone_date TEXT,
title_de TEXT,
title_en TEXT,
description_de TEXT,
description_en TEXT,
status TEXT,
category TEXT,
sort_order INT DEFAULT 0
);
-- Key metrics
CREATE TABLE IF NOT EXISTS pitch_metrics (
id SERIAL PRIMARY KEY,
metric_name TEXT,
label_de TEXT,
label_en TEXT,
value TEXT,
unit TEXT,
is_live BOOLEAN
);
-- Funding round
CREATE TABLE IF NOT EXISTS pitch_funding (
id SERIAL PRIMARY KEY,
round_name TEXT,
amount_eur BIGINT,
use_of_funds JSONB,
instrument TEXT,
target_date TEXT,
status TEXT
);
-- Products / tiers
CREATE TABLE IF NOT EXISTS pitch_products (
id SERIAL PRIMARY KEY,
name TEXT,
hardware TEXT,
hardware_cost_eur NUMERIC,
monthly_price_eur NUMERIC,
llm_model TEXT,
llm_size TEXT,
llm_capability_de TEXT,
llm_capability_en TEXT,
features_de TEXT[],
features_en TEXT[],
is_popular BOOLEAN,
operating_cost_eur NUMERIC,
sort_order INT DEFAULT 0
);
-- =========================================================
-- Financial Model
-- =========================================================
CREATE TABLE IF NOT EXISTS pitch_fm_scenarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT,
description TEXT,
is_default BOOLEAN DEFAULT false,
color TEXT DEFAULT '#6366f1',
sort_order INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS pitch_fm_assumptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
key TEXT,
label_de TEXT,
label_en TEXT,
value JSONB,
value_type TEXT DEFAULT 'scalar',
unit TEXT,
min_value NUMERIC,
max_value NUMERIC,
step_size NUMERIC,
category TEXT,
sort_order INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS pitch_fm_results (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
month INT,
year INT,
month_in_year INT,
new_customers INT,
churned_customers INT,
total_customers INT,
mrr_eur NUMERIC,
arr_eur NUMERIC,
revenue_eur NUMERIC,
cogs_eur NUMERIC,
personnel_eur NUMERIC,
infra_eur NUMERIC,
marketing_eur NUMERIC,
total_costs_eur NUMERIC,
employees_count INT,
gross_margin_pct NUMERIC,
burn_rate_eur NUMERIC,
runway_months NUMERIC,
cac_eur NUMERIC,
ltv_eur NUMERIC,
ltv_cac_ratio NUMERIC,
cash_balance_eur NUMERIC,
cumulative_revenue_eur NUMERIC
);
@@ -0,0 +1,36 @@
-- =========================================================
-- Pitch Deck: Version Management (Git-Style History)
-- =========================================================
-- Version metadata: each version points to its parent (git-style DAG)
CREATE TABLE IF NOT EXISTS pitch_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
parent_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'committed')),
created_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
committed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pitch_versions_parent ON pitch_versions(parent_id);
CREATE INDEX IF NOT EXISTS idx_pitch_versions_status ON pitch_versions(status);
-- Version content: one row per data table per version (fully materialized)
-- table_name values: company, team, financials, market, competitors, features,
-- milestones, metrics, funding, products, fm_scenarios, fm_assumptions
CREATE TABLE IF NOT EXISTS pitch_version_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version_id UUID NOT NULL REFERENCES pitch_versions(id) ON DELETE CASCADE,
table_name TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
UNIQUE(version_id, table_name)
);
CREATE INDEX IF NOT EXISTS idx_pitch_version_data_version ON pitch_version_data(version_id);
-- Per-investor version assignment (NULL = use base tables)
ALTER TABLE pitch_investors
ADD COLUMN IF NOT EXISTS assigned_version_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL;