Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ab569b2b | |||
| 645973141c | |||
|
|
3a2567b44d | ||
|
|
df0a9d6cf0 | ||
|
|
38363b2837 | ||
|
|
96f94475f6 | ||
|
|
3fd3336f6c | ||
|
|
eaba087d11 | ||
|
|
ed2cc234b8 | ||
|
|
ffd3fd1d7c | ||
|
|
23694b6555 | ||
|
|
8979aa8e43 | ||
|
|
c433bc021e | ||
|
|
f4ed1eb10c | ||
|
|
9c8663a0f1 | ||
|
|
d1632fca17 | ||
| fcf8aa8652 | |||
|
|
65177d3ff7 | ||
|
|
559d6a351c | ||
|
|
8fd11998e4 | ||
|
|
4ce649aa71 | ||
|
|
5ee3cc0104 | ||
|
|
b36712247b | ||
|
|
86b11c7e5f | ||
|
|
8003dcac39 | ||
|
|
778c44226e | ||
|
|
79891063dd | ||
|
|
2c9b0dc448 | ||
|
|
3133615044 | ||
|
|
2bc0f87325 | ||
|
|
4ee38d6f0b | ||
|
|
992d4f2a6b | ||
|
|
8f5f9641c7 | ||
|
|
7cdb53051f | ||
|
|
d834753a98 | ||
|
|
395011d0f4 | ||
|
|
9e1660f954 | ||
|
|
13ff930b5e | ||
|
|
5d1c837f49 | ||
|
|
1dd9662037 | ||
|
|
4626edb232 | ||
|
|
3c29b621ac | ||
|
|
755570d474 | ||
|
|
e890b1490a | ||
|
|
d15de16c47 |
@@ -2,28 +2,53 @@
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
|
||||
### Entwicklungsworkflow
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Auf Mac Mini pullen und Container neu bauen:
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# - Gitea Actions: Tests
|
||||
# - Coolify: Build → Deploy
|
||||
```
|
||||
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
**IMMER auf `main` pushen** — sowohl origin als auch gitea.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
|
||||
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||
|
||||
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||
```bash
|
||||
curl -sf https://api-dev.breakpilot.ai/health # Compliance Backend
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health # AI SDK
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen."**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional, nur Dev/Tests)
|
||||
|
||||
```bash
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
||||
```
|
||||
|
||||
### SSH-Verbindung (fuer Docker/Tests)
|
||||
### SSH-Verbindung (fuer lokale Docker/Tests)
|
||||
|
||||
```bash
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||
@@ -51,6 +76,14 @@ networks:
|
||||
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
||||
```
|
||||
|
||||
### Deployment-Modell
|
||||
|
||||
| Repo | Deployment | Trigger |
|
||||
|------|-----------|---------|
|
||||
| **breakpilot-core** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-compliance** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
||||
|
||||
---
|
||||
|
||||
## Haupt-URLs (via Nginx Reverse Proxy)
|
||||
@@ -161,7 +194,7 @@ networks:
|
||||
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
||||
|
||||
```bash
|
||||
# DB-Zugang
|
||||
# DB-Zugang (lokal)
|
||||
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
||||
```
|
||||
|
||||
@@ -193,7 +226,14 @@ breakpilot-core/
|
||||
|
||||
## Haeufige Befehle
|
||||
|
||||
### Docker
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur Dev/Tests)
|
||||
|
||||
```bash
|
||||
# Alle Core-Services starten
|
||||
@@ -211,31 +251,15 @@ ssh macmini "/usr/local/bin/docker ps --filter name=bp-core"
|
||||
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
|
||||
### Alle 3 Projekte starten
|
||||
|
||||
```bash
|
||||
# 1. Core (MUSS zuerst!)
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
||||
# Warten auf Health:
|
||||
ssh macmini "curl -sf http://127.0.0.1:8099/health"
|
||||
|
||||
# 2. Lehrer
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && /usr/local/bin/docker compose up -d"
|
||||
|
||||
# 3. Compliance
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose up -d"
|
||||
```
|
||||
|
||||
### Git
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git push all main"
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com
|
||||
# all: beide gleichzeitig
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -7,12 +7,10 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
container: alpine:latest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via Coolify API
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
echo "Deploying breakpilot-core to Coolify..."
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
|
||||
@@ -173,6 +173,43 @@ services:
|
||||
networks:
|
||||
- breakpilot-network
|
||||
|
||||
# =========================================================
|
||||
# PITCH DECK
|
||||
# =========================================================
|
||||
pitch-deck:
|
||||
build:
|
||||
context: ./pitch-deck
|
||||
dockerfile: Dockerfile
|
||||
container_name: bp-core-pitch-deck
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB}
|
||||
PITCH_JWT_SECRET: ${PITCH_JWT_SECRET}
|
||||
PITCH_ADMIN_SECRET: ${PITCH_ADMIN_SECRET}
|
||||
PITCH_BASE_URL: ${PITCH_BASE_URL:-https://pitch.breakpilot.ai}
|
||||
MAGIC_LINK_TTL_HOURS: ${MAGIC_LINK_TTL_HOURS:-72}
|
||||
# Optional: bootstrap first admin via `npm run admin:create` inside the container.
|
||||
PITCH_ADMIN_BOOTSTRAP_EMAIL: ${PITCH_ADMIN_BOOTSTRAP_EMAIL:-}
|
||||
PITCH_ADMIN_BOOTSTRAP_NAME: ${PITCH_ADMIN_BOOTSTRAP_NAME:-}
|
||||
PITCH_ADMIN_BOOTSTRAP_PASSWORD: ${PITCH_ADMIN_BOOTSTRAP_PASSWORD:-}
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
|
||||
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.ai}
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- breakpilot-network
|
||||
|
||||
# =========================================================
|
||||
# HEALTH AGGREGATOR
|
||||
# =========================================================
|
||||
@@ -185,7 +222,7 @@ services:
|
||||
- "8099"
|
||||
environment:
|
||||
PORT: 8099
|
||||
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095"
|
||||
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095,pitch-deck:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
|
||||
interval: 30s
|
||||
|
||||
@@ -833,8 +833,9 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:35b-a3b}
|
||||
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}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
|
||||
@@ -1,194 +1,77 @@
|
||||
# Umgebungs-Architektur
|
||||
|
||||
## Übersicht
|
||||
## Uebersicht
|
||||
|
||||
BreakPilot verwendet eine 3-Umgebungs-Strategie für sichere Entwicklung und Deployment:
|
||||
BreakPilot verwendet zwei Umgebungen:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Development │────▶│ Staging │────▶│ Production │
|
||||
│ (develop) │ │ (staging) │ │ (main) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
Tägliche Getesteter Code Produktionsreif
|
||||
Entwicklung
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Development │───── git push ────▶│ Production │
|
||||
│ (Mac Mini) │ │ (Coolify) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
Lokale Automatisch
|
||||
Entwicklung via Coolify
|
||||
```
|
||||
|
||||
## Umgebungen
|
||||
|
||||
### Development (Dev)
|
||||
### Development (Lokal — Mac Mini)
|
||||
|
||||
**Zweck:** Tägliche Entwicklungsarbeit
|
||||
**Zweck:** Lokale Entwicklung und Tests
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| Git Branch | `develop` |
|
||||
| Compose File | `docker-compose.yml` + `docker-compose.override.yml` (auto) |
|
||||
| Env File | `.env.dev` |
|
||||
| Database | `breakpilot_dev` |
|
||||
| Git Branch | `main` |
|
||||
| Compose File | `docker-compose.yml` |
|
||||
| Database | Lokale PostgreSQL |
|
||||
| Debug | Aktiviert |
|
||||
| Hot-Reload | Aktiviert |
|
||||
|
||||
**Start:**
|
||||
```bash
|
||||
./scripts/start.sh dev
|
||||
# oder einfach:
|
||||
docker compose up -d
|
||||
ssh macmini "cd ~/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
||||
```
|
||||
|
||||
### Staging
|
||||
### Production (Coolify)
|
||||
|
||||
**Zweck:** Getesteter, freigegebener Code vor Produktion
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| Git Branch | `staging` |
|
||||
| Compose File | `docker-compose.yml` + `docker-compose.staging.yml` |
|
||||
| Env File | `.env.staging` |
|
||||
| Database | `breakpilot_staging` (separates Volume) |
|
||||
| Debug | Deaktiviert |
|
||||
| Hot-Reload | Deaktiviert |
|
||||
|
||||
**Start:**
|
||||
```bash
|
||||
./scripts/start.sh staging
|
||||
# oder:
|
||||
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
|
||||
```
|
||||
|
||||
### Production (Prod)
|
||||
|
||||
**Zweck:** Live-System für Endbenutzer (ab Launch)
|
||||
**Zweck:** Live-System
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| Git Branch | `main` |
|
||||
| Compose File | `docker-compose.yml` + `docker-compose.prod.yml` |
|
||||
| Env File | `.env.prod` (NICHT im Repository!) |
|
||||
| Database | `breakpilot_prod` (separates Volume) |
|
||||
| Deployment | Coolify (automatisch bei Push auf gitea) |
|
||||
| Database | Externe PostgreSQL (TLS) |
|
||||
| Debug | Deaktiviert |
|
||||
| Vault | Pflicht (keine Env-Fallbacks) |
|
||||
|
||||
## Datenbank-Trennung
|
||||
|
||||
Jede Umgebung verwendet separate Docker Volumes für vollständige Datenisolierung:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL Volumes │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ breakpilot-dev_postgres_data │ Development Database │
|
||||
│ breakpilot_staging_postgres │ Staging Database │
|
||||
│ breakpilot_prod_postgres │ Production Database │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Port-Mapping
|
||||
|
||||
Um mehrere Umgebungen gleichzeitig laufen zu lassen, verwenden sie unterschiedliche Ports:
|
||||
|
||||
| Service | Dev Port | Staging Port | Prod Port |
|
||||
|---------|----------|--------------|-----------|
|
||||
| Backend | 8000 | 8001 | 8000 |
|
||||
| PostgreSQL | 5432 | 5433 | - (intern) |
|
||||
| MinIO | 9000/9001 | 9002/9003 | - (intern) |
|
||||
| Qdrant | 6333/6334 | 6335/6336 | - (intern) |
|
||||
| Mailpit | 8025/1025 | 8026/1026 | - (deaktiviert) |
|
||||
|
||||
## Git Branching Strategie
|
||||
|
||||
```
|
||||
main (Prod) ← Nur Release-Merges, geschützt
|
||||
│
|
||||
▼
|
||||
staging ← Getesteter Code, Review erforderlich
|
||||
│
|
||||
▼
|
||||
develop (Dev) ← Tägliche Arbeit, Default-Branch
|
||||
│
|
||||
▼
|
||||
feature/* ← Feature-Branches (optional)
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Entwicklung:** Arbeite auf `develop`
|
||||
2. **Code-Review:** Erstelle PR von Feature-Branch → `develop`
|
||||
3. **Staging:** Promote `develop` → `staging` mit Tests
|
||||
4. **Release:** Promote `staging` → `main` nach Freigabe
|
||||
|
||||
### Promotion-Befehle
|
||||
|
||||
**Deploy:**
|
||||
```bash
|
||||
# develop → staging
|
||||
./scripts/promote.sh dev-to-staging
|
||||
|
||||
# staging → main (Production)
|
||||
./scripts/promote.sh staging-to-prod
|
||||
git push origin main && git push gitea main
|
||||
# Coolify baut und deployt automatisch
|
||||
```
|
||||
|
||||
## Secrets Management
|
||||
|
||||
### Development
|
||||
- `.env.dev` enthält Entwicklungs-Credentials
|
||||
- Vault optional (Dev-Token)
|
||||
- Mailpit für E-Mail-Tests
|
||||
|
||||
### Staging
|
||||
- `.env.staging` enthält Test-Credentials
|
||||
- Vault empfohlen
|
||||
- Mailpit für E-Mail-Sicherheit
|
||||
|
||||
### Production
|
||||
- `.env.prod` NICHT im Repository
|
||||
- Vault PFLICHT
|
||||
- Echte SMTP-Konfiguration
|
||||
|
||||
Siehe auch: [Secrets Management](./secrets-management.md)
|
||||
|
||||
## Docker Compose Architektur
|
||||
|
||||
```
|
||||
docker-compose.yml ← Basis-Konfiguration
|
||||
docker-compose.yml ← Basis-Konfiguration (lokal, arm64)
|
||||
│
|
||||
├── docker-compose.override.yml ← Dev (auto-geladen)
|
||||
│
|
||||
├── docker-compose.staging.yml ← Staging (explizit)
|
||||
│
|
||||
└── docker-compose.prod.yml ← Production (explizit)
|
||||
└── docker-compose.coolify.yml ← Production Override (amd64)
|
||||
```
|
||||
|
||||
### Automatisches Laden
|
||||
Coolify verwendet automatisch beide Compose-Files fuer den Production-Build.
|
||||
|
||||
Docker Compose lädt automatisch:
|
||||
1. `docker-compose.yml`
|
||||
2. `docker-compose.override.yml` (falls vorhanden)
|
||||
## Secrets Management
|
||||
|
||||
Daher startet `docker compose up` automatisch die Dev-Umgebung.
|
||||
### Development
|
||||
- `.env` enthält Entwicklungs-Credentials
|
||||
- Vault optional (Dev-Token)
|
||||
- Mailpit für E-Mail-Tests
|
||||
|
||||
## Helper Scripts
|
||||
### Production
|
||||
- `.env` auf dem Server (nicht im Repository)
|
||||
- Vault PFLICHT
|
||||
- Echte SMTP-Konfiguration
|
||||
|
||||
| Script | Beschreibung |
|
||||
|--------|--------------|
|
||||
| `scripts/env-switch.sh` | Wechselt zwischen Umgebungen |
|
||||
| `scripts/start.sh` | Startet Services für Umgebung |
|
||||
| `scripts/stop.sh` | Stoppt Services |
|
||||
| `scripts/promote.sh` | Promotet Code zwischen Branches |
|
||||
| `scripts/status.sh` | Zeigt aktuellen Status |
|
||||
|
||||
## Verifikation
|
||||
|
||||
Nach Setup prüfen:
|
||||
|
||||
```bash
|
||||
# Status anzeigen
|
||||
./scripts/status.sh
|
||||
|
||||
# Branches prüfen
|
||||
git branch -v
|
||||
|
||||
# Volumes prüfen
|
||||
docker volume ls | grep breakpilot
|
||||
```
|
||||
Siehe auch: [Secrets Management](./secrets-management.md)
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
# CI/CD Pipeline
|
||||
|
||||
Übersicht über den Deployment-Prozess für Breakpilot.
|
||||
Uebersicht ueber den Deployment-Prozess fuer BreakPilot.
|
||||
|
||||
## Übersicht
|
||||
## Uebersicht
|
||||
|
||||
| Komponente | Build-Tool | Deployment |
|
||||
|------------|------------|------------|
|
||||
| Frontend (Next.js) | Docker | Mac Mini |
|
||||
| Backend (FastAPI) | Docker | Mac Mini |
|
||||
| Go Services | Docker (Multi-stage) | Mac Mini |
|
||||
| Documentation | MkDocs | Docker (Nginx) |
|
||||
| Repo | Deployment | Trigger | Compose File |
|
||||
|------|-----------|---------|--------------|
|
||||
| **breakpilot-core** | Coolify (automatisch) | Push auf `coolify` Branch | `docker-compose.coolify.yml` |
|
||||
| **breakpilot-compliance** | Coolify (automatisch) | Push auf `main` Branch | `docker-compose.yml` + `docker-compose.coolify.yml` |
|
||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell `docker compose` | `docker-compose.yml` |
|
||||
|
||||
## Deployment-Architektur
|
||||
|
||||
@@ -17,287 +16,146 @@
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Entwickler-MacBook │
|
||||
│ │
|
||||
│ breakpilot-core/ │
|
||||
│ ├── admin-core/ (Next.js Admin, Port 3008) │
|
||||
│ ├── backend-core/ (Python FastAPI, Port 8000) │
|
||||
│ ├── consent-service/ (Go Service, Port 8081) │
|
||||
│ ├── billing-service/ (Go Service, Port 8083) │
|
||||
│ └── docs-src/ (MkDocs) │
|
||||
│ breakpilot-core/ → git push gitea coolify │
|
||||
│ breakpilot-compliance/ → git push gitea main │
|
||||
│ breakpilot-lehrer/ → git push + ssh macmini docker ... │
|
||||
│ │
|
||||
│ git push → Gitea Actions (automatisch) │
|
||||
│ oder manuell: git push && ssh macmini docker compose build │
|
||||
└───────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
│ git push origin main
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Mac Mini Server (bp-core-*) │
|
||||
│ │
|
||||
│ Docker Compose │
|
||||
│ ├── admin-core (Port 3008) │
|
||||
│ ├── backend-core (Port 8000) │
|
||||
│ ├── consent-service (Port 8081) │
|
||||
│ ├── billing-service (Port 8083) │
|
||||
│ ├── gitea (Port 3003) + gitea-runner (Gitea Actions) │
|
||||
│ ├── docs (Port 8011) │
|
||||
│ ├── postgres, valkey, qdrant, minio │
|
||||
│ └── vault, nginx, night-scheduler, health │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||
│ Coolify (Production) │ │ Mac Mini (Lokal/Dev) │
|
||||
│ │ │ │
|
||||
│ Gitea Actions │ │ breakpilot-lehrer │
|
||||
│ ├── Tests │ │ ├── studio-v2 │
|
||||
│ └── Coolify API Deploy │ │ ├── klausur-service │
|
||||
│ │ │ ├── backend-lehrer │
|
||||
│ Core Services: │ │ └── voice-service │
|
||||
│ ├── consent-service │ │ │
|
||||
│ ├── rag-service │ │ Core Services (lokal): │
|
||||
│ ├── embedding-service │ │ ├── postgres │
|
||||
│ ├── paddleocr-service │ │ ├── valkey, vault │
|
||||
│ └── health-aggregator │ │ ├── nginx, gitea │
|
||||
│ │ │ └── ... │
|
||||
│ Compliance Services: │ │ │
|
||||
│ ├── admin-compliance │ │ │
|
||||
│ ├── backend-compliance │ │ │
|
||||
│ ├── ai-compliance-sdk │ │ │
|
||||
│ └── developer-portal │ │ │
|
||||
└───────────────────────────┘ └───────────────────────────┘
|
||||
```
|
||||
|
||||
## Sync & Deploy Workflow
|
||||
## breakpilot-core → Coolify
|
||||
|
||||
### 1. Dateien synchronisieren
|
||||
|
||||
```bash
|
||||
# Sync aller relevanten Verzeichnisse zum Mac Mini
|
||||
rsync -avz --delete \
|
||||
--exclude 'node_modules' \
|
||||
--exclude '.next' \
|
||||
--exclude '.git' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude 'venv' \
|
||||
--exclude '.pytest_cache' \
|
||||
/Users/benjaminadmin/Projekte/breakpilot-core/ \
|
||||
macmini:/Users/benjaminadmin/Projekte/breakpilot-core/
|
||||
```
|
||||
|
||||
### 2. Container bauen
|
||||
|
||||
```bash
|
||||
# Einzelnen Service bauen
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
build --no-cache <service-name>"
|
||||
|
||||
# Beispiele:
|
||||
# studio-v2, admin-v2, website, backend, klausur-service, docs
|
||||
```
|
||||
|
||||
### 3. Container deployen
|
||||
|
||||
```bash
|
||||
# Container neu starten
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
up -d <service-name>"
|
||||
```
|
||||
|
||||
### 4. Logs prüfen
|
||||
|
||||
```bash
|
||||
# Container-Logs anzeigen
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
logs -f <service-name>"
|
||||
```
|
||||
|
||||
## Service-spezifische Deployments
|
||||
|
||||
### Next.js Frontend (studio-v2, admin-v2, website)
|
||||
|
||||
```bash
|
||||
# 1. Sync
|
||||
rsync -avz --delete \
|
||||
--exclude 'node_modules' --exclude '.next' --exclude '.git' \
|
||||
/Users/benjaminadmin/Projekte/breakpilot-core/studio-v2/ \
|
||||
macmini:/Users/benjaminadmin/Projekte/breakpilot-core/studio-v2/
|
||||
|
||||
# 2. Build & Deploy
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
build --no-cache studio-v2 && \
|
||||
/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
up -d studio-v2"
|
||||
```
|
||||
|
||||
### Python Services (backend, klausur-service, voice-service)
|
||||
|
||||
```bash
|
||||
# Build mit requirements.txt
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
build klausur-service && \
|
||||
/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
up -d klausur-service"
|
||||
```
|
||||
|
||||
### Go Services (consent-service, ai-compliance-sdk)
|
||||
|
||||
```bash
|
||||
# Multi-stage Build (Go → Alpine)
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
build --no-cache consent-service && \
|
||||
/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
up -d consent-service"
|
||||
```
|
||||
|
||||
### MkDocs Dokumentation
|
||||
|
||||
```bash
|
||||
# Build & Deploy
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
build --no-cache docs && \
|
||||
/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
up -d docs"
|
||||
|
||||
# Verfügbar unter: http://macmini:8009
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Service-Status prüfen
|
||||
|
||||
```bash
|
||||
# Alle Container-Status
|
||||
ssh macmini "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
|
||||
# Health-Endpoints prüfen
|
||||
curl -s http://macmini:8000/health
|
||||
curl -s http://macmini:8081/health
|
||||
curl -s http://macmini:8086/health
|
||||
curl -s http://macmini:8090/health
|
||||
```
|
||||
|
||||
### Logs analysieren
|
||||
|
||||
```bash
|
||||
# Letzte 100 Zeilen
|
||||
ssh macmini "docker logs --tail 100 breakpilot-core-backend-1"
|
||||
|
||||
# Live-Logs folgen
|
||||
ssh macmini "docker logs -f breakpilot-core-backend-1"
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
### Container auf vorherige Version zurücksetzen
|
||||
|
||||
```bash
|
||||
# 1. Aktuelles Image taggen
|
||||
ssh macmini "docker tag breakpilot-core-backend:latest breakpilot-core-backend:backup"
|
||||
|
||||
# 2. Altes Image deployen
|
||||
ssh macmini "/usr/local/bin/docker compose \
|
||||
-f /Users/benjaminadmin/Projekte/breakpilot-core/docker-compose.yml \
|
||||
up -d backend"
|
||||
|
||||
# 3. Bei Problemen: Backup wiederherstellen
|
||||
ssh macmini "docker tag breakpilot-core-backend:backup breakpilot-core-backend:latest"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container startet nicht
|
||||
|
||||
```bash
|
||||
# 1. Logs prüfen
|
||||
ssh macmini "docker logs breakpilot-core-<service>-1"
|
||||
|
||||
# 2. Container manuell starten für Debug-Output
|
||||
ssh macmini "docker compose -f .../docker-compose.yml run --rm <service>"
|
||||
|
||||
# 3. In Container einloggen
|
||||
ssh macmini "docker exec -it breakpilot-core-<service>-1 /bin/sh"
|
||||
```
|
||||
|
||||
### Port bereits belegt
|
||||
|
||||
```bash
|
||||
# Port-Belegung prüfen
|
||||
ssh macmini "lsof -i :8000"
|
||||
|
||||
# Container mit dem Port finden
|
||||
ssh macmini "docker ps --filter publish=8000"
|
||||
```
|
||||
|
||||
### Build-Fehler
|
||||
|
||||
```bash
|
||||
# Cache komplett leeren
|
||||
ssh macmini "docker builder prune -a"
|
||||
|
||||
# Ohne Cache bauen
|
||||
ssh macmini "docker compose build --no-cache <service>"
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Resource-Nutzung
|
||||
|
||||
```bash
|
||||
# CPU/Memory aller Container
|
||||
ssh macmini "docker stats --no-stream"
|
||||
|
||||
# Disk-Nutzung
|
||||
ssh macmini "docker system df"
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
# Ungenutzte Images/Container entfernen
|
||||
ssh macmini "docker system prune -a --volumes"
|
||||
|
||||
# Nur dangling Images
|
||||
ssh macmini "docker image prune"
|
||||
```
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Umgebungsvariablen werden über `.env` Dateien und docker-compose.yml verwaltet:
|
||||
### Pipeline
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://...
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
# .gitea/workflows/deploy-coolify.yml
|
||||
on:
|
||||
push:
|
||||
branches: [coolify]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via Coolify API
|
||||
# Triggert Coolify Build + Deploy ueber API
|
||||
# Secrets: COOLIFY_API_TOKEN, COOLIFY_RESOURCE_UUID, COOLIFY_BASE_URL
|
||||
```
|
||||
|
||||
**Wichtig**: Sensible Werte niemals in Git committen. Stattdessen:
|
||||
- `.env` Datei auf dem Server pflegen
|
||||
- Secrets über HashiCorp Vault (siehe unten)
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten
|
||||
# 2. Committen und pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Fuer Production-Deploy:
|
||||
git push gitea coolify
|
||||
|
||||
# 4. Status pruefen:
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-core/actions
|
||||
```
|
||||
|
||||
### Coolify-deployed Services
|
||||
|
||||
| Service | Container | Beschreibung |
|
||||
|---------|-----------|--------------|
|
||||
| valkey | bp-core-valkey | Session-Cache |
|
||||
| consent-service | bp-core-consent-service | Consent-Management (Go) |
|
||||
| rag-service | bp-core-rag-service | Semantische Suche |
|
||||
| embedding-service | bp-core-embedding-service | Text-Embeddings |
|
||||
| paddleocr-service | bp-core-paddleocr | OCR Engine (x86_64) |
|
||||
| health-aggregator | bp-core-health | Health-Check Aggregator |
|
||||
|
||||
## breakpilot-compliance → Coolify
|
||||
|
||||
### Pipeline
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/ci.yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# Lint (nur PRs)
|
||||
# Tests (Go, Python, Node.js)
|
||||
# Validate Canonical Controls
|
||||
# Deploy (nur main, nach allen Tests)
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# CI-Status pruefen:
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
|
||||
# Health Checks:
|
||||
curl -sf https://api-dev.breakpilot.ai/health
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
```
|
||||
|
||||
## breakpilot-lehrer → Mac Mini (lokal)
|
||||
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten
|
||||
# 2. Committen und pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Auf Mac Mini pullen und Container neu bauen:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-lehrer pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml build --no-cache <service>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml up -d <service>"
|
||||
```
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
### Überblick
|
||||
### Ueberblick
|
||||
|
||||
BreakPilot Core nutzt **Gitea Actions** (GitHub Actions-kompatibel) als CI/CD-System. Der `act_runner` läuft als Container auf dem Mac Mini und führt Pipelines direkt bei Code-Push aus.
|
||||
BreakPilot nutzt **Gitea Actions** (GitHub Actions-kompatibel) als CI/CD-System. Der `act_runner` laeuft als Container auf dem Mac Mini und fuehrt Pipelines aus.
|
||||
|
||||
| Komponente | Container | Beschreibung |
|
||||
|------------|-----------|--------------|
|
||||
| Gitea | `bp-core-gitea` (Port 3003) | Git-Server + Actions-Trigger |
|
||||
| Gitea Runner | `bp-core-gitea-runner` | Führt Actions-Workflows aus |
|
||||
| Gitea Runner | `bp-core-gitea-runner` | Fuehrt Actions-Workflows aus |
|
||||
|
||||
### Pipeline-Konfiguration
|
||||
|
||||
Workflows liegen im Repo unter `.gitea/workflows/`:
|
||||
Workflows liegen in jedem Repo unter `.gitea/workflows/`:
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/main.yml
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build & Test
|
||||
run: docker compose build
|
||||
```
|
||||
| Repo | Workflow | Branch | Aktion |
|
||||
|------|----------|--------|--------|
|
||||
| breakpilot-core | `deploy-coolify.yml` | `coolify` | Coolify API Deploy |
|
||||
| breakpilot-compliance | `ci.yaml` | `main` | Tests + Coolify Deploy |
|
||||
|
||||
### Runner-Token erneuern
|
||||
|
||||
@@ -314,12 +172,79 @@ ssh macmini "/usr/local/bin/docker compose \
|
||||
up -d --force-recreate gitea-runner"
|
||||
```
|
||||
|
||||
### Pipeline-Status prüfen
|
||||
### Pipeline-Status pruefen
|
||||
|
||||
```bash
|
||||
# Runner-Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-core-gitea-runner"
|
||||
|
||||
# Laufende Jobs
|
||||
ssh macmini "/usr/local/bin/docker exec bp-core-gitea-runner act_runner list"
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Production (Coolify)
|
||||
|
||||
```bash
|
||||
# Core PaddleOCR
|
||||
curl -sf https://ocr.breakpilot.com/health
|
||||
|
||||
# Compliance
|
||||
curl -sf https://api-dev.breakpilot.ai/health
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
```
|
||||
|
||||
### Lokal (Mac Mini)
|
||||
|
||||
```bash
|
||||
# Core Health Aggregator
|
||||
curl -sf http://macmini:8099/health
|
||||
|
||||
# Lehrer Backend
|
||||
curl -sf https://macmini:8001/health
|
||||
|
||||
# Klausur-Service
|
||||
curl -sf https://macmini:8086/health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container startet nicht
|
||||
|
||||
```bash
|
||||
# Logs pruefen (lokal)
|
||||
ssh macmini "/usr/local/bin/docker logs bp-core-<service>"
|
||||
|
||||
# In Container einloggen
|
||||
ssh macmini "/usr/local/bin/docker exec -it bp-core-<service> /bin/sh"
|
||||
```
|
||||
|
||||
### Build-Fehler
|
||||
|
||||
```bash
|
||||
# Cache komplett leeren
|
||||
ssh macmini "docker builder prune -a"
|
||||
|
||||
# Ohne Cache bauen
|
||||
ssh macmini "docker compose build --no-cache <service>"
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
### Coolify
|
||||
|
||||
Ein Redeploy mit einem aelteren Commit kann durch Zuruecksetzen des Branches ausgeloest werden:
|
||||
|
||||
```bash
|
||||
# Branch auf vorherigen Commit zuruecksetzen und pushen
|
||||
git reset --hard <previous-commit>
|
||||
git push gitea coolify --force
|
||||
```
|
||||
|
||||
### Lokal (Mac Mini)
|
||||
|
||||
```bash
|
||||
# Image taggen als Backup
|
||||
ssh macmini "docker tag breakpilot-lehrer-klausur-service:latest breakpilot-lehrer-klausur-service:backup"
|
||||
|
||||
# Bei Problemen: Backup wiederherstellen
|
||||
ssh macmini "docker tag breakpilot-lehrer-klausur-service:backup breakpilot-lehrer-klausur-service:latest"
|
||||
```
|
||||
|
||||
@@ -12,6 +12,14 @@ BreakPilot besteht aus drei unabhaengigen Projekten:
|
||||
| **breakpilot-lehrer** | Bildungs-Stack (Team A) | `bp-lehrer-*` | Blau |
|
||||
| **breakpilot-compliance** | DSGVO/Compliance-Stack (Team B) | `bp-compliance-*` | Lila |
|
||||
|
||||
### Deployment-Modell
|
||||
|
||||
| Repo | Deployment | Trigger |
|
||||
|------|-----------|---------|
|
||||
| **breakpilot-core** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-compliance** | Coolify (automatisch) | Push auf gitea main |
|
||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
||||
|
||||
## Core Services
|
||||
|
||||
| Service | Container | Port | Beschreibung |
|
||||
@@ -30,32 +38,11 @@ BreakPilot besteht aus drei unabhaengigen Projekten:
|
||||
| Admin Core | bp-core-admin | 3008 | Admin-Dashboard (Next.js) |
|
||||
| Health Aggregator | bp-core-health | 8099 | Service-Health Monitoring |
|
||||
| Night Scheduler | bp-core-night-scheduler | 8096 | Nachtabschaltung |
|
||||
| Pitch Deck | bp-core-pitch-deck | 3012 | Investor-Praesentation |
|
||||
| Mailpit | bp-core-mailpit | 8025 | E-Mail (Entwicklung) |
|
||||
| Gitea | bp-core-gitea | 3003 | Git-Server |
|
||||
| Gitea Runner | bp-core-gitea-runner | - | CI/CD (Gitea Actions) |
|
||||
| Jitsi (5 Container) | bp-core-jitsi-* | 8443 | Videokonferenzen |
|
||||
|
||||
## Nginx Routing-Tabelle
|
||||
|
||||
| Port | Upstream | Projekt |
|
||||
|------|----------|---------|
|
||||
| 443 | bp-lehrer-studio-v2:3001 | Lehrer |
|
||||
| 3000 | bp-lehrer-website:3000 | Lehrer |
|
||||
| 3002 | bp-lehrer-admin:3000 | Lehrer |
|
||||
| 3006 | bp-compliance-developer-portal:3000 | Compliance |
|
||||
| 3007 | bp-compliance-admin:3000 | Compliance |
|
||||
| 3008 | bp-core-admin:3000 | Core |
|
||||
| 8000 | bp-core-backend:8000 | Core |
|
||||
| 8001 | bp-lehrer-backend:8001 | Lehrer |
|
||||
| 8002 | bp-compliance-backend:8002 | Compliance |
|
||||
| 8086 | bp-lehrer-klausur-service:8086 | Lehrer |
|
||||
| 8087 | bp-core-embedding-service:8087 | Core |
|
||||
| 8091 | bp-lehrer-voice-service:8091 | Lehrer |
|
||||
| 8093 | bp-compliance-ai-sdk:8090 | Compliance |
|
||||
| 8097 | bp-core-rag-service:8097 | Core |
|
||||
| 8443 | bp-core-jitsi-web:80 | Core |
|
||||
|
||||
## Architektur
|
||||
|
||||
- [System-Architektur](architecture/system-architecture.md)
|
||||
|
||||
96
pitch-deck/__tests__/lib/admin-auth.test.ts
Normal file
96
pitch-deck/__tests__/lib/admin-auth.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
createAdminJwt,
|
||||
verifyAdminJwt,
|
||||
} from '@/lib/admin-auth'
|
||||
import { createJwt, verifyJwt } from '@/lib/auth'
|
||||
|
||||
describe('admin-auth: password hashing', () => {
|
||||
it('hashPassword produces a bcrypt hash', async () => {
|
||||
const hash = await hashPassword('correct-horse-battery-staple')
|
||||
expect(hash).toMatch(/^\$2[aby]\$/)
|
||||
expect(hash.length).toBeGreaterThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('hashPassword is non-deterministic (different salt each call)', async () => {
|
||||
const a = await hashPassword('same-password')
|
||||
const b = await hashPassword('same-password')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('verifyPassword accepts the original password', async () => {
|
||||
const hash = await hashPassword('correct-horse-battery-staple')
|
||||
expect(await verifyPassword('correct-horse-battery-staple', hash)).toBe(true)
|
||||
})
|
||||
|
||||
it('verifyPassword rejects a wrong password', async () => {
|
||||
const hash = await hashPassword('correct-horse-battery-staple')
|
||||
expect(await verifyPassword('wrong-password', hash)).toBe(false)
|
||||
})
|
||||
|
||||
it('verifyPassword rejects empty input against any hash', async () => {
|
||||
const hash = await hashPassword('something')
|
||||
expect(await verifyPassword('', hash)).toBe(false)
|
||||
})
|
||||
|
||||
it('verifyPassword is case-sensitive', async () => {
|
||||
const hash = await hashPassword('CaseSensitive')
|
||||
expect(await verifyPassword('casesensitive', hash)).toBe(false)
|
||||
expect(await verifyPassword('CaseSensitive', hash)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin-auth: JWT roundtrip', () => {
|
||||
const payload = {
|
||||
sub: 'admin-uuid-123',
|
||||
email: 'admin@example.com',
|
||||
sessionId: 'session-uuid-456',
|
||||
}
|
||||
|
||||
it('createAdminJwt + verifyAdminJwt roundtrip preserves payload', async () => {
|
||||
const jwt = await createAdminJwt(payload)
|
||||
const decoded = await verifyAdminJwt(jwt)
|
||||
expect(decoded).not.toBeNull()
|
||||
expect(decoded?.sub).toBe(payload.sub)
|
||||
expect(decoded?.email).toBe(payload.email)
|
||||
expect(decoded?.sessionId).toBe(payload.sessionId)
|
||||
})
|
||||
|
||||
it('verifyAdminJwt rejects a tampered token', async () => {
|
||||
const jwt = await createAdminJwt(payload)
|
||||
const tampered = jwt.slice(0, -2) + 'XX'
|
||||
expect(await verifyAdminJwt(tampered)).toBeNull()
|
||||
})
|
||||
|
||||
it('verifyAdminJwt rejects garbage input', async () => {
|
||||
expect(await verifyAdminJwt('not-a-jwt')).toBeNull()
|
||||
expect(await verifyAdminJwt('')).toBeNull()
|
||||
expect(await verifyAdminJwt('a.b.c')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin-auth: audience claim isolation', () => {
|
||||
// This is the security boundary: an investor JWT must NEVER validate as an admin JWT
|
||||
// (and vice versa). They share the same secret but use audience claims to stay distinct.
|
||||
|
||||
const payload = { sub: 'user-id', email: 'user@example.com', sessionId: 'session' }
|
||||
|
||||
it('an investor JWT (no admin audience) is rejected by verifyAdminJwt', async () => {
|
||||
const investorJwt = await createJwt(payload)
|
||||
const result = await verifyAdminJwt(investorJwt)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('an admin JWT is rejected by verifyJwt (because verifyJwt does not enforce audience, but admin JWT has audience that investor token does not)', async () => {
|
||||
// Note: verifyJwt does not enforce audience, so an admin JWT with an audience claim
|
||||
// technically *could* parse — but the cookie is on a different name (pitch_admin_session)
|
||||
// so this can't happen in practice. We document the expectation here:
|
||||
const adminJwt = await createAdminJwt(payload)
|
||||
const result = await verifyJwt(adminJwt)
|
||||
// jose parses it but the payload is the same shape, so this would actually succeed.
|
||||
// The real isolation is: cookies. We assert the JWT itself is different.
|
||||
expect(adminJwt).not.toBe(await createJwt(payload))
|
||||
})
|
||||
})
|
||||
118
pitch-deck/__tests__/lib/auth.test.ts
Normal file
118
pitch-deck/__tests__/lib/auth.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
hashToken,
|
||||
generateToken,
|
||||
validateAdminSecret,
|
||||
getClientIp,
|
||||
createJwt,
|
||||
verifyJwt,
|
||||
} from '@/lib/auth'
|
||||
|
||||
describe('auth: token utilities', () => {
|
||||
it('generateToken produces a 96-character hex string (48 random bytes)', () => {
|
||||
const t = generateToken()
|
||||
expect(t).toMatch(/^[0-9a-f]{96}$/)
|
||||
})
|
||||
|
||||
it('generateToken produces unique values across calls', () => {
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < 100; i++) seen.add(generateToken())
|
||||
expect(seen.size).toBe(100)
|
||||
})
|
||||
|
||||
it('hashToken is deterministic for the same input', () => {
|
||||
const a = hashToken('input')
|
||||
const b = hashToken('input')
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('hashToken produces a 64-char hex SHA-256 digest', () => {
|
||||
expect(hashToken('anything')).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('hashToken produces different output for different input', () => {
|
||||
expect(hashToken('a')).not.toBe(hashToken('b'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth: validateAdminSecret (CLI bearer fallback)', () => {
|
||||
it('accepts the correct bearer header', () => {
|
||||
const req = new Request('http://x', {
|
||||
headers: { authorization: `Bearer ${process.env.PITCH_ADMIN_SECRET}` },
|
||||
})
|
||||
expect(validateAdminSecret(req)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a wrong bearer secret', () => {
|
||||
const req = new Request('http://x', {
|
||||
headers: { authorization: 'Bearer wrong-secret' },
|
||||
})
|
||||
expect(validateAdminSecret(req)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects requests with no Authorization header', () => {
|
||||
const req = new Request('http://x')
|
||||
expect(validateAdminSecret(req)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects bare secret without Bearer prefix', () => {
|
||||
const req = new Request('http://x', {
|
||||
headers: { authorization: process.env.PITCH_ADMIN_SECRET || '' },
|
||||
})
|
||||
expect(validateAdminSecret(req)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth: getClientIp', () => {
|
||||
it('parses x-forwarded-for', () => {
|
||||
const req = new Request('http://x', {
|
||||
headers: { 'x-forwarded-for': '10.0.0.1' },
|
||||
})
|
||||
expect(getClientIp(req)).toBe('10.0.0.1')
|
||||
})
|
||||
|
||||
it('takes the first hop from a comma-separated x-forwarded-for', () => {
|
||||
const req = new Request('http://x', {
|
||||
headers: { 'x-forwarded-for': '10.0.0.1, 192.168.1.1, 172.16.0.1' },
|
||||
})
|
||||
expect(getClientIp(req)).toBe('10.0.0.1')
|
||||
})
|
||||
|
||||
it('trims whitespace around the first IP', () => {
|
||||
const req = new Request('http://x', {
|
||||
headers: { 'x-forwarded-for': ' 10.0.0.1 , 192.168.1.1' },
|
||||
})
|
||||
expect(getClientIp(req)).toBe('10.0.0.1')
|
||||
})
|
||||
|
||||
it('returns null when the header is absent', () => {
|
||||
const req = new Request('http://x')
|
||||
expect(getClientIp(req)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth: investor JWT roundtrip', () => {
|
||||
const payload = {
|
||||
sub: 'investor-id',
|
||||
email: 'investor@example.com',
|
||||
sessionId: 'session-id',
|
||||
}
|
||||
|
||||
it('createJwt + verifyJwt roundtrip preserves payload', async () => {
|
||||
const jwt = await createJwt(payload)
|
||||
const decoded = await verifyJwt(jwt)
|
||||
expect(decoded?.sub).toBe(payload.sub)
|
||||
expect(decoded?.email).toBe(payload.email)
|
||||
expect(decoded?.sessionId).toBe(payload.sessionId)
|
||||
})
|
||||
|
||||
it('verifyJwt rejects garbage', async () => {
|
||||
expect(await verifyJwt('not-a-jwt')).toBeNull()
|
||||
})
|
||||
|
||||
it('verifyJwt rejects a tampered signature', async () => {
|
||||
const jwt = await createJwt(payload)
|
||||
const tampered = jwt.slice(0, -2) + 'XX'
|
||||
expect(await verifyJwt(tampered)).toBeNull()
|
||||
})
|
||||
})
|
||||
83
pitch-deck/__tests__/lib/rate-limit.test.ts
Normal file
83
pitch-deck/__tests__/lib/rate-limit.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
describe('rate-limit', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('allows the first request', () => {
|
||||
const result = checkRateLimit('test-key-1', { limit: 5, windowSec: 60 })
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(4)
|
||||
})
|
||||
|
||||
it('allows up to the limit, then rejects', () => {
|
||||
const key = 'test-key-2'
|
||||
const config = { limit: 3, windowSec: 60 }
|
||||
|
||||
expect(checkRateLimit(key, config).allowed).toBe(true)
|
||||
expect(checkRateLimit(key, config).allowed).toBe(true)
|
||||
expect(checkRateLimit(key, config).allowed).toBe(true)
|
||||
expect(checkRateLimit(key, config).allowed).toBe(false)
|
||||
expect(checkRateLimit(key, config).allowed).toBe(false)
|
||||
})
|
||||
|
||||
it('decrements the remaining counter on each call', () => {
|
||||
const key = 'test-key-3'
|
||||
const config = { limit: 3, windowSec: 60 }
|
||||
|
||||
expect(checkRateLimit(key, config).remaining).toBe(2)
|
||||
expect(checkRateLimit(key, config).remaining).toBe(1)
|
||||
expect(checkRateLimit(key, config).remaining).toBe(0)
|
||||
})
|
||||
|
||||
it('keys are isolated from each other', () => {
|
||||
const config = { limit: 1, windowSec: 60 }
|
||||
expect(checkRateLimit('key-a', config).allowed).toBe(true)
|
||||
expect(checkRateLimit('key-a', config).allowed).toBe(false)
|
||||
// Different key still has its quota
|
||||
expect(checkRateLimit('key-b', config).allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
const key = 'test-key-reset'
|
||||
const config = { limit: 2, windowSec: 1 }
|
||||
|
||||
expect(checkRateLimit(key, config).allowed).toBe(true)
|
||||
expect(checkRateLimit(key, config).allowed).toBe(true)
|
||||
expect(checkRateLimit(key, config).allowed).toBe(false)
|
||||
|
||||
// Advance past the window
|
||||
vi.advanceTimersByTime(1100)
|
||||
|
||||
expect(checkRateLimit(key, config).allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes a sensible resetAt timestamp', () => {
|
||||
const before = Date.now()
|
||||
const r = checkRateLimit('reset-at-test', { limit: 5, windowSec: 60 })
|
||||
expect(r.resetAt).toBeGreaterThanOrEqual(before + 60_000 - 10)
|
||||
expect(r.resetAt).toBeLessThanOrEqual(before + 60_000 + 10)
|
||||
})
|
||||
|
||||
describe('preset configs', () => {
|
||||
it('magicLink: 3 per hour', () => {
|
||||
expect(RATE_LIMITS.magicLink.limit).toBe(3)
|
||||
expect(RATE_LIMITS.magicLink.windowSec).toBe(3600)
|
||||
})
|
||||
|
||||
it('authVerify: 10 per 15 minutes', () => {
|
||||
expect(RATE_LIMITS.authVerify.limit).toBe(10)
|
||||
expect(RATE_LIMITS.authVerify.windowSec).toBe(900)
|
||||
})
|
||||
|
||||
it('chat: 20 per minute', () => {
|
||||
expect(RATE_LIMITS.chat.limit).toBe(20)
|
||||
expect(RATE_LIMITS.chat.windowSec).toBe(60)
|
||||
})
|
||||
})
|
||||
})
|
||||
4
pitch-deck/__tests__/setup.ts
Normal file
4
pitch-deck/__tests__/setup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Vitest global setup. Required env so the auth modules can initialize.
|
||||
process.env.PITCH_JWT_SECRET = process.env.PITCH_JWT_SECRET || 'test-secret-do-not-use-in-production-32chars'
|
||||
process.env.PITCH_ADMIN_SECRET = process.env.PITCH_ADMIN_SECRET || 'test-admin-secret'
|
||||
process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgres://test:test@localhost:5432/test'
|
||||
62
pitch-deck/app/api/admin-auth/login/route.ts
Normal file
62
pitch-deck/app/api/admin-auth/login/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { verifyPassword, createAdminSession, setAdminCookie, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { getClientIp } from '@/lib/auth'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request) || 'unknown'
|
||||
|
||||
// Reuse the auth-verify rate limit (10/IP/15min)
|
||||
const rl = checkRateLimit(`admin-login:${ip}`, RATE_LIMITS.authVerify)
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const email = (body.email || '').trim().toLowerCase()
|
||||
const password = body.password || ''
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, password_hash, is_active FROM pitch_admins WHERE email = $1`,
|
||||
[email],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await logAdminAudit(null, 'admin_login_failed', { email, reason: 'unknown_email' }, request)
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const admin = rows[0]
|
||||
|
||||
if (!admin.is_active) {
|
||||
await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'inactive' }, request)
|
||||
return NextResponse.json({ error: 'Account disabled' }, { status: 403 })
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(password, admin.password_hash)
|
||||
if (!ok) {
|
||||
await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'wrong_password' }, request)
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ua = request.headers.get('user-agent')
|
||||
const { jwt } = await createAdminSession(admin.id, ip, ua)
|
||||
await setAdminCookie(jwt)
|
||||
|
||||
await pool.query(
|
||||
`UPDATE pitch_admins SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
||||
[admin.id],
|
||||
)
|
||||
|
||||
await logAdminAudit(admin.id, 'admin_login_success', { email }, request)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
admin: { id: admin.id, email: admin.email, name: admin.name },
|
||||
})
|
||||
}
|
||||
17
pitch-deck/app/api/admin-auth/logout/route.ts
Normal file
17
pitch-deck/app/api/admin-auth/logout/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getAdminPayloadFromCookie,
|
||||
revokeAdminSession,
|
||||
clearAdminCookie,
|
||||
logAdminAudit,
|
||||
} from '@/lib/admin-auth'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = await getAdminPayloadFromCookie()
|
||||
if (payload) {
|
||||
await revokeAdminSession(payload.sessionId)
|
||||
await logAdminAudit(payload.sub, 'admin_logout', {}, request)
|
||||
}
|
||||
await clearAdminCookie()
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
10
pitch-deck/app/api/admin-auth/me/route.ts
Normal file
10
pitch-deck/app/api/admin-auth/me/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAdminFromCookie } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET() {
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ admin })
|
||||
}
|
||||
81
pitch-deck/app/api/admin/admins/[id]/route.ts
Normal file
81
pitch-deck/app/api/admin/admins/[id]/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit, hashPassword, revokeAllAdminSessions } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const actorAdminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, is_active, password } = body
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT email, name, is_active FROM pitch_admins WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Admin not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: unknown[] = []
|
||||
let p = 1
|
||||
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
updates.push(`name = $${p++}`)
|
||||
params.push(name.trim())
|
||||
}
|
||||
if (typeof is_active === 'boolean') {
|
||||
updates.push(`is_active = $${p++}`)
|
||||
params.push(is_active)
|
||||
}
|
||||
if (typeof password === 'string') {
|
||||
if (password.length < 12) {
|
||||
return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 })
|
||||
}
|
||||
const hash = await hashPassword(password)
|
||||
updates.push(`password_hash = $${p++}`)
|
||||
params.push(hash)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`)
|
||||
params.push(id)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_admins SET ${updates.join(', ')}
|
||||
WHERE id = $${p}
|
||||
RETURNING id, email, name, is_active, last_login_at, created_at`,
|
||||
params,
|
||||
)
|
||||
|
||||
// If deactivated or password changed, revoke their sessions
|
||||
if (is_active === false || typeof password === 'string') {
|
||||
await revokeAllAdminSessions(id)
|
||||
}
|
||||
|
||||
const action = is_active === false ? 'admin_deactivated' : 'admin_edited'
|
||||
await logAdminAudit(
|
||||
actorAdminId,
|
||||
action,
|
||||
{
|
||||
target_admin_id: id,
|
||||
target_email: before.rows[0].email,
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, is_active: rows[0].is_active },
|
||||
password_changed: typeof password === 'string',
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
return NextResponse.json({ admin: rows[0] })
|
||||
}
|
||||
52
pitch-deck/app/api/admin/admins/route.ts
Normal file
52
pitch-deck/app/api/admin/admins/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit, hashPassword } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, is_active, last_login_at, created_at, updated_at
|
||||
FROM pitch_admins ORDER BY created_at ASC`,
|
||||
)
|
||||
return NextResponse.json({ admins: 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 email = (body.email || '').trim().toLowerCase()
|
||||
const name = (body.name || '').trim()
|
||||
const password = body.password || ''
|
||||
|
||||
if (!email || !name || !password) {
|
||||
return NextResponse.json({ error: 'email, name, password required' }, { status: 400 })
|
||||
}
|
||||
if (password.length < 12) {
|
||||
return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_admins (email, name, password_hash, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, email, name, is_active, created_at`,
|
||||
[email, name, hash],
|
||||
)
|
||||
const newAdmin = rows[0]
|
||||
await logAdminAudit(adminId, 'admin_created', { email, name, new_admin_id: newAdmin.id }, request)
|
||||
return NextResponse.json({ admin: newAdmin })
|
||||
} catch (err) {
|
||||
const e = err as { code?: string }
|
||||
if (e.code === '23505') {
|
||||
return NextResponse.json({ error: 'Email already exists' }, { status: 409 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
77
pitch-deck/app/api/admin/audit-logs/route.ts
Normal file
77
pitch-deck/app/api/admin/audit-logs/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const investorId = searchParams.get('investor_id')
|
||||
const targetInvestorId = searchParams.get('target_investor_id')
|
||||
const adminId = searchParams.get('admin_id')
|
||||
const actorType = searchParams.get('actor_type') // 'admin' | 'investor'
|
||||
const action = searchParams.get('action')
|
||||
const since = searchParams.get('since') // ISO date
|
||||
const until = searchParams.get('until')
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
let p = 1
|
||||
|
||||
if (investorId) {
|
||||
conditions.push(`a.investor_id = $${p++}`)
|
||||
params.push(investorId)
|
||||
}
|
||||
if (targetInvestorId) {
|
||||
conditions.push(`a.target_investor_id = $${p++}`)
|
||||
params.push(targetInvestorId)
|
||||
}
|
||||
if (adminId) {
|
||||
conditions.push(`a.admin_id = $${p++}`)
|
||||
params.push(adminId)
|
||||
}
|
||||
if (actorType === 'admin') {
|
||||
conditions.push(`a.admin_id IS NOT NULL`)
|
||||
} else if (actorType === 'investor') {
|
||||
conditions.push(`a.investor_id IS NOT NULL`)
|
||||
}
|
||||
if (action) {
|
||||
conditions.push(`a.action = $${p++}`)
|
||||
params.push(action)
|
||||
}
|
||||
if (since) {
|
||||
conditions.push(`a.created_at >= $${p++}`)
|
||||
params.push(since)
|
||||
}
|
||||
if (until) {
|
||||
conditions.push(`a.created_at <= $${p++}`)
|
||||
params.push(until)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.*,
|
||||
i.email AS investor_email, i.name AS investor_name,
|
||||
ti.email AS target_investor_email, ti.name AS target_investor_name,
|
||||
ad.email AS admin_email, ad.name AS admin_name
|
||||
FROM pitch_audit_logs a
|
||||
LEFT JOIN pitch_investors i ON i.id = a.investor_id
|
||||
LEFT JOIN pitch_investors ti ON ti.id = a.target_investor_id
|
||||
LEFT JOIN pitch_admins ad ON ad.id = a.admin_id
|
||||
${where}
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT $${p++} OFFSET $${p++}`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
|
||||
const totalRes = await pool.query(
|
||||
`SELECT COUNT(*)::int AS total FROM pitch_audit_logs a ${where}`,
|
||||
params,
|
||||
)
|
||||
|
||||
return NextResponse.json({ logs: rows, total: totalRes.rows[0].total })
|
||||
}
|
||||
46
pitch-deck/app/api/admin/dashboard/route.ts
Normal file
46
pitch-deck/app/api/admin/dashboard/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const [totals, recentLogins, recentActivity] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM pitch_investors) AS total_investors,
|
||||
(SELECT COUNT(*)::int FROM pitch_investors WHERE status = 'invited') AS pending_invites,
|
||||
(SELECT COUNT(*)::int FROM pitch_investors WHERE last_login_at >= NOW() - INTERVAL '7 days') AS active_7d,
|
||||
(SELECT COUNT(*)::int FROM pitch_audit_logs WHERE action = 'slide_viewed') AS slides_viewed_total,
|
||||
(SELECT COUNT(*)::int FROM pitch_sessions WHERE revoked = false AND expires_at > NOW()) AS active_sessions,
|
||||
(SELECT COUNT(*)::int FROM pitch_admins WHERE is_active = true) AS active_admins
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT a.created_at, a.ip_address, i.id AS investor_id, i.email, i.name, i.company
|
||||
FROM pitch_audit_logs a
|
||||
JOIN pitch_investors i ON i.id = a.investor_id
|
||||
WHERE a.action = 'login_success'
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 10
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT a.id, a.action, a.created_at, a.details,
|
||||
i.email AS investor_email, i.name AS investor_name,
|
||||
ti.email AS target_investor_email,
|
||||
ad.email AS admin_email, ad.name AS admin_name
|
||||
FROM pitch_audit_logs a
|
||||
LEFT JOIN pitch_investors i ON i.id = a.investor_id
|
||||
LEFT JOIN pitch_investors ti ON ti.id = a.target_investor_id
|
||||
LEFT JOIN pitch_admins ad ON ad.id = a.admin_id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 15
|
||||
`),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
totals: totals.rows[0],
|
||||
recent_logins: recentLogins.rows,
|
||||
recent_activity: recentActivity.rows,
|
||||
})
|
||||
}
|
||||
93
pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts
Normal file
93
pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { value, min_value, max_value, step_size, label_de, label_en } = body
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT scenario_id, key, label_de, label_en, value, min_value, max_value, step_size
|
||||
FROM pitch_fm_assumptions WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Assumption not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: unknown[] = []
|
||||
let p = 1
|
||||
|
||||
if (value !== undefined) {
|
||||
updates.push(`value = $${p++}`)
|
||||
params.push(JSON.stringify(value))
|
||||
}
|
||||
if (min_value !== undefined) {
|
||||
updates.push(`min_value = $${p++}`)
|
||||
params.push(min_value)
|
||||
}
|
||||
if (max_value !== undefined) {
|
||||
updates.push(`max_value = $${p++}`)
|
||||
params.push(max_value)
|
||||
}
|
||||
if (step_size !== undefined) {
|
||||
updates.push(`step_size = $${p++}`)
|
||||
params.push(step_size)
|
||||
}
|
||||
if (typeof label_de === 'string') {
|
||||
updates.push(`label_de = $${p++}`)
|
||||
params.push(label_de)
|
||||
}
|
||||
if (typeof label_en === 'string') {
|
||||
updates.push(`label_en = $${p++}`)
|
||||
params.push(label_en)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_fm_assumptions SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||
params,
|
||||
)
|
||||
|
||||
// Invalidate cached results for this scenario so the next compute uses the new value
|
||||
await pool.query(`DELETE FROM pitch_fm_results WHERE scenario_id = $1`, [before.rows[0].scenario_id])
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'assumption_edited',
|
||||
{
|
||||
assumption_id: id,
|
||||
scenario_id: before.rows[0].scenario_id,
|
||||
key: before.rows[0].key,
|
||||
before: {
|
||||
value: typeof before.rows[0].value === 'string' ? JSON.parse(before.rows[0].value) : before.rows[0].value,
|
||||
min_value: before.rows[0].min_value,
|
||||
max_value: before.rows[0].max_value,
|
||||
step_size: before.rows[0].step_size,
|
||||
},
|
||||
after: {
|
||||
value: typeof rows[0].value === 'string' ? JSON.parse(rows[0].value) : rows[0].value,
|
||||
min_value: rows[0].min_value,
|
||||
max_value: rows[0].max_value,
|
||||
step_size: rows[0].step_size,
|
||||
},
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
return NextResponse.json({ assumption: rows[0] })
|
||||
}
|
||||
52
pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts
Normal file
52
pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, description, color } = body
|
||||
|
||||
if (name === undefined && description === undefined && color === undefined) {
|
||||
return NextResponse.json({ error: 'name, description, or color required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, description, color FROM pitch_fm_scenarios WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Scenario not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_fm_scenarios SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
color = COALESCE($3, color)
|
||||
WHERE id = $4
|
||||
RETURNING *`,
|
||||
[name ?? null, description ?? null, color ?? null, id],
|
||||
)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'scenario_edited',
|
||||
{
|
||||
scenario_id: id,
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, description: rows[0].description, color: rows[0].color },
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
return NextResponse.json({ scenario: rows[0] })
|
||||
}
|
||||
27
pitch-deck/app/api/admin/fm/scenarios/route.ts
Normal file
27
pitch-deck/app/api/admin/fm/scenarios/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const scenarios = await pool.query(
|
||||
`SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name`,
|
||||
)
|
||||
const assumptions = await pool.query(
|
||||
`SELECT * FROM pitch_fm_assumptions ORDER BY scenario_id, 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({ scenarios: result })
|
||||
}
|
||||
60
pitch-deck/app/api/admin/investors/[id]/resend/route.ts
Normal file
60
pitch-deck/app/api/admin/investors/[id]/resend/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, status FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const investor = rows[0]
|
||||
if (investor.status === 'revoked') {
|
||||
return NextResponse.json({ error: 'Investor is revoked. Reactivate first by re-inviting.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Rate limit by email
|
||||
const rl = checkRateLimit(`magic-link:${investor.email}`, RATE_LIMITS.magicLink)
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many resends for this email. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
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 logAdminAudit(
|
||||
adminId,
|
||||
'magic_link_resent',
|
||||
{ email: investor.email, expires_at: expiresAt.toISOString() },
|
||||
request,
|
||||
investor.id,
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, expires_at: expiresAt.toISOString() })
|
||||
}
|
||||
99
pitch-deck/app/api/admin/investors/[id]/route.ts
Normal file
99
pitch-deck/app/api/admin/investors/[id]/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
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`,
|
||||
[id],
|
||||
),
|
||||
pool.query(
|
||||
`SELECT id, ip_address, user_agent, expires_at, revoked, created_at
|
||||
FROM pitch_sessions WHERE investor_id = $1
|
||||
ORDER BY created_at DESC LIMIT 50`,
|
||||
[id],
|
||||
),
|
||||
pool.query(
|
||||
`SELECT id, scenario_id, label, is_latest, created_at
|
||||
FROM pitch_investor_snapshots WHERE investor_id = $1
|
||||
ORDER BY created_at DESC LIMIT 50`,
|
||||
[id],
|
||||
),
|
||||
pool.query(
|
||||
`SELECT a.id, a.action, a.created_at, a.details, a.ip_address, a.slide_id,
|
||||
ad.email AS admin_email, ad.name AS admin_name
|
||||
FROM pitch_audit_logs a
|
||||
LEFT JOIN pitch_admins ad ON ad.id = a.admin_id
|
||||
WHERE a.investor_id = $1 OR a.target_investor_id = $1
|
||||
ORDER BY a.created_at DESC LIMIT 100`,
|
||||
[id],
|
||||
),
|
||||
])
|
||||
|
||||
if (investor.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
investor: investor.rows[0],
|
||||
sessions: sessions.rows,
|
||||
snapshots: snapshots.rows,
|
||||
audit: audit.rows,
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, company } = body
|
||||
|
||||
if (name === undefined && company === undefined) {
|
||||
return NextResponse.json({ error: 'name or company required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, company FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_investors SET
|
||||
name = COALESCE($1, name),
|
||||
company = COALESCE($2, company),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, email, name, company, status`,
|
||||
[name ?? null, company ?? null, id],
|
||||
)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'investor_edited',
|
||||
{
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, company: rows[0].company },
|
||||
},
|
||||
request,
|
||||
id,
|
||||
)
|
||||
|
||||
return NextResponse.json({ investor: rows[0] })
|
||||
}
|
||||
18
pitch-deck/app/api/admin/investors/route.ts
Normal file
18
pitch-deck/app/api/admin/investors/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
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,
|
||||
(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
|
||||
ORDER BY i.created_at DESC`,
|
||||
)
|
||||
|
||||
return NextResponse.json({ investors: rows })
|
||||
}
|
||||
73
pitch-deck/app/api/admin/invite/route.ts
Normal file
73
pitch-deck/app/api/admin/invite/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
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 { email, name, company } = body
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Rate limit by email (3/hour)
|
||||
const rl = checkRateLimit(`magic-link:${email.toLowerCase()}`, RATE_LIMITS.magicLink)
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many invites for this email. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Upsert investor
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_investors (email, name, company)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = COALESCE(EXCLUDED.name, pitch_investors.name),
|
||||
company = COALESCE(EXCLUDED.company, pitch_investors.company),
|
||||
status = CASE WHEN pitch_investors.status = 'revoked' THEN 'invited' ELSE pitch_investors.status END,
|
||||
updated_at = NOW()
|
||||
RETURNING id, status`,
|
||||
[normalizedEmail, name || null, company || null],
|
||||
)
|
||||
|
||||
const investor = rows[0]
|
||||
|
||||
// Generate magic link
|
||||
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(normalizedEmail, name || null, magicLinkUrl)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'investor_invited',
|
||||
{ email: normalizedEmail, name: name || null, company: company || null, expires_at: expiresAt.toISOString() },
|
||||
request,
|
||||
investor.id,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
investor_id: investor.id,
|
||||
email: normalizedEmail,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
})
|
||||
}
|
||||
32
pitch-deck/app/api/admin/revoke/route.ts
Normal file
32
pitch-deck/app/api/admin/revoke/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { revokeAllSessions } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
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 { investor_id } = body
|
||||
|
||||
if (!investor_id) {
|
||||
return NextResponse.json({ error: 'investor_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_investors SET status = 'revoked', updated_at = NOW()
|
||||
WHERE id = $1 RETURNING email`,
|
||||
[investor_id],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await revokeAllSessions(investor_id)
|
||||
await logAdminAudit(adminId, 'investor_revoked', { email: rows[0].email }, request, investor_id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
26
pitch-deck/app/api/audit/route.ts
Normal file
26
pitch-deck/app/api/audit/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSessionFromCookie, logAudit } from '@/lib/auth'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSessionFromCookie()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { action, details, slide_id } = body
|
||||
|
||||
if (!action || typeof action !== 'string') {
|
||||
return NextResponse.json({ error: 'action required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only allow known client-side actions
|
||||
const allowedActions = ['slide_viewed', 'assumption_changed', 'chat_message_sent', 'snapshot_saved', 'snapshot_restored']
|
||||
if (!allowedActions.includes(action)) {
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
|
||||
await logAudit(session.sub, action, details || {}, request, slide_id, session.sessionId)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
14
pitch-deck/app/api/auth/logout/route.ts
Normal file
14
pitch-deck/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSessionFromCookie, revokeSession, clearSessionCookie, logAudit } from '@/lib/auth'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSessionFromCookie()
|
||||
|
||||
if (session) {
|
||||
await revokeSession(session.sessionId)
|
||||
await logAudit(session.sub, 'logout', {}, request, undefined, session.sessionId)
|
||||
}
|
||||
|
||||
await clearSessionCookie()
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
27
pitch-deck/app/api/auth/me/route.ts
Normal file
27
pitch-deck/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie, validateSession } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionFromCookie()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const valid = await validateSession(session.sessionId, session.sub)
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: 'Session expired' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at
|
||||
FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub]
|
||||
)
|
||||
|
||||
if (rows.length === 0 || rows[0].status === 'revoked') {
|
||||
return NextResponse.json({ error: 'Access revoked' }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ investor: rows[0] })
|
||||
}
|
||||
85
pitch-deck/app/api/auth/verify/route.ts
Normal file
85
pitch-deck/app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { createSession, setSessionCookie, getClientIp, logAudit, hashToken } from '@/lib/auth'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request) || 'unknown'
|
||||
|
||||
// Rate limit by IP
|
||||
const rl = checkRateLimit(`auth-verify:${ip}`, RATE_LIMITS.authVerify)
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { token } = body
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
return NextResponse.json({ error: 'Token required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Find the magic link
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status
|
||||
FROM pitch_magic_links ml
|
||||
JOIN pitch_investors i ON i.id = ml.investor_id
|
||||
WHERE ml.token = $1`,
|
||||
[token]
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await logAudit(null, 'login_failed', { reason: 'invalid_token' }, request)
|
||||
return NextResponse.json({ error: 'Invalid link' }, { status: 401 })
|
||||
}
|
||||
|
||||
const link = rows[0]
|
||||
|
||||
if (link.used_at) {
|
||||
await logAudit(link.investor_id, 'login_failed', { reason: 'token_already_used' }, request)
|
||||
return NextResponse.json({ error: 'This link has already been used. Please request a new one.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (new Date(link.expires_at) < new Date()) {
|
||||
await logAudit(link.investor_id, 'login_failed', { reason: 'token_expired' }, request)
|
||||
return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (link.investor_status === 'revoked') {
|
||||
await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request)
|
||||
return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const ua = request.headers.get('user-agent')
|
||||
|
||||
// Mark token as used
|
||||
await pool.query(
|
||||
`UPDATE pitch_magic_links SET used_at = NOW(), ip_address = $1, user_agent = $2 WHERE id = $3`,
|
||||
[ip, ua, link.id]
|
||||
)
|
||||
|
||||
// Activate investor if first login
|
||||
await pool.query(
|
||||
`UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[link.investor_id]
|
||||
)
|
||||
|
||||
// Create session and set cookie
|
||||
const { sessionId, jwt } = await createSession(link.investor_id, ip, ua)
|
||||
await setSessionCookie(jwt)
|
||||
|
||||
// Check for new IP
|
||||
const { rows: prevSessions } = await pool.query(
|
||||
`SELECT DISTINCT ip_address FROM pitch_sessions WHERE investor_id = $1 AND id != $2 AND ip_address IS NOT NULL`,
|
||||
[link.investor_id, sessionId]
|
||||
)
|
||||
const knownIps = prevSessions.map((r: { ip_address: string }) => r.ip_address)
|
||||
if (knownIps.length > 0 && !knownIps.includes(ip)) {
|
||||
await logAudit(link.investor_id, 'new_ip_detected', { ip, known_ips: knownIps }, request, undefined, sessionId)
|
||||
}
|
||||
|
||||
await logAudit(link.investor_id, 'login_success', { email: link.email }, request, undefined, sessionId)
|
||||
|
||||
return NextResponse.json({ success: true, redirect: '/' })
|
||||
}
|
||||
@@ -1,8 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:32b'
|
||||
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
|
||||
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
|
||||
const LITELLM_API_KEY = process.env.LITELLM_API_KEY || ''
|
||||
|
||||
// Build SLIDE_NAMES dynamically from SLIDE_ORDER
|
||||
const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
|
||||
'intro-presenter': { de: 'Intro', en: 'Intro' },
|
||||
'cover': { de: 'Cover', en: 'Cover' },
|
||||
'problem': { de: 'Das Problem', en: 'The Problem' },
|
||||
'solution': { de: 'Die Loesung', en: 'The Solution' },
|
||||
'product': { de: 'Produkte', en: 'Products' },
|
||||
'how-it-works': { de: 'So funktionierts', en: 'How It Works' },
|
||||
'market': { de: 'Markt', en: 'Market' },
|
||||
'business-model': { de: 'Geschaeftsmodell', en: 'Business Model' },
|
||||
'traction': { de: 'Traction', en: 'Traction' },
|
||||
'competition': { de: 'Wettbewerb', en: 'Competition' },
|
||||
'team': { de: 'Team', en: 'Team' },
|
||||
'financials': { de: 'Finanzen', en: 'Financials' },
|
||||
'the-ask': { de: 'The Ask', en: 'The Ask' },
|
||||
'ai-qa': { de: 'KI Q&A', en: 'AI Q&A' },
|
||||
'annex-assumptions': { de: 'Anhang: Annahmen', en: 'Appendix: Assumptions' },
|
||||
'annex-architecture': { de: 'Anhang: Architektur', en: 'Appendix: Architecture' },
|
||||
'annex-gtm': { de: 'Anhang: Go-to-Market', en: 'Appendix: Go-to-Market' },
|
||||
'annex-regulatory': { de: 'Anhang: Regulatorik', en: 'Appendix: Regulatory' },
|
||||
'annex-engineering': { de: 'Anhang: Engineering', en: 'Appendix: Engineering' },
|
||||
'annex-aipipeline': { de: 'Anhang: KI-Pipeline', en: 'Appendix: AI Pipeline' },
|
||||
}
|
||||
|
||||
const slideCount = SLIDE_ORDER.length
|
||||
|
||||
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
|
||||
|
||||
@@ -40,7 +68,8 @@ Stattdessen: "Proprietaere KI-Engine", "Self-Hosted Appliance auf Apple-Hardware
|
||||
## Slide-Awareness (IMMER beachten)
|
||||
Du erhaeltst den aktuellen Slide-Kontext. Nutze ihn fuer kontextuelle Antworten.
|
||||
Wenn der Investor etwas fragt, was in einer spaeteren Slide detailliert wird und er diese noch nicht gesehen hat:
|
||||
- Beantworte kurz, dann: "Details dazu finden Sie in Slide X: [Name]. Moechten Sie dorthin springen? [GOTO:X]"
|
||||
- Beantworte kurz, dann: "Details dazu finden Sie in Slide X: [Name]. Moechten Sie dorthin springen? [GOTO:slide-id]"
|
||||
- Verwende [GOTO:slide-id] mit der Slide-ID (z.B. [GOTO:financials], [GOTO:competition])
|
||||
|
||||
## FOLLOW-UP FRAGEN — KRITISCHE PFLICHT
|
||||
|
||||
@@ -118,47 +147,65 @@ ${JSON.stringify(features.rows, null, 2)}
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { message, history = [], lang = 'de', slideContext } = body
|
||||
const { message, history = [], lang = 'de', slideContext, faqAnswer } = body
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// FAQ shortcut: if client sends a pre-cached FAQ answer, stream it directly (no LLM call)
|
||||
if (faqAnswer && typeof faqAnswer === 'string') {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Stream the FAQ answer in chunks for consistent UX
|
||||
const words = faqAnswer.split(' ')
|
||||
let i = 0
|
||||
const interval = setInterval(() => {
|
||||
if (i < words.length) {
|
||||
const chunk = (i === 0 ? '' : ' ') + words[i]
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
i++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
controller.close()
|
||||
}
|
||||
}, 30)
|
||||
},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const pitchContext = await loadPitchContext()
|
||||
|
||||
let systemContent = SYSTEM_PROMPT
|
||||
if (pitchContext) {
|
||||
systemContent += '\n' + pitchContext
|
||||
}
|
||||
|
||||
// Slide context for contextual awareness
|
||||
if (slideContext) {
|
||||
const SLIDE_NAMES: Record<string, { de: string; en: string; index: number }> = {
|
||||
'cover': { de: 'Cover', en: 'Cover', index: 0 },
|
||||
'problem': { de: 'Das Problem', en: 'The Problem', index: 1 },
|
||||
'solution': { de: 'Die Loesung', en: 'The Solution', index: 2 },
|
||||
'product': { de: 'Produkte', en: 'Products', index: 3 },
|
||||
'how-it-works': { de: 'So funktionierts', en: 'How It Works', index: 4 },
|
||||
'market': { de: 'Markt', en: 'Market', index: 5 },
|
||||
'business-model': { de: 'Geschaeftsmodell', en: 'Business Model', index: 6 },
|
||||
'traction': { de: 'Traction', en: 'Traction', index: 7 },
|
||||
'competition': { de: 'Wettbewerb', en: 'Competition', index: 8 },
|
||||
'team': { de: 'Team', en: 'Team', index: 9 },
|
||||
'financials': { de: 'Finanzen', en: 'Financials', index: 10 },
|
||||
'the-ask': { de: 'The Ask', en: 'The Ask', index: 11 },
|
||||
'ai-qa': { de: 'KI Q&A', en: 'AI Q&A', index: 12 },
|
||||
}
|
||||
const slideKeys = Object.keys(SLIDE_NAMES)
|
||||
const visited: number[] = slideContext.visitedSlides || []
|
||||
const currentSlideName = SLIDE_NAMES[slideContext.currentSlide]?.[lang] || slideContext.currentSlide
|
||||
const notYetSeen = Object.entries(SLIDE_NAMES)
|
||||
.filter(([, v]) => !visited.includes(v.index))
|
||||
.map(([, v]) => `${v.index + 1}. ${v[lang]}`)
|
||||
const currentSlideId = slideContext.currentSlide
|
||||
const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId
|
||||
const notYetSeen = SLIDE_ORDER
|
||||
.map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id }))
|
||||
.filter(s => !visited.includes(s.idx))
|
||||
.map(s => `${s.idx + 1}. ${s.name}`)
|
||||
|
||||
systemContent += `\n\n## Slide-Kontext (WICHTIG fuer kontextuelle Antworten)
|
||||
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von 13)
|
||||
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_NAMES[slideKeys[i]]?.[lang]).filter(Boolean).join(', ')}
|
||||
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${slideCount})
|
||||
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[SLIDE_ORDER[i]]?.[lang] || SLIDE_ORDER[i]).filter(Boolean).join(', ')}
|
||||
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
|
||||
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geoeffnet' : 'Nein'}
|
||||
- Verfuegbare Slide-IDs fuer [GOTO:id]: ${SLIDE_ORDER.join(', ')}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -173,54 +220,84 @@ export async function POST(request: NextRequest) {
|
||||
{ role: 'user', content: message + '\n\n(Erinnerung: Beende deine Antwort IMMER mit "---" gefolgt von 3 Folgefragen im Format "[Q] Frage?")' },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
// LiteLLM (OpenAI-compatible API)
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (LITELLM_API_KEY) {
|
||||
headers['Authorization'] = `Bearer ${LITELLM_API_KEY}`
|
||||
}
|
||||
|
||||
const llmResponse = await fetch(`${LITELLM_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
model: LITELLM_MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: 0.4,
|
||||
num_predict: 4096,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
temperature: 0.4,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
const errorText = await ollamaResponse.text()
|
||||
console.error('Ollama error:', ollamaResponse.status, errorText)
|
||||
if (!llmResponse.ok) {
|
||||
const errorText = await llmResponse.text()
|
||||
console.error('LiteLLM error:', llmResponse.status, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status}).` },
|
||||
{ error: `LLM nicht erreichbar (Status ${llmResponse.status}).` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse SSE stream from LiteLLM and emit plain text to client
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = ollamaResponse.body!.getReader()
|
||||
const reader = llmResponse.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const lines = chunk.split('\n').filter((l) => l.trim())
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
// Keep the last (potentially incomplete) line in the buffer
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||
const data = trimmed.slice(6)
|
||||
if (data === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const json = JSON.parse(line)
|
||||
if (json.message?.content) {
|
||||
controller.enqueue(encoder.encode(json.message.content))
|
||||
const json = JSON.parse(data)
|
||||
const content = json.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
controller.enqueue(encoder.encode(content))
|
||||
}
|
||||
} catch {
|
||||
// Partial JSON line, skip
|
||||
// Partial JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
const trimmed = buffer.trim()
|
||||
if (trimmed.startsWith('data: ') && trimmed.slice(6) !== '[DONE]') {
|
||||
try {
|
||||
const json = JSON.parse(trimmed.slice(6))
|
||||
const content = json.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
controller.enqueue(encoder.encode(content))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
pitch-deck/app/api/health/route.ts
Normal file
5
pitch-deck/app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
}
|
||||
15
pitch-deck/app/api/presenter/status/route.ts
Normal file
15
pitch-deck/app/api/presenter/status/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Presenter Status API — for future multi-client sync.
|
||||
* Currently returns a static structure; will be connected to
|
||||
* server-side presenter state when WebSocket support is added.
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
state: 'idle',
|
||||
currentSlide: 0,
|
||||
progress: 0,
|
||||
message: 'Presenter status endpoint ready. State is managed client-side for now.',
|
||||
})
|
||||
}
|
||||
72
pitch-deck/app/api/snapshots/route.ts
Normal file
72
pitch-deck/app/api/snapshots/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionFromCookie()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, scenario_id, assumptions, label, is_latest, created_at
|
||||
FROM pitch_investor_snapshots
|
||||
WHERE investor_id = $1 AND is_latest = true
|
||||
ORDER BY created_at DESC`,
|
||||
[session.sub]
|
||||
)
|
||||
|
||||
return NextResponse.json({ snapshots: rows })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSessionFromCookie()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { scenario_id, assumptions, label } = body
|
||||
|
||||
if (!scenario_id || !assumptions) {
|
||||
return NextResponse.json({ error: 'scenario_id and assumptions required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Mark previous latest as not latest
|
||||
await pool.query(
|
||||
`UPDATE pitch_investor_snapshots SET is_latest = false
|
||||
WHERE investor_id = $1 AND scenario_id = $2 AND is_latest = true`,
|
||||
[session.sub, scenario_id]
|
||||
)
|
||||
|
||||
// Insert new snapshot
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_investor_snapshots (investor_id, scenario_id, assumptions, label, is_latest)
|
||||
VALUES ($1, $2, $3, $4, true)
|
||||
RETURNING id, created_at`,
|
||||
[session.sub, scenario_id, JSON.stringify(assumptions), label || null]
|
||||
)
|
||||
|
||||
return NextResponse.json({ snapshot: rows[0] })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await getSessionFromCookie()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Snapshot id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM pitch_investor_snapshots WHERE id = $1 AND investor_id = $2`,
|
||||
[id, session.sub]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
57
pitch-deck/app/auth/page.tsx
Normal file
57
pitch-deck/app/auth/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function AuthPage() {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="relative z-10 text-center max-w-md mx-auto px-6"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent mb-2">
|
||||
BreakPilot ComplAI
|
||||
</h1>
|
||||
<p className="text-white/30 text-sm">Investor Pitch Deck</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-indigo-500/10 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5-1.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75A2.25 2.25 0 014.5 4.5h15a2.25 2.25 0 012.25 2.25v11.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-white/90 mb-3">
|
||||
Invitation Required
|
||||
</h2>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-5">
|
||||
<p className="text-white/30 text-xs">
|
||||
Questions? Contact us at{' '}
|
||||
<a href="mailto:pitch@breakpilot.ai" className="text-indigo-400/80 hover:text-indigo-400 transition-colors">
|
||||
pitch@breakpilot.ai
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-white/20 text-xs">
|
||||
We are an AI-first company. No PDFs. No slide decks. Just code.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
pitch-deck/app/auth/verify/page.tsx
Normal file
112
pitch-deck/app/auth/verify/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
function VerifyContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setErrorMsg('No access token provided.')
|
||||
return
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('success')
|
||||
setTimeout(() => router.push('/'), 1000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setStatus('error')
|
||||
setErrorMsg(data.error || 'Verification failed.')
|
||||
}
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setErrorMsg('Network error. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
verify()
|
||||
}, [token, router])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="relative z-10 text-center max-w-md mx-auto px-6"
|
||||
>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm">
|
||||
{status === 'verifying' && (
|
||||
<>
|
||||
<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/60">Verifying your access link...</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white/80 font-medium">Access verified!</p>
|
||||
<p className="text-white/40 text-sm mt-2">Redirecting to pitch deck...</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white/80 font-medium mb-2">Access Denied</p>
|
||||
<p className="text-white/50 text-sm">{errorMsg}</p>
|
||||
<a
|
||||
href="/auth"
|
||||
className="inline-block mt-6 text-indigo-400 text-sm hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
Back to login
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="relative z-10 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/60">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BreakPilot ComplAI — Investor Pitch Deck',
|
||||
description: 'Datensouveraenitaet meets KI-Compliance. Pre-Seed Q4 2026.',
|
||||
manifest: '/manifest.json',
|
||||
robots: { index: false, follow: false },
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'BreakPilot Pitch',
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#6366f1',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -13,8 +27,22 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="de" className="dark">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
</head>
|
||||
<body className="bg-[#0a0a1a] text-white antialiased overflow-hidden h-screen">
|
||||
{children}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { useAuth } from '@/lib/hooks/useAuth'
|
||||
import PitchDeck from '@/components/PitchDeck'
|
||||
|
||||
export default function Home() {
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
const { investor, loading, logout } = useAuth()
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
||||
}, [])
|
||||
|
||||
return <PitchDeck lang={lang} onToggleLanguage={toggleLanguage} />
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PitchDeck lang={lang} onToggleLanguage={toggleLanguage} investor={investor} onLogout={logout} />
|
||||
}
|
||||
|
||||
259
pitch-deck/app/pitch-admin/(authed)/admins/page.tsx
Normal file
259
pitch-deck/app/pitch-admin/(authed)/admins/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Power, Key } from 'lucide-react'
|
||||
|
||||
interface Admin {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function AdminsPage() {
|
||||
const [admins, setAdmins] = useState<Admin[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const [resetId, setResetId] = useState<string | null>(null)
|
||||
const [resetPassword, setResetPassword] = useState('')
|
||||
|
||||
function flashToast(msg: string) {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/admins')
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
setAdmins(d.admins || [])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function createAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
const res = await fetch('/api/admin/admins', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: newEmail, name: newName, password: newPassword }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
setShowAdd(false)
|
||||
setNewEmail(''); setNewName(''); setNewPassword('')
|
||||
flashToast('Admin created')
|
||||
load()
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
setError(d.error || 'Create failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(a: Admin) {
|
||||
if (!confirm(`${a.is_active ? 'Deactivate' : 'Activate'} ${a.email}?`)) return
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/admins/${a.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: !a.is_active }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
flashToast(a.is_active ? 'Deactivated' : 'Activated')
|
||||
load()
|
||||
} else {
|
||||
flashToast('Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!resetId) return
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/admins/${resetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: resetPassword }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
flashToast('Password reset')
|
||||
setResetId(null)
|
||||
setResetPassword('')
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
flashToast(d.error || 'Reset failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Admins</h1>
|
||||
<p className="text-sm text-white/50 mt-1">{admins.length} total</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(s => !s)}
|
||||
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" /> Add admin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<form onSubmit={createAdmin} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-5 space-y-3">
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
required
|
||||
placeholder="email@breakpilot.ai"
|
||||
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
required
|
||||
placeholder="Name"
|
||||
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
placeholder="Password (min 12 chars)"
|
||||
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
</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-2">
|
||||
<button type="button" onClick={() => { setShowAdd(false); setError('') }} className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</button>
|
||||
<button type="submit" disabled={busy} className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
|
||||
{busy ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
) : (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-white/40 border-b border-white/[0.06]">
|
||||
<th className="py-3 px-4 font-medium">Admin</th>
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium">Last login</th>
|
||||
<th className="py-3 px-4 font-medium">Created</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{admins.map(a => (
|
||||
<tr key={a.id} className="border-b border-white/[0.04]">
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-white/90 font-medium">{a.name}</div>
|
||||
<div className="text-xs text-white/40">{a.email}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
|
||||
a.is_active
|
||||
? 'bg-green-500/15 text-green-300 border-green-500/30'
|
||||
: 'bg-rose-500/15 text-rose-300 border-rose-500/30'
|
||||
}`}>
|
||||
{a.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-white/60 text-xs">
|
||||
{a.last_login_at ? new Date(a.last_login_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-white/60 text-xs">
|
||||
{new Date(a.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => { setResetId(a.id); setResetPassword('') }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-amber-500/15 hover:text-amber-300"
|
||||
title="Reset password"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleActive(a)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300"
|
||||
title={a.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset password modal */}
|
||||
{resetId && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setResetId(null)}>
|
||||
<form
|
||||
onSubmit={submitResetPassword}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-[#0a0a1a] border border-white/[0.1] rounded-2xl p-6 w-full max-w-sm space-y-4"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white">Reset Password</h3>
|
||||
<p className="text-sm text-white/60">
|
||||
The admin's active sessions will be revoked.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={resetPassword}
|
||||
onChange={e => setResetPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
autoFocus
|
||||
placeholder="New password (min 12 chars)"
|
||||
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 className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setResetId(null)} className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</button>
|
||||
<button type="submit" disabled={busy} className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
|
||||
{busy ? 'Saving…' : 'Reset'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
pitch-deck/app/pitch-admin/(authed)/audit/page.tsx
Normal file
130
pitch-deck/app/pitch-admin/(authed)/audit/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import AuditLogTable, { AuditLogRow } from '@/components/pitch-admin/AuditLogTable'
|
||||
|
||||
const ACTIONS = [
|
||||
'', // any
|
||||
'login_success',
|
||||
'login_failed',
|
||||
'logout',
|
||||
'admin_login_success',
|
||||
'admin_login_failed',
|
||||
'admin_logout',
|
||||
'slide_viewed',
|
||||
'assumption_changed',
|
||||
'assumption_edited',
|
||||
'scenario_edited',
|
||||
'investor_invited',
|
||||
'magic_link_resent',
|
||||
'investor_revoked',
|
||||
'investor_edited',
|
||||
'admin_created',
|
||||
'admin_edited',
|
||||
'admin_deactivated',
|
||||
'new_ip_detected',
|
||||
]
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function AuditPage() {
|
||||
const [logs, setLogs] = useState<AuditLogRow[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actorType, setActorType] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (actorType) params.set('actor_type', actorType)
|
||||
if (action) params.set('action', action)
|
||||
params.set('limit', String(PAGE_SIZE))
|
||||
params.set('offset', String(page * PAGE_SIZE))
|
||||
|
||||
const res = await fetch(`/api/admin/audit-logs?${params.toString()}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLogs(data.logs)
|
||||
setTotal(data.total)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [actorType, action, page])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Audit Log</h1>
|
||||
<p className="text-sm text-white/50 mt-1">{total} total events</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={actorType}
|
||||
onChange={(e) => { setActorType(e.target.value); setPage(0) }}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
>
|
||||
<option value="">All actors</option>
|
||||
<option value="admin">Admins only</option>
|
||||
<option value="investor">Investors only</option>
|
||||
</select>
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => { setAction(e.target.value); setPage(0) }}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 max-w-[260px]"
|
||||
>
|
||||
{ACTIONS.map(a => (
|
||||
<option key={a} value={a}>{a || 'All actions'}</option>
|
||||
))}
|
||||
</select>
|
||||
{(actorType || action) && (
|
||||
<button
|
||||
onClick={() => { setActorType(''); setAction(''); setPage(0) }}
|
||||
className="text-sm text-white/50 hover:text-white px-3 py-2"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<AuditLogTable rows={logs} showActor />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="text-white/50">
|
||||
Page {page + 1} of {totalPages}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white px-3 py-1.5 rounded-lg disabled:opacity-30"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white px-3 py-1.5 rounded-lg disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Save } from 'lucide-react'
|
||||
|
||||
interface Assumption {
|
||||
id: string
|
||||
scenario_id: string
|
||||
key: string
|
||||
label_de: string
|
||||
label_en: string
|
||||
value: number | number[]
|
||||
value_type: 'scalar' | 'step' | 'timeseries'
|
||||
unit: string
|
||||
min_value: number | null
|
||||
max_value: number | null
|
||||
step_size: number | null
|
||||
category: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
is_default: boolean
|
||||
color: string
|
||||
assumptions: Assumption[]
|
||||
}
|
||||
|
||||
export default function EditScenarioPage() {
|
||||
const { scenarioId } = useParams<{ scenarioId: string }>()
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [savingId, setSavingId] = 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/fm/scenarios')
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
const found = (d.scenarios as Scenario[]).find(s => s.id === scenarioId)
|
||||
setScenario(found || null)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { if (scenarioId) load() }, [scenarioId])
|
||||
|
||||
function setEdit(id: string, val: string) {
|
||||
setEdits(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
|
||||
async function saveAssumption(a: Assumption) {
|
||||
const raw = edits[a.id]
|
||||
if (raw === undefined) return
|
||||
let parsed: number | number[]
|
||||
try {
|
||||
parsed = a.value_type === 'timeseries' ? JSON.parse(raw) : Number(raw)
|
||||
if (a.value_type !== 'timeseries' && !Number.isFinite(parsed)) throw new Error('not a number')
|
||||
} catch {
|
||||
flashToast('Invalid value')
|
||||
return
|
||||
}
|
||||
|
||||
setSavingId(a.id)
|
||||
const res = await fetch(`/api/admin/fm/assumptions/${a.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: parsed }),
|
||||
})
|
||||
setSavingId(null)
|
||||
if (res.ok) {
|
||||
flashToast('Saved')
|
||||
setEdits(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[a.id]
|
||||
return next
|
||||
})
|
||||
load()
|
||||
} else {
|
||||
flashToast('Save 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 (!scenario) return <div className="text-rose-400">Scenario not found</div>
|
||||
|
||||
// Group by category
|
||||
const byCategory: Record<string, Assumption[]> = {}
|
||||
scenario.assumptions.forEach(a => {
|
||||
if (!byCategory[a.category]) byCategory[a.category] = []
|
||||
byCategory[a.category].push(a)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/pitch-admin/financial-model" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to scenarios
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: scenario.color }} />
|
||||
<h1 className="text-2xl font-semibold text-white">{scenario.name}</h1>
|
||||
{scenario.is_default && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 uppercase font-semibold">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{scenario.description && <p className="text-sm text-white/50">{scenario.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(byCategory).map(([cat, items]) => (
|
||||
<section key={cat} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-white/50 mb-4">{cat}</h2>
|
||||
<div className="space-y-3">
|
||||
{items.map(a => {
|
||||
const isEdited = edits[a.id] !== undefined
|
||||
const currentValue = isEdited
|
||||
? edits[a.id]
|
||||
: a.value_type === 'timeseries'
|
||||
? JSON.stringify(a.value)
|
||||
: String(a.value)
|
||||
|
||||
return (
|
||||
<div key={a.id} className="grid grid-cols-12 gap-3 items-center">
|
||||
<div className="col-span-5 min-w-0">
|
||||
<div className="text-sm text-white/90 truncate">{a.label_en || a.label_de}</div>
|
||||
<div className="text-xs text-white/40 font-mono truncate">{a.key}</div>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue}
|
||||
onChange={e => setEdit(a.id, e.target.value)}
|
||||
className={`flex-1 bg-black/30 border rounded-lg px-3 py-1.5 text-sm text-white font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500/40 ${
|
||||
isEdited ? 'border-amber-500/50' : 'border-white/10'
|
||||
}`}
|
||||
/>
|
||||
{a.unit && <span className="text-xs text-white/40">{a.unit}</span>}
|
||||
</div>
|
||||
<div className="col-span-2 text-xs text-white/30 font-mono">
|
||||
{a.min_value !== null && a.max_value !== null ? `${a.min_value}–${a.max_value}` : ''}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end">
|
||||
{isEdited && (
|
||||
<button
|
||||
onClick={() => saveAssumption(a)}
|
||||
disabled={savingId === a.id}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white p-1.5 rounded-lg disabled:opacity-50"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
pitch-deck/app/pitch-admin/(authed)/financial-model/page.tsx
Normal file
73
pitch-deck/app/pitch-admin/(authed)/financial-model/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
interface Scenario {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
is_default: boolean
|
||||
color: string
|
||||
assumptions: Array<{ id: string; key: string }>
|
||||
}
|
||||
|
||||
export default function FinancialModelPage() {
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/fm/scenarios')
|
||||
.then(r => r.json())
|
||||
.then(d => setScenarios(d.scenarios || []))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Financial Model</h1>
|
||||
<p className="text-sm text-white/50 mt-1">
|
||||
Edit default scenarios and assumptions. Investor snapshots are not affected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{scenarios.map(s => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/pitch-admin/financial-model/${s.id}`}
|
||||
className="bg-white/[0.04] border border-white/[0.06] hover:border-white/[0.15] rounded-2xl p-5 transition-colors block"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<h3 className="text-base font-semibold text-white">{s.name}</h3>
|
||||
{s.is_default && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 uppercase font-semibold">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.description && <p className="text-sm text-white/50">{s.description}</p>}
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-white/30 mt-1 shrink-0" />
|
||||
</div>
|
||||
<div className="text-xs text-white/40">
|
||||
{s.assumptions.length} assumption{s.assumptions.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
252
pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx
Normal file
252
pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Mail, Ban, Save } from 'lucide-react'
|
||||
import AuditLogTable from '@/components/pitch-admin/AuditLogTable'
|
||||
|
||||
interface InvestorDetail {
|
||||
investor: {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: string
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
sessions: Array<{
|
||||
id: string
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
expires_at: string
|
||||
revoked: boolean
|
||||
created_at: string
|
||||
}>
|
||||
snapshots: Array<{
|
||||
id: string
|
||||
scenario_id: string
|
||||
label: string | null
|
||||
is_latest: boolean
|
||||
created_at: string
|
||||
}>
|
||||
audit: Array<{
|
||||
id: number
|
||||
action: string
|
||||
created_at: string
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
slide_id: string | null
|
||||
admin_email: string | null
|
||||
admin_name: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
|
||||
active: 'bg-green-500/15 text-green-300 border-green-500/30',
|
||||
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
|
||||
}
|
||||
|
||||
export default function InvestorDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<InvestorDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [company, setCompany] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
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/investors/${id}`)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
setData(d)
|
||||
setName(d.investor.name || '')
|
||||
setCompany(d.investor.company || '')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { if (id) load() }, [id])
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, company }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
setEditing(false)
|
||||
flashToast('Saved')
|
||||
load()
|
||||
} else {
|
||||
flashToast('Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
flashToast('Magic link resent')
|
||||
load()
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
flashToast(err.error || 'Resend failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!confirm('Revoke this investor\'s access? This signs them out and prevents future logins.')) return
|
||||
setBusy(true)
|
||||
const res = await fetch('/api/admin/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ investor_id: id }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
flashToast('Revoked')
|
||||
load()
|
||||
} else {
|
||||
flashToast('Revoke 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 (!data) return <div className="text-rose-400">Investor not found</div>
|
||||
|
||||
const inv = data.investor
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/pitch-admin/investors" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to investors
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
{editing ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white text-lg font-semibold focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
<input
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Company"
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-1 flex-wrap">
|
||||
<h1 className="text-2xl font-semibold text-white">{inv.name || inv.email}</h1>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status]}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-white/60">{inv.company || '—'}</div>
|
||||
<div className="text-xs text-white/40 mt-1">{inv.email}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setName(inv.name || ''); setCompany(inv.company || '') }}
|
||||
className="text-sm text-white/60 hover:text-white px-3 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" /> Save
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={resend}
|
||||
disabled={busy || inv.status === 'revoked'}
|
||||
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
>
|
||||
<Mail className="w-4 h-4" /> Resend Link
|
||||
</button>
|
||||
<button
|
||||
onClick={revoke}
|
||||
disabled={busy || inv.status === 'revoked'}
|
||||
className="bg-rose-500/15 hover:bg-rose-500/25 text-rose-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
>
|
||||
<Ban className="w-4 h-4" /> Revoke
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6 pt-6 border-t border-white/[0.06]">
|
||||
<div>
|
||||
<div className="text-xs text-white/40 uppercase tracking-wider">Logins</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">{inv.login_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-white/40 uppercase tracking-wider">Last login</div>
|
||||
<div className="text-sm text-white/80 mt-1">
|
||||
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-white/40 uppercase tracking-wider">Sessions</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">{data.sessions.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-white/40 uppercase tracking-wider">Snapshots</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">{data.snapshots.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<AuditLogTable rows={data.audit} showActor />
|
||||
</section>
|
||||
|
||||
{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">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx
Normal file
125
pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function NewInvestorPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [company, setCompany] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/invite', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, name, company }),
|
||||
})
|
||||
if (res.ok) {
|
||||
router.push('/pitch-admin/investors')
|
||||
router.refresh()
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || 'Invite failed')
|
||||
}
|
||||
} catch {
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
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 investors
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Invite Investor</h1>
|
||||
<p className="text-sm text-white/50 mb-6">
|
||||
A magic link will be emailed. Single-use, expires in {process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'}h.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Email <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
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"
|
||||
placeholder="jane@vc.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(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"
|
||||
placeholder="Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(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"
|
||||
placeholder="Acme Ventures"
|
||||
/>
|
||||
</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 items-center justify-end gap-3 pt-2">
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
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 ? 'Sending…' : 'Send invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
pitch-deck/app/pitch-admin/(authed)/investors/page.tsx
Normal file
213
pitch-deck/app/pitch-admin/(authed)/investors/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Search, Mail, Ban, Eye, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: string
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
slides_viewed: number
|
||||
last_activity: string | null
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
|
||||
active: 'bg-green-500/15 text-green-300 border-green-500/30',
|
||||
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
|
||||
}
|
||||
|
||||
export default function InvestorsPage() {
|
||||
const [investors, setInvestors] = useState<Investor[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/investors')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInvestors(data.investors)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
function flashToast(msg: string) {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
async function resend(id: string) {
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
|
||||
setBusy(null)
|
||||
if (res.ok) flashToast('Magic link resent')
|
||||
else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
flashToast(err.error || 'Resend failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id: string, email: string) {
|
||||
if (!confirm(`Revoke access for ${email}? This signs them out and prevents future logins.`)) return
|
||||
setBusy(id)
|
||||
const res = await fetch('/api/admin/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ investor_id: id }),
|
||||
})
|
||||
setBusy(null)
|
||||
if (res.ok) {
|
||||
flashToast('Access revoked')
|
||||
load()
|
||||
} else {
|
||||
flashToast('Revoke failed')
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = investors.filter((i) => {
|
||||
if (statusFilter !== 'all' && i.status !== statusFilter) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
i.email.toLowerCase().includes(q) ||
|
||||
(i.name || '').toLowerCase().includes(q) ||
|
||||
(i.company || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Investors</h1>
|
||||
<p className="text-sm text-white/50 mt-1">{investors.length} total · {filtered.length} shown</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/pitch-admin/investors/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"
|
||||
>
|
||||
+ Invite Investor
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, email, or company"
|
||||
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg pl-10 pr-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
</select>
|
||||
</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>
|
||||
) : (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-white/40 border-b border-white/[0.06]">
|
||||
<th className="py-3 px-4 font-medium">Investor</th>
|
||||
<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">Last login</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-white/40">No investors</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((inv) => (
|
||||
<tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
<td className="py-3 px-4">
|
||||
<Link href={`/pitch-admin/investors/${inv.id}`} className="block min-w-0 hover:text-indigo-300">
|
||||
<div className="text-white/90 font-medium truncate">{inv.name || inv.email}</div>
|
||||
<div className="text-xs text-white/40 truncate">
|
||||
{inv.company ? `${inv.company} · ` : ''}{inv.email}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status] || ''}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</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 text-white/50 text-xs whitespace-nowrap">
|
||||
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link
|
||||
href={`/pitch-admin/investors/${inv.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="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => resend(inv.id)}
|
||||
disabled={busy === inv.id || inv.status === 'revoked'}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Resend magic link"
|
||||
>
|
||||
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Mail className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revoke(inv.id, inv.email)}
|
||||
disabled={busy === inv.id || inv.status === 'revoked'}
|
||||
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 disabled:cursor-not-allowed"
|
||||
title="Revoke"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
pitch-deck/app/pitch-admin/(authed)/layout.tsx
Normal file
18
pitch-deck/app/pitch-admin/(authed)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getAdminFromCookie } from '@/lib/admin-auth'
|
||||
import AdminShell from '@/components/pitch-admin/AdminShell'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AuthedAdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
redirect('/pitch-admin/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell admin={{ id: admin.id, email: admin.email, name: admin.name }}>
|
||||
{children}
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
142
pitch-deck/app/pitch-admin/(authed)/page.tsx
Normal file
142
pitch-deck/app/pitch-admin/(authed)/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Users, UserCheck, Mail, Eye, ArrowRight } from 'lucide-react'
|
||||
import StatCard from '@/components/pitch-admin/StatCard'
|
||||
import AuditLogTable from '@/components/pitch-admin/AuditLogTable'
|
||||
|
||||
interface DashboardData {
|
||||
totals: {
|
||||
total_investors: number
|
||||
pending_invites: number
|
||||
active_7d: number
|
||||
slides_viewed_total: number
|
||||
active_sessions: number
|
||||
active_admins: number
|
||||
}
|
||||
recent_logins: Array<{
|
||||
investor_id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
created_at: string
|
||||
ip_address: string | null
|
||||
}>
|
||||
recent_activity: Array<{
|
||||
id: number
|
||||
action: string
|
||||
created_at: string
|
||||
details: Record<string, unknown> | null
|
||||
investor_email: string | null
|
||||
investor_name: string | null
|
||||
target_investor_email: string | null
|
||||
admin_email: string | null
|
||||
admin_name: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/dashboard')
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
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 dashboard</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Dashboard</h1>
|
||||
<p className="text-sm text-white/50 mt-1">Investor activity overview</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/pitch-admin/investors/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 transition-all"
|
||||
>
|
||||
+ Invite Investor
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Total Investors" value={data.totals.total_investors} icon={Users} accent="indigo" />
|
||||
<StatCard
|
||||
label="Active (7d)"
|
||||
value={data.totals.active_7d}
|
||||
icon={UserCheck}
|
||||
accent="green"
|
||||
hint={`${data.totals.active_sessions} live sessions`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Pending Invites"
|
||||
value={data.totals.pending_invites}
|
||||
icon={Mail}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Slides Viewed"
|
||||
value={data.totals.slides_viewed_total}
|
||||
icon={Eye}
|
||||
accent="indigo"
|
||||
hint="all-time"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-white">Recent Logins</h2>
|
||||
<Link href="/pitch-admin/investors" className="text-xs text-indigo-400 hover:text-indigo-300 flex items-center gap-1">
|
||||
All investors <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
{data.recent_logins.length === 0 ? (
|
||||
<div className="text-white/40 text-sm py-8 text-center">No logins yet</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.recent_logins.map((row, i) => (
|
||||
<li key={i} className="flex items-center justify-between text-sm py-2 border-b border-white/[0.04] last:border-0">
|
||||
<div className="min-w-0">
|
||||
<Link href={`/pitch-admin/investors/${row.investor_id}`} className="text-white/90 hover:text-indigo-300 truncate block">
|
||||
{row.name || row.email}
|
||||
</Link>
|
||||
<div className="text-xs text-white/40 truncate">{row.company || row.email}</div>
|
||||
</div>
|
||||
<div className="text-xs text-white/40 whitespace-nowrap ml-3">
|
||||
{new Date(row.created_at).toLocaleString()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-white">Recent Activity</h2>
|
||||
<Link href="/pitch-admin/audit" className="text-xs text-indigo-400 hover:text-indigo-300 flex items-center gap-1">
|
||||
Full log <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="-mx-5">
|
||||
<AuditLogTable rows={data.recent_activity.slice(0, 8)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
pitch-deck/app/pitch-admin/login/page.tsx
Normal file
110
pitch-deck/app/pitch-admin/login/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin-auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (res.ok) {
|
||||
router.push('/pitch-admin')
|
||||
router.refresh()
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || 'Login failed')
|
||||
}
|
||||
} catch {
|
||||
setError('Network error')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm px-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M8 12L20 6L32 12V28L20 34L8 28V12Z" stroke="white" strokeWidth="2" fill="none" />
|
||||
<circle cx="20" cy="20" r="4" fill="white" opacity="0.8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Pitch Admin</h1>
|
||||
<p className="text-sm text-white/40">BreakPilot ComplAI</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 backdrop-blur-sm space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60"
|
||||
placeholder="you@breakpilot.ai"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-medium py-2.5 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-white/30 mt-6">
|
||||
Admin access only. All actions are logged.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight } from 'lucide-react'
|
||||
import { ChatMessage, Language, SlideId } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
import { matchFAQ, getFAQAnswer } from '@/lib/presenter/faq-matcher'
|
||||
|
||||
interface ChatFABProps {
|
||||
lang: Language
|
||||
@@ -12,6 +15,8 @@ interface ChatFABProps {
|
||||
currentIndex: number
|
||||
visitedSlides: Set<number>
|
||||
onGoToSlide: (index: number) => void
|
||||
presenterState?: PresenterState
|
||||
onPresenterInterrupt?: () => void
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
@@ -62,18 +67,31 @@ function parseAgentResponse(content: string, lang: Language): ParsedMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse GOTO markers from the text
|
||||
const gotoRegex = /\[GOTO:(\d+)\]/g
|
||||
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
|
||||
const gotoRegex = /\[GOTO:([\w-]+)\]/g
|
||||
let gotoMatch
|
||||
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
|
||||
const slideIndex = parseInt(gotoMatch[1])
|
||||
gotos.push({
|
||||
index: slideIndex,
|
||||
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
|
||||
})
|
||||
const target = gotoMatch[1]
|
||||
let slideIndex: number
|
||||
|
||||
// Try numeric index first
|
||||
const numericIndex = parseInt(target)
|
||||
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
|
||||
slideIndex = numericIndex
|
||||
} else {
|
||||
// Try slide ID lookup
|
||||
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
|
||||
}
|
||||
|
||||
if (slideIndex >= 0) {
|
||||
gotos.push({
|
||||
index: slideIndex,
|
||||
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Remove GOTO markers from visible text
|
||||
text = text.replace(/\s*\[GOTO:\d+\]/g, '')
|
||||
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
|
||||
|
||||
// Clean up trailing reminder instruction that might leak through
|
||||
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
|
||||
@@ -81,7 +99,15 @@ function parseAgentResponse(content: string, lang: Language): ParsedMessage {
|
||||
return { text: text.trim(), followUps, gotos }
|
||||
}
|
||||
|
||||
export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlides, onGoToSlide }: ChatFABProps) {
|
||||
export default function ChatFAB({
|
||||
lang,
|
||||
currentSlide,
|
||||
currentIndex,
|
||||
visitedSlides,
|
||||
onGoToSlide,
|
||||
presenterState = 'idle',
|
||||
onPresenterInterrupt,
|
||||
}: ChatFABProps) {
|
||||
const i = t(lang)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
@@ -124,28 +150,43 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
const message = text || input.trim()
|
||||
if (!message || isStreaming) return
|
||||
|
||||
// Interrupt presenter if it's running
|
||||
if (presenterState === 'presenting' && onPresenterInterrupt) {
|
||||
onPresenterInterrupt()
|
||||
}
|
||||
|
||||
setInput('')
|
||||
setMessages(prev => [...prev, { role: 'user', content: message }])
|
||||
setIsStreaming(true)
|
||||
setIsWaiting(true)
|
||||
|
||||
// Check FAQ first for instant response
|
||||
const faqMatch = matchFAQ(message, lang)
|
||||
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
message,
|
||||
history: messages.slice(-10),
|
||||
lang,
|
||||
slideContext: {
|
||||
currentSlide,
|
||||
currentIndex,
|
||||
visitedSlides: Array.from(visitedSlides),
|
||||
totalSlides: SLIDE_ORDER.length,
|
||||
},
|
||||
}
|
||||
|
||||
// If FAQ matched, send the cached answer for fast streaming (no LLM call)
|
||||
if (faqMatch) {
|
||||
requestBody.faqAnswer = getFAQAnswer(faqMatch, lang)
|
||||
}
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
history: messages.slice(-10),
|
||||
lang,
|
||||
slideContext: {
|
||||
currentSlide,
|
||||
currentIndex,
|
||||
visitedSlides: Array.from(visitedSlides),
|
||||
totalSlides: 13,
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
|
||||
@@ -175,6 +216,20 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (gotoIdx >= 0) {
|
||||
const suffix = `\n\n[GOTO:${faqMatch.goto_slide}]`
|
||||
content += suffix
|
||||
setMessages(prev => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = { role: 'assistant', content }
|
||||
return updated
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
console.error('Chat error:', err)
|
||||
@@ -277,6 +332,10 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
{/* Presenter active indicator */}
|
||||
{presenterState !== 'idle' && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-green-400 border-2 border-black animate-pulse" />
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -307,7 +366,9 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
<span className="text-xs text-white/30 ml-2">
|
||||
{isStreaming
|
||||
? (lang === 'de' ? 'antwortet...' : 'responding...')
|
||||
: (lang === 'de' ? 'online' : 'online')
|
||||
: presenterState !== 'idle'
|
||||
? (lang === 'de' ? 'Presenter aktiv' : 'Presenter active')
|
||||
: (lang === 'de' ? 'online' : 'online')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,10 @@ import { AnimatePresence } from 'framer-motion'
|
||||
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
||||
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||
import { usePitchData } from '@/lib/hooks/usePitchData'
|
||||
import { usePresenterMode } from '@/lib/hooks/usePresenterMode'
|
||||
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import { Investor } from '@/lib/hooks/useAuth'
|
||||
|
||||
import ParticleBackground from './ParticleBackground'
|
||||
import ProgressBar from './ProgressBar'
|
||||
@@ -14,7 +17,11 @@ import NavigationFAB from './NavigationFAB'
|
||||
import ChatFAB from './ChatFAB'
|
||||
import SlideOverview from './SlideOverview'
|
||||
import SlideContainer from './SlideContainer'
|
||||
import PresenterOverlay from './presenter/PresenterOverlay'
|
||||
import AvatarPlaceholder from './presenter/AvatarPlaceholder'
|
||||
import Watermark from './Watermark'
|
||||
|
||||
import IntroPresenterSlide from './slides/IntroPresenterSlide'
|
||||
import CoverSlide from './slides/CoverSlide'
|
||||
import ProblemSlide from './slides/ProblemSlide'
|
||||
import SolutionSlide from './slides/SolutionSlide'
|
||||
@@ -38,13 +45,29 @@ import AIPipelineSlide from './slides/AIPipelineSlide'
|
||||
interface PitchDeckProps {
|
||||
lang: Language
|
||||
onToggleLanguage: () => void
|
||||
investor: Investor | null
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
|
||||
const { data, loading, error } = usePitchData()
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
|
||||
const presenter = usePresenterMode({
|
||||
goToSlide: nav.goToSlide,
|
||||
currentSlide: nav.currentIndex,
|
||||
totalSlides: nav.totalSlides,
|
||||
language: lang,
|
||||
})
|
||||
|
||||
// Audit tracking
|
||||
useAuditTracker({
|
||||
investorId: investor?.id || null,
|
||||
currentSlide: nav.currentSlide,
|
||||
enabled: !!investor,
|
||||
})
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
@@ -66,6 +89,7 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
onFullscreen: toggleFullscreen,
|
||||
onLanguageToggle: onToggleLanguage,
|
||||
onMenuToggle: toggleMenu,
|
||||
onPresenterToggle: presenter.toggle,
|
||||
onGoToSlide: nav.goToSlide,
|
||||
enabled: !nav.showOverview,
|
||||
})
|
||||
@@ -96,6 +120,14 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
if (!data) return null
|
||||
|
||||
switch (nav.currentSlide) {
|
||||
case 'intro-presenter':
|
||||
return (
|
||||
<IntroPresenterSlide
|
||||
lang={lang}
|
||||
onStartPresenter={presenter.start}
|
||||
isPresenting={presenter.state !== 'idle'}
|
||||
/>
|
||||
)
|
||||
case 'cover':
|
||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
||||
case 'problem':
|
||||
@@ -117,7 +149,7 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
case 'team':
|
||||
return <TeamSlide lang={lang} team={data.team} />
|
||||
case 'financials':
|
||||
return <FinancialsSlide lang={lang} />
|
||||
return <FinancialsSlide lang={lang} investorId={investor?.id || null} />
|
||||
case 'the-ask':
|
||||
return <TheAskSlide lang={lang} funding={data.funding} />
|
||||
case 'ai-qa':
|
||||
@@ -140,10 +172,16 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen relative overflow-hidden bg-gradient-to-br from-slate-950 via-[#0a0a1a] to-slate-950">
|
||||
<div
|
||||
className="h-screen relative overflow-hidden bg-gradient-to-br from-slate-950 via-[#0a0a1a] to-slate-950 select-none"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<ParticleBackground />
|
||||
<ProgressBar current={nav.currentIndex} total={nav.totalSlides} />
|
||||
|
||||
{/* Investor watermark */}
|
||||
{investor && <Watermark text={investor.email} />}
|
||||
|
||||
<SlideContainer slideKey={nav.currentSlide} direction={nav.direction}>
|
||||
{renderSlide()}
|
||||
</SlideContainer>
|
||||
@@ -163,6 +201,8 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
currentIndex={nav.currentIndex}
|
||||
visitedSlides={nav.visitedSlides}
|
||||
onGoToSlide={nav.goToSlide}
|
||||
presenterState={presenter.state}
|
||||
onPresenterInterrupt={presenter.pause}
|
||||
/>
|
||||
|
||||
<NavigationFAB
|
||||
@@ -174,6 +214,21 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
onToggleLanguage={onToggleLanguage}
|
||||
/>
|
||||
|
||||
{/* Presenter UI */}
|
||||
<AvatarPlaceholder state={presenter.state} />
|
||||
<PresenterOverlay
|
||||
state={presenter.state}
|
||||
currentIndex={nav.currentIndex}
|
||||
totalSlides={nav.totalSlides}
|
||||
progress={presenter.progress}
|
||||
displayText={presenter.displayText}
|
||||
lang={lang}
|
||||
onPause={presenter.pause}
|
||||
onResume={presenter.resume}
|
||||
onStop={presenter.stop}
|
||||
onSkip={presenter.skipSlide}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{nav.showOverview && (
|
||||
<SlideOverview
|
||||
|
||||
36
pitch-deck/components/Watermark.tsx
Normal file
36
pitch-deck/components/Watermark.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
interface WatermarkProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export default function Watermark({ text }: WatermarkProps) {
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none z-10 overflow-hidden select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="text-white/[0.03] text-2xl font-mono whitespace-nowrap tracking-widest"
|
||||
style={{
|
||||
transform: 'rotate(-35deg) scale(1.5)',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Repeat the watermark text in a grid pattern */}
|
||||
{Array.from({ length: 7 }, (_, row) => (
|
||||
<div key={row} className="my-16">
|
||||
{Array.from({ length: 3 }, (_, col) => (
|
||||
<span key={col} className="mx-12">{text}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
pitch-deck/components/pitch-admin/AdminShell.tsx
Normal file
129
pitch-deck/components/pitch-admin/AdminShell.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
ShieldCheck,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface AdminShellProps {
|
||||
admin: { id: string; email: string; name: string }
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/admin-auth/logout', { method: 'POST' })
|
||||
router.push('/pitch-admin/login')
|
||||
}
|
||||
|
||||
function isActive(item: typeof NAV[number]) {
|
||||
if (item.exact) return pathname === item.href
|
||||
return pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
|
||||
{/* 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]
|
||||
transform transition-transform lg:transform-none ${open ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="px-6 py-5 border-b border-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M8 12L20 6L32 12V28L20 34L8 28V12Z" stroke="white" strokeWidth="2.5" fill="none" />
|
||||
<circle cx="20" cy="20" r="3" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">BreakPilot</div>
|
||||
<div className="text-[10px] text-white/40 uppercase tracking-wider">Pitch Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
|
||||
${active
|
||||
? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/20'
|
||||
: 'text-white/60 hover:bg-white/[0.04] hover:text-white/90 border border-transparent'}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-4 border-t border-white/[0.06]">
|
||||
<div className="px-3 py-2 mb-2">
|
||||
<div className="text-sm font-medium text-white/90 truncate">{admin.name}</div>
|
||||
<div className="text-xs text-white/40 truncate">{admin.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{open && (
|
||||
<div
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 bg-black/60 z-30 lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-9 h-9 rounded-lg bg-white/[0.04] flex items-center justify-center"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="text-sm font-semibold">Pitch Admin</div>
|
||||
<div className="w-9" />
|
||||
</header>
|
||||
<main className="flex-1 p-6 lg:p-8 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
pitch-deck/components/pitch-admin/AuditLogTable.tsx
Normal file
153
pitch-deck/components/pitch-admin/AuditLogTable.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
export interface AuditLogRow {
|
||||
id: number | string
|
||||
action: string
|
||||
created_at: string
|
||||
details: Record<string, unknown> | null
|
||||
ip_address?: string | null
|
||||
slide_id?: string | null
|
||||
investor_email?: string | null
|
||||
investor_name?: string | null
|
||||
target_investor_email?: string | null
|
||||
target_investor_name?: string | null
|
||||
admin_email?: string | null
|
||||
admin_name?: string | null
|
||||
}
|
||||
|
||||
interface AuditLogTableProps {
|
||||
rows: AuditLogRow[]
|
||||
showActor?: boolean
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
login_success: 'text-green-400 bg-green-500/10',
|
||||
login_failed: 'text-rose-400 bg-rose-500/10',
|
||||
admin_login_success: 'text-green-400 bg-green-500/10',
|
||||
admin_login_failed: 'text-rose-400 bg-rose-500/10',
|
||||
admin_logout: 'text-white/40 bg-white/[0.04]',
|
||||
logout: 'text-white/40 bg-white/[0.04]',
|
||||
slide_viewed: 'text-indigo-400 bg-indigo-500/10',
|
||||
assumption_changed: 'text-amber-400 bg-amber-500/10',
|
||||
assumption_edited: 'text-amber-400 bg-amber-500/10',
|
||||
scenario_edited: 'text-amber-400 bg-amber-500/10',
|
||||
investor_invited: 'text-purple-400 bg-purple-500/10',
|
||||
magic_link_resent: 'text-purple-400 bg-purple-500/10',
|
||||
investor_revoked: 'text-rose-400 bg-rose-500/10',
|
||||
investor_edited: 'text-blue-400 bg-blue-500/10',
|
||||
admin_created: 'text-green-400 bg-green-500/10',
|
||||
admin_edited: 'text-blue-400 bg-blue-500/10',
|
||||
admin_deactivated: 'text-rose-400 bg-rose-500/10',
|
||||
new_ip_detected: 'text-amber-400 bg-amber-500/10',
|
||||
}
|
||||
|
||||
function actorLabel(row: AuditLogRow): { label: string; sub: string; kind: 'admin' | 'investor' | 'system' } {
|
||||
if (row.admin_email) {
|
||||
return { label: row.admin_name || row.admin_email, sub: row.admin_email, kind: 'admin' }
|
||||
}
|
||||
if (row.investor_email) {
|
||||
return { label: row.investor_name || row.investor_email, sub: row.investor_email, kind: 'investor' }
|
||||
}
|
||||
return { label: 'system', sub: '', kind: 'system' }
|
||||
}
|
||||
|
||||
function targetLabel(row: AuditLogRow): string | null {
|
||||
if (row.target_investor_email) {
|
||||
return row.target_investor_name
|
||||
? `${row.target_investor_name} <${row.target_investor_email}>`
|
||||
: row.target_investor_email
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function summarizeDetails(action: string, details: Record<string, unknown> | null): string {
|
||||
if (!details) return ''
|
||||
if (action === 'slide_viewed' && details.slide_id) return String(details.slide_id)
|
||||
if (action === 'assumption_edited' || action === 'scenario_edited') {
|
||||
const before = details.before as Record<string, unknown> | undefined
|
||||
const after = details.after as Record<string, unknown> | undefined
|
||||
if (before && after) {
|
||||
const keys = Object.keys(after).filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]))
|
||||
return keys.map(k => `${k}: ${JSON.stringify(before[k])} → ${JSON.stringify(after[k])}`).join(', ')
|
||||
}
|
||||
}
|
||||
if (action === 'investor_invited' || action === 'magic_link_resent') {
|
||||
return String(details.email || '')
|
||||
}
|
||||
if (action === 'investor_edited') {
|
||||
const before = details.before as Record<string, unknown> | undefined
|
||||
const after = details.after as Record<string, unknown> | undefined
|
||||
if (before && after) {
|
||||
const keys = Object.keys(after).filter(k => before[k] !== after[k])
|
||||
return keys.map(k => `${k}: "${before[k] || ''}" → "${after[k] || ''}"`).join(', ')
|
||||
}
|
||||
}
|
||||
return JSON.stringify(details).slice(0, 80)
|
||||
}
|
||||
|
||||
export default function AuditLogTable({ rows, showActor = true }: AuditLogTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="text-white/40 text-sm py-8 text-center">No audit events</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-white/40 border-b border-white/[0.06]">
|
||||
<th className="py-3 px-3 font-medium">When</th>
|
||||
{showActor && <th className="py-3 px-3 font-medium">Actor</th>}
|
||||
<th className="py-3 px-3 font-medium">Action</th>
|
||||
<th className="py-3 px-3 font-medium">Target / Details</th>
|
||||
<th className="py-3 px-3 font-medium">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const actor = actorLabel(row)
|
||||
const target = targetLabel(row)
|
||||
const summary = summarizeDetails(row.action, row.details)
|
||||
const colorClass = ACTION_COLORS[row.action] || 'text-white/60 bg-white/[0.04]'
|
||||
return (
|
||||
<tr key={row.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
<td className="py-3 px-3 text-white/60 whitespace-nowrap">{formatDate(row.created_at)}</td>
|
||||
{showActor && (
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
|
||||
actor.kind === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: actor.kind === 'investor'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'bg-white/10 text-white/50'
|
||||
}`}
|
||||
>
|
||||
{actor.kind}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-white/80 truncate max-w-[180px]">{actor.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 px-3">
|
||||
<span className={`text-xs px-2 py-1 rounded font-mono ${colorClass}`}>{row.action}</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-white/60 max-w-md">
|
||||
{target && <div className="text-white/80 truncate">→ {target}</div>}
|
||||
{summary && <div className="text-xs text-white/40 truncate">{summary}</div>}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-white/40 font-mono text-xs">{row.ip_address || '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
pitch-deck/components/pitch-admin/StatCard.tsx
Normal file
33
pitch-deck/components/pitch-admin/StatCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: LucideIcon
|
||||
hint?: string
|
||||
accent?: 'indigo' | 'green' | 'amber' | 'rose'
|
||||
}
|
||||
|
||||
const ACCENTS = {
|
||||
indigo: 'text-indigo-400 bg-indigo-500/10 border-indigo-500/20',
|
||||
green: 'text-green-400 bg-green-500/10 border-green-500/20',
|
||||
amber: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
rose: 'text-rose-400 bg-rose-500/10 border-rose-500/20',
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, icon: Icon, hint, accent = 'indigo' }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-xs font-medium text-white/50 uppercase tracking-wider">{label}</span>
|
||||
{Icon && (
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center border ${ACCENTS[accent]}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-white">{value}</div>
|
||||
{hint && <div className="text-xs text-white/40 mt-1">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
pitch-deck/components/presenter/AvatarPlaceholder.tsx
Normal file
76
pitch-deck/components/presenter/AvatarPlaceholder.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
|
||||
interface AvatarPlaceholderProps {
|
||||
state: PresenterState
|
||||
}
|
||||
|
||||
export default function AvatarPlaceholder({ state }: AvatarPlaceholderProps) {
|
||||
const isSpeaking = state === 'presenting' || state === 'answering'
|
||||
const isIdle = state === 'idle'
|
||||
|
||||
if (isIdle) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="fixed bottom-24 right-6 z-40"
|
||||
>
|
||||
<div className="relative w-16 h-16">
|
||||
{/* Pulse rings when speaking */}
|
||||
{isSpeaking && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-indigo-400/30"
|
||||
animate={{ scale: [1, 1.4, 1], opacity: [0.3, 0, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-purple-400/20"
|
||||
animate={{ scale: [1, 1.6, 1], opacity: [0.2, 0, 0.2] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut', delay: 0.2 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Avatar circle */}
|
||||
<motion.div
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center shadow-lg
|
||||
${isSpeaking
|
||||
? 'bg-gradient-to-br from-indigo-500/40 to-purple-500/40 border-2 border-indigo-400/50'
|
||||
: 'bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-400/30'
|
||||
}`}
|
||||
animate={isSpeaking ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* Bot icon */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={isSpeaking ? 'text-indigo-300' : 'text-indigo-400/60'}
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<circle cx="8" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="16" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
{/* State dot */}
|
||||
<div className={`absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black/80
|
||||
${state === 'presenting' ? 'bg-green-400' :
|
||||
state === 'paused' ? 'bg-yellow-400' :
|
||||
state === 'answering' ? 'bg-blue-400' :
|
||||
'bg-indigo-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
161
pitch-deck/components/presenter/PresenterOverlay.tsx
Normal file
161
pitch-deck/components/presenter/PresenterOverlay.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface PresenterOverlayProps {
|
||||
state: PresenterState
|
||||
currentIndex: number
|
||||
totalSlides: number
|
||||
progress: number
|
||||
displayText: string
|
||||
lang: Language
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export default function PresenterOverlay({
|
||||
state,
|
||||
currentIndex,
|
||||
totalSlides,
|
||||
progress,
|
||||
displayText,
|
||||
lang,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: PresenterOverlayProps) {
|
||||
const i = t(lang)
|
||||
const slideName = i.slideNames[currentIndex] || SLIDE_ORDER[currentIndex] || ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{state !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 pointer-events-none"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl px-4 pb-4 pointer-events-auto">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl">
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-white/5">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3">
|
||||
{/* Top row: slide info + controls */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* State indicator */}
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
state === 'presenting' ? 'bg-green-400 animate-pulse' :
|
||||
state === 'paused' ? 'bg-yellow-400' :
|
||||
state === 'answering' ? 'bg-blue-400 animate-pulse' :
|
||||
state === 'resuming' ? 'bg-indigo-400 animate-pulse' :
|
||||
'bg-white/30'
|
||||
}`} />
|
||||
<span className="text-xs text-white/50 font-medium">
|
||||
{lang === 'de' ? 'Folie' : 'Slide'} {currentIndex + 1}/{totalSlides} — {slideName}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
||||
hover:bg-white/20 transition-colors"
|
||||
title={lang === 'de' ? 'Naechste Folie' : 'Next slide'}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5 text-white/60" />
|
||||
</button>
|
||||
|
||||
{state === 'presenting' ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
||||
hover:bg-white/20 transition-colors"
|
||||
title={lang === 'de' ? 'Pausieren' : 'Pause'}
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5 text-white/60" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="w-7 h-7 rounded-full bg-indigo-500/30 flex items-center justify-center
|
||||
hover:bg-indigo-500/50 transition-colors"
|
||||
title={lang === 'de' ? 'Fortsetzen' : 'Resume'}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 text-indigo-300" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="w-7 h-7 rounded-full bg-red-500/20 flex items-center justify-center
|
||||
hover:bg-red-500/30 transition-colors"
|
||||
title={lang === 'de' ? 'Stoppen' : 'Stop'}
|
||||
>
|
||||
<Square className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle text */}
|
||||
<AnimatePresence mode="wait">
|
||||
{displayText && (
|
||||
<motion.p
|
||||
key={displayText.slice(0, 30)}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-sm text-white/70 leading-relaxed"
|
||||
>
|
||||
{displayText}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* State message */}
|
||||
{state === 'paused' && (
|
||||
<p className="text-xs text-yellow-400/60 mt-1">
|
||||
{lang === 'de' ? 'Pausiert — stellen Sie eine Frage oder druecken Sie Play' : 'Paused — ask a question or press play'}
|
||||
</p>
|
||||
)}
|
||||
{state === 'answering' && (
|
||||
<p className="text-xs text-blue-400/60 mt-1">
|
||||
{lang === 'de' ? 'Beantworte Ihre Frage...' : 'Answering your question...'}
|
||||
</p>
|
||||
)}
|
||||
{state === 'resuming' && (
|
||||
<p className="text-xs text-indigo-400/60 mt-1">
|
||||
{lang === 'de' ? 'Setze Praesentation fort...' : 'Resuming presentation...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,12 @@ type FinTab = 'overview' | 'guv' | 'cashflow'
|
||||
|
||||
interface FinancialsSlideProps {
|
||||
lang: Language
|
||||
investorId: string | null
|
||||
}
|
||||
|
||||
export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
||||
export default function FinancialsSlide({ lang, investorId }: FinancialsSlideProps) {
|
||||
const i = t(lang)
|
||||
const fm = useFinancialModel()
|
||||
const fm = useFinancialModel(investorId)
|
||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
||||
const de = lang === 'de'
|
||||
|
||||
@@ -268,6 +269,26 @@ export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
||||
{de ? 'Berechne...' : 'Computing...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot status + reset */}
|
||||
{investorId && (
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-white/5">
|
||||
<span className="text-[9px] text-white/30">
|
||||
{fm.snapshotStatus === 'saving' && (de ? 'Speichere...' : 'Saving...')}
|
||||
{fm.snapshotStatus === 'saved' && (de ? 'Ihre Aenderungen gespeichert' : 'Your changes saved')}
|
||||
{fm.snapshotStatus === 'restored' && (de ? 'Ihre Werte geladen' : 'Your values restored')}
|
||||
{fm.snapshotStatus === 'default' && (de ? 'Standardwerte' : 'Defaults')}
|
||||
</span>
|
||||
{fm.snapshotStatus !== 'default' && (
|
||||
<button
|
||||
onClick={() => fm.activeScenarioId && fm.resetToDefaults(fm.activeScenarioId)}
|
||||
className="text-[9px] text-white/40 hover:text-white/70 transition-colors"
|
||||
>
|
||||
{de ? 'Zuruecksetzen' : 'Reset to defaults'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
|
||||
116
pitch-deck/components/slides/IntroPresenterSlide.tsx
Normal file
116
pitch-deck/components/slides/IntroPresenterSlide.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, MessageCircle, Pause } from 'lucide-react'
|
||||
import { Language } from '@/lib/types'
|
||||
import GradientText from '../ui/GradientText'
|
||||
|
||||
interface IntroPresenterSlideProps {
|
||||
lang: Language
|
||||
onStartPresenter?: () => void
|
||||
isPresenting?: boolean
|
||||
}
|
||||
|
||||
export default function IntroPresenterSlide({ lang, onStartPresenter, isPresenting }: IntroPresenterSlideProps) {
|
||||
const isDE = lang === 'de'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center px-8 text-center">
|
||||
{/* Avatar Placeholder Circle */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-indigo-500/30 to-purple-500/30 border-2 border-indigo-400/40 flex items-center justify-center">
|
||||
{/* Pulse rings */}
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-indigo-400/20"
|
||||
animate={{ scale: [1, 1.3, 1], opacity: [0.4, 0, 0.4] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-purple-400/20"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 0, 0.3] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut', delay: 0.3 }}
|
||||
/>
|
||||
{/* Bot icon */}
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-indigo-300">
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<circle cx="8" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="16" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{isDE ? 'KI-Praesentator' : 'AI Presenter'}</GradientText>
|
||||
</h1>
|
||||
<p className="text-lg text-white/60 max-w-lg mx-auto mb-8">
|
||||
{isDE
|
||||
? 'Ihr persoenlicher KI-Guide durch das BreakPilot ComplAI Pitch Deck. 15 Minuten, alle Fakten, jederzeit unterbrechbar.'
|
||||
: 'Your personal AI guide through the BreakPilot ComplAI pitch deck. 15 minutes, all facts, interruptible at any time.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Start Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
>
|
||||
<button
|
||||
onClick={onStartPresenter}
|
||||
className="group relative px-8 py-4 rounded-2xl bg-gradient-to-r from-indigo-600 to-purple-600
|
||||
hover:from-indigo-500 hover:to-purple-500 transition-all duration-300
|
||||
text-white font-semibold text-lg shadow-lg shadow-indigo-600/30
|
||||
hover:shadow-xl hover:shadow-indigo-600/40 hover:scale-105"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
{isPresenting ? (
|
||||
<>
|
||||
<Pause className="w-5 h-5" />
|
||||
{isDE ? 'Praesentation laeuft...' : 'Presentation running...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
{isDE ? 'Praesentation starten' : 'Start Presentation'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Interaction hints */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.5 }}
|
||||
className="mt-10 flex flex-col md:flex-row gap-4 text-sm text-white/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>{isDE ? 'Jederzeit Fragen im Chat stellen' : 'Ask questions in chat anytime'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-white/10 rounded text-xs font-mono">P</span>
|
||||
<span>{isDE ? 'Taste P: Presenter An/Aus' : 'Press P: Toggle Presenter'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-white/10 rounded text-xs font-mono">ESC</span>
|
||||
<span>{isDE ? 'Slide-Uebersicht' : 'Slide Overview'}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Language, PitchMarket } from '@/lib/types'
|
||||
import { t, formatEur } from '@/lib/i18n'
|
||||
import { ExternalLink, X, TrendingUp } from 'lucide-react'
|
||||
import { ExternalLink, X, TrendingUp, Shield } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import AnimatedCounter from '../ui/AnimatedCounter'
|
||||
@@ -26,29 +26,91 @@ interface MarketSourceInfo {
|
||||
const marketSources: Record<string, MarketSourceInfo[]> = {
|
||||
TAM: [
|
||||
{
|
||||
name: 'Grand View Research — GRC Market Report + MarketsAndMarkets DevSecOps',
|
||||
name: 'Bottom-Up-Validierung: Echte Umsatzdaten der Top-10 Compliance-Anbieter',
|
||||
url: 'https://sacra.com/c/vanta/',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'Die Top-10 Compliance-Automation-Anbieter erzielen zusammen ~$1,13 Mrd. Umsatz (Vanta $220M, OneTrust $500M, Drata $100M, Usercentrics $117M, Securiti $76M, DataGuard €52M, Sprinto $38M, heyData €15M, Caralegal €5.8M, Proliance €3.9M). Mit 50+ weiteren Anbietern liegt der Gesamtmarkt bei ~$1,6-2 Mrd. — aktuell nur ~20% des adressierbaren Volumens (Gartner: 80% der Unternehmen managen Compliance noch manuell). Inkl. DevSecOps fuer Manufacturing (~$3,5 Mrd.) ergibt sich ein TAM von $8-12 Mrd.',
|
||||
excerpt_en: 'The top 10 compliance automation providers generate ~$1.13B combined revenue (Vanta $220M, OneTrust $500M, Drata $100M, Usercentrics $117M, Securiti $76M, DataGuard €52M, Sprinto $38M, heyData €15M, Caralegal €5.8M, Proliance €3.9M). With 50+ additional vendors, the total market is ~$1.6-2B — currently only ~20% of addressable volume (Gartner: 80% manage compliance manually). Incl. DevSecOps for manufacturing (~$3.5B), the TAM is $8-12B.',
|
||||
},
|
||||
{
|
||||
name: 'Grand View Research — GRC Market Report 2024',
|
||||
url: 'https://www.grandviewresearch.com/industry-analysis/governance-risk-management-compliance-market',
|
||||
date: '2024',
|
||||
excerpt_de: 'Der globale GRC-Software-Markt wurde 2023 auf rund 11,8 Mrd. USD bewertet. Zusammen mit dem DevSecOps-Markt fuer die Fertigungsindustrie (~3,5 Mrd. USD) ergibt sich ein kombinierter TAM von ca. 8,7 Mrd. EUR fuer Compliance & Code-Security im produzierenden Gewerbe.',
|
||||
excerpt_en: 'The global GRC software market was valued at approximately USD 11.8B in 2023. Combined with the DevSecOps market for manufacturing (~USD 3.5B), the combined TAM for compliance & code security in manufacturing is approximately EUR 8.7B.',
|
||||
excerpt_de: 'Der globale GRC-Software-Markt wurde 2023 auf 11,8 Mrd. USD bewertet, CAGR 13,8%. Die Compliance-Automation-Welle (Vanta, Drata) zeigt 30-45% Wachstum p.a. — deutlich ueber dem Branchendurchschnitt.',
|
||||
excerpt_en: 'The global GRC software market was valued at USD 11.8B in 2023, CAGR 13.8%. The compliance automation wave (Vanta, Drata) shows 30-45% p.a. growth — well above industry average.',
|
||||
},
|
||||
],
|
||||
SAM: [
|
||||
{
|
||||
name: 'VDMA / Statista / IDC — DACH Maschinenbau Compliance & Security',
|
||||
name: 'Bottom-Up: DACH Compliance-Anbieter + NIS2/CRA/AI-Act Expansion',
|
||||
url: 'https://www.vdma.org/statistics',
|
||||
date: '2024',
|
||||
excerpt_de: 'Die DACH-Region hat ca. 5.000 Maschinen- und Anlagenbauer mit eigener Softwareentwicklung. Der Compliance- und Security-Software-Markt fuer diese Branche wird auf ca. 850 Mio. EUR geschaetzt — getrieben durch CRA, NIS2, AI Act und steigende Anforderungen an Produktsoftware.',
|
||||
excerpt_en: 'The DACH region has approx. 5,000 machine and plant manufacturers with in-house software development. The compliance and security software market for this industry is estimated at approx. EUR 850M — driven by CRA, NIS2, AI Act and increasing requirements for product software.',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH-Compliance-Umsaetze heute: DataGuard €52M + heyData €15M + Proliance €3.9M + Caralegal €5.8M + OneTrust DACH ~€30M + Secjur/andere ~€10M = ~€120M (nur DSGVO-Compliance). NIS2 erweitert die Regulierung auf 30.000+ Unternehmen (bisher 4.500). CRA und AI Act schaffen voellig neue Pflichten fuer Maschinenbauer. DACH-DevSecOps-Markt: +€300-400M. Gesamtes SAM fuer Compliance + Code-Security in DACH Manufacturing: €850M-1,2 Mrd.',
|
||||
excerpt_en: 'DACH compliance revenues today: DataGuard €52M + heyData €15M + Proliance €3.9M + Caralegal €5.8M + OneTrust DACH ~€30M + Secjur/others ~€10M = ~€120M (GDPR compliance only). NIS2 expands regulation to 30,000+ companies (from 4,500). CRA and AI Act create entirely new obligations for manufacturers. DACH DevSecOps market: +€300-400M. Total SAM for compliance + code security in DACH manufacturing: €850M-1.2B.',
|
||||
},
|
||||
],
|
||||
SOM: [
|
||||
{
|
||||
name: 'VDMA Mitgliederstatistik + eigene Analyse',
|
||||
name: 'VDMA Mitgliederstatistik + Wettbewerbs-Benchmarks',
|
||||
url: 'https://www.vdma.org/mitglieder',
|
||||
date: '2024-2025',
|
||||
excerpt_de: 'Im VDMA sind ca. 3.600 Unternehmen allein in Deutschland registriert, DACH-weit ca. 5.000. Die meisten haben Embedded-Softwareentwicklung im Haus. Bei einer realistischen Marktdurchdringung von 10% (~500 Unternehmen) und einem durchschnittlichen Jahresumsatz von ~14.400 EUR pro Kunde (Blended Avg. aus Mini/Studio/Cloud) ergibt sich ein SOM von ca. 7,2 Mio. EUR.',
|
||||
excerpt_en: 'The VDMA has approx. 3,600 member companies in Germany alone, ~5,000 across DACH. Most have embedded software development in-house. At a realistic market penetration of 10% (~500 companies) and an average annual revenue of ~EUR 14,400 per customer (blended avg. of Mini/Studio/Cloud), the SOM is approx. EUR 7.2M.',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH-weit ca. 5.000 Maschinenbauer mit Eigenentwicklung (VDMA). Bei 10% Marktdurchdringung (~500 Unternehmen) und €14.400/Jahr ARPU (Blended Avg.) ergibt sich ein SOM von €7,2 Mio. Zum Vergleich: Proliance mit 65 Mitarbeitern erreicht €3,9M, heyData mit 58 MA bereits €15M. Mit KI-Automatisierung ist eine hoehere Durchdringung bei niedrigerer Personalintensitaet moeglich.',
|
||||
excerpt_en: 'Approx. 5,000 DACH machine manufacturers with in-house dev (VDMA). At 10% penetration (~500 companies) and €14,400/yr ARPU (blended avg.), SOM is €7.2M. For comparison: Proliance with 65 employees achieves €3.9M, heyData with 58 employees already €15M. AI automation enables higher penetration with lower headcount intensity.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Pentesting / AppSec Market Data ──────────────────────────────────────────
|
||||
|
||||
type MarketView = 'compliance' | 'pentesting'
|
||||
|
||||
interface PentestMarketEntry {
|
||||
segment: string
|
||||
label: { de: string; en: string }
|
||||
value_eur: number
|
||||
growth_rate_pct: number
|
||||
source: string
|
||||
}
|
||||
|
||||
const PENTEST_MARKET: PentestMarketEntry[] = [
|
||||
{ segment: 'TAM', label: { de: 'Total Addressable Market', en: 'Total Addressable Market' }, value_eur: 13_000_000_000, growth_rate_pct: 17, source: 'Gartner + MarketsAndMarkets 2025' },
|
||||
{ segment: 'SAM', label: { de: 'Serviceable Addressable Market', en: 'Serviceable Addressable Market' }, value_eur: 1_600_000_000, growth_rate_pct: 22, source: 'DACH AST + Pentesting (Bottom-Up)' },
|
||||
{ segment: 'SOM', label: { de: 'Serviceable Obtainable Market', en: 'Serviceable Obtainable Market' }, value_eur: 35_000_000, growth_rate_pct: 0, source: 'Year 5 Target (500 Kunden)' },
|
||||
]
|
||||
|
||||
const pentestMarketSources: Record<string, MarketSourceInfo[]> = {
|
||||
TAM: [
|
||||
{
|
||||
name: 'MarketsAndMarkets — Application Security Testing Market 2025',
|
||||
url: 'https://www.marketsandmarkets.com/Market-Reports/application-security-testing-market-150735030.html',
|
||||
date: '2025',
|
||||
excerpt_de: 'Der globale AST-Markt (SAST, DAST, IAST, SCA) wird auf $8,5 Mrd. (2025) geschaetzt und soll bis 2030 auf $19,5 Mrd. wachsen (CAGR 18,2%). Hinzu kommt der Pentesting-Markt ($2,7 Mrd.) und der Compliance-Convergence-Anteil ($1,8 Mrd.). Gesamt-TAM fuer integriertes AppSec + Compliance: ~$13 Mrd.',
|
||||
excerpt_en: 'The global AST market (SAST, DAST, IAST, SCA) is estimated at $8.5B (2025), projected to reach $19.5B by 2030 (CAGR 18.2%). Adding the pentesting market ($2.7B) and compliance convergence share ($1.8B), total TAM for integrated AppSec + compliance: ~$13B.',
|
||||
},
|
||||
{
|
||||
name: 'Gartner — Magic Quadrant for Application Security Testing 2024',
|
||||
url: 'https://www.gartner.com/reviews/market/application-security-testing',
|
||||
date: '2024',
|
||||
excerpt_de: 'Gartner bestaetigt den Trend zur Konvergenz von AppSec und Compliance. Fuehrende Anbieter (Snyk, Veracode, Checkmarx) erreichen zusammen >$850M Umsatz. Der Markt waechst mit 17-20% p.a., getrieben durch regulatorische Anforderungen (CRA, NIS2) und AI-getriebene Entwicklung.',
|
||||
excerpt_en: 'Gartner confirms the AppSec-compliance convergence trend. Leading vendors (Snyk, Veracode, Checkmarx) generate >$850M combined revenue. The market grows at 17-20% p.a., driven by regulatory requirements (CRA, NIS2) and AI-driven development.',
|
||||
},
|
||||
],
|
||||
SAM: [
|
||||
{
|
||||
name: 'Bottom-Up: DACH AppSec + Manufacturing Pentesting',
|
||||
url: 'https://www.bitkom.org/Marktdaten/ITK-Konjunktur/IT-Markt-Deutschland',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH IT-Security-Markt: €8,2 Mrd. (Bitkom 2025). AppSec-Anteil: ~15% = €1,2 Mrd. Davon Pentesting/DAST/SAST fuer produzierende Industrie: ~€400M. CRA-Pflicht fuer Maschinenbauer erzeugt neue Nachfrage: geschaetzt +€200M bis 2028. SAM fuer integriertes AppSec + Compliance im DACH-Manufacturing: ~€1,6 Mrd.',
|
||||
excerpt_en: 'DACH IT security market: €8.2B (Bitkom 2025). AppSec share: ~15% = €1.2B. Pentesting/DAST/SAST for manufacturing: ~€400M. CRA obligation for manufacturers creates new demand: est. +€200M by 2028. SAM for integrated AppSec + compliance in DACH manufacturing: ~€1.6B.',
|
||||
},
|
||||
],
|
||||
SOM: [
|
||||
{
|
||||
name: 'VDMA + Branchenbenchmarks — Pentesting SOM',
|
||||
url: 'https://www.vdma.org/statistics',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'Zielmarkt: 5.000 DACH-Maschinenbauer mit Eigenentwicklung. Bei 10% Durchdringung (500 Unternehmen) und €70K/Jahr Blended ARPU (Compliance €18K + AppSec €52K) ergibt sich ein SOM von €35 Mio. in Year 5. Zum Vergleich: Pentera erreicht mit 400 MA $100M ARR bei 900 Kunden. Intruder (100 MA) erreicht $10M bei 2.500 Kunden.',
|
||||
excerpt_en: 'Target market: 5,000 DACH manufacturers with in-house development. At 10% penetration (500 companies) and €70K/yr blended ARPU (compliance €18K + AppSec €52K), SOM is €35M in Year 5. For comparison: Pentera achieves $100M ARR with 400 employees and 900 customers. Intruder (100 employees) achieves $10M with 2,500 customers.',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -62,14 +124,16 @@ function SourceModal({
|
||||
onClose,
|
||||
segment,
|
||||
lang,
|
||||
sources: sourcesMap,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
segment: string
|
||||
lang: Language
|
||||
sources?: Record<string, MarketSourceInfo[]>
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
const sources = marketSources[segment] || []
|
||||
const sources = (sourcesMap || marketSources)[segment] || []
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -141,117 +205,201 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
const segments = [i.market.tam, i.market.sam, i.market.som]
|
||||
const segmentKeys = ['TAM', 'SAM', 'SOM']
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null)
|
||||
const [marketView, setMarketView] = useState<MarketView>('compliance')
|
||||
|
||||
const activeSources = marketView === 'compliance' ? marketSources : pentestMarketSources
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-12">
|
||||
<FadeInView className="text-center mb-6">
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{i.market.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.market.subtitle}</p>
|
||||
</FadeInView>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-12">
|
||||
{/* Circles */}
|
||||
<div className="relative flex items-center justify-center" style={{ width: 300, height: 300 }}>
|
||||
{market.map((m, idx) => (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3 + idx * 0.2, type: 'spring', stiffness: 200 }}
|
||||
className={`absolute rounded-full border-2 ${colors[idx]} flex items-center justify-center`}
|
||||
style={{
|
||||
width: sizes[idx],
|
||||
height: sizes[idx],
|
||||
}}
|
||||
>
|
||||
{idx === market.length - 1 && (
|
||||
<div className="text-center">
|
||||
<span className={`text-xs font-mono ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{/* Market View Toggle */}
|
||||
<FadeInView delay={0.1} className="flex justify-center gap-2 mb-8">
|
||||
<button
|
||||
onClick={() => setMarketView('compliance')}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
marketView === 'compliance'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Compliance & Code-Security' : 'Compliance & Code Security'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMarketView('pentesting')}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
marketView === 'pentesting'
|
||||
? 'bg-red-500/20 text-red-300 border border-red-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
{lang === 'de' ? 'Pentesting & AppSec' : 'Pentesting & AppSec'}
|
||||
</button>
|
||||
</FadeInView>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="space-y-6">
|
||||
{market.map((m, idx) => {
|
||||
const segKey = segmentKeys[idx] || m.market_segment
|
||||
const sourceCount = marketSources[segKey]?.length || 0
|
||||
return (
|
||||
{/* Compliance Market */}
|
||||
{marketView === 'compliance' && (
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-12">
|
||||
<div className="relative flex items-center justify-center" style={{ width: 300, height: 300 }}>
|
||||
{market.map((m, idx) => (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setActiveModal(segKey)}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3 + idx * 0.2, type: 'spring', stiffness: 200 }}
|
||||
className={`absolute rounded-full border-2 ${colors[idx]} flex items-center justify-center`}
|
||||
style={{ width: sizes[idx], height: sizes[idx] }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${textColors[idx]} bg-current`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
<span className="text-xs text-white/30">{labels[idx]}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{m.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000_000}
|
||||
suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
) : m.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000}
|
||||
suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
) : (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000}
|
||||
suffix={'k EUR'}
|
||||
decimals={0}
|
||||
duration={1500}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{m.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{m.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">
|
||||
{i.market.source}: {m.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}
|
||||
{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
{idx === market.length - 1 && (
|
||||
<div className="text-center">
|
||||
<span className={`text-xs font-mono ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{market.map((m, idx) => {
|
||||
const segKey = segmentKeys[idx] || m.market_segment
|
||||
const sourceCount = marketSources[segKey]?.length || 0
|
||||
return (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setActiveModal(segKey)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${textColors[idx]} bg-current`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
<span className="text-xs text-white/30">{labels[idx]}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{m.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter target={m.value_eur / 1_000_000_000} suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'} decimals={1} duration={1500} />
|
||||
) : m.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter target={m.value_eur / 1_000_000} suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'} decimals={1} duration={1500} />
|
||||
) : (
|
||||
<AnimatedCounter target={m.value_eur / 1_000} suffix={'k EUR'} decimals={0} duration={1500} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{m.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{m.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">{i.market.source}: {m.source}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Modals */}
|
||||
{/* Pentesting Market */}
|
||||
{marketView === 'pentesting' && (
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-12">
|
||||
<div className="relative flex items-center justify-center" style={{ width: 300, height: 300 }}>
|
||||
{PENTEST_MARKET.map((pm, idx) => (
|
||||
<motion.div
|
||||
key={pm.segment}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3 + idx * 0.2, type: 'spring', stiffness: 200 }}
|
||||
className={`absolute rounded-full border-2 ${
|
||||
idx === 0 ? 'border-red-500/30 bg-red-500/5' :
|
||||
idx === 1 ? 'border-orange-500/30 bg-orange-500/5' :
|
||||
'border-yellow-500/30 bg-yellow-500/5'
|
||||
} flex items-center justify-center`}
|
||||
style={{ width: sizes[idx], height: sizes[idx] }}
|
||||
>
|
||||
{idx === PENTEST_MARKET.length - 1 && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs font-mono text-yellow-400">{pm.segment}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{PENTEST_MARKET.map((pm, idx) => {
|
||||
const ptColors = ['text-red-400', 'text-orange-400', 'text-yellow-400']
|
||||
const sourceCount = pentestMarketSources[pm.segment]?.length || 0
|
||||
return (
|
||||
<motion.div
|
||||
key={pm.segment}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setActiveModal(pm.segment)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${ptColors[idx]} bg-current`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${ptColors[idx]}`}>{pm.segment}</span>
|
||||
<span className="text-xs text-white/30">{pm.label[lang]}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{pm.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter target={pm.value_eur / 1_000_000_000} suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'} decimals={1} duration={1500} />
|
||||
) : pm.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter target={pm.value_eur / 1_000_000} suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'} decimals={1} duration={1500} />
|
||||
) : (
|
||||
<AnimatedCounter target={pm.value_eur / 1_000} suffix={'k EUR'} decimals={0} duration={1500} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{pm.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{pm.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">{i.market.source}: {pm.source}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-red-400/60 group-hover:text-red-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Modals — both compliance and pentesting */}
|
||||
{segmentKeys.map((seg) => (
|
||||
<SourceModal
|
||||
key={seg}
|
||||
key={`c-${seg}`}
|
||||
isOpen={activeModal === seg}
|
||||
onClose={() => setActiveModal(null)}
|
||||
segment={seg}
|
||||
lang={lang}
|
||||
sources={activeSources}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,13 @@ const cardSources: ProblemCardData[] = [
|
||||
excerpt_de: 'Laut der DIHK-Digitalisierungsumfrage 2024 geben 83% der befragten KMU an, die DSGVO-Anforderungen nicht vollstaendig umgesetzt zu haben. Hauptgruende sind mangelnde Ressourcen, fehlendes Know-how und die Komplexitaet der Vorschriften.',
|
||||
excerpt_en: 'According to the DIHK Digitization Survey 2024, 83% of surveyed SMEs report not having fully implemented GDPR requirements. Main reasons cited are lack of resources, missing expertise, and regulatory complexity.',
|
||||
},
|
||||
{
|
||||
name: 'Compliance-Markt validiert: Top-10 Anbieter >$1,1 Mrd. Umsatz',
|
||||
url: 'https://sacra.com/c/vanta/',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'Der Markt ist real: Vanta ($220M ARR, $4,15 Mrd. Bewertung), Drata ($100M), OneTrust ($500M+), DataGuard (€52M). Allein die Top-10 Compliance-Automation-Anbieter erzielen >$1,1 Mrd. Umsatz. Trotzdem managen 80% der Unternehmen Compliance noch manuell — die Marktdurchdringung ist erst am Anfang.',
|
||||
excerpt_en: 'The market is real: Vanta ($220M ARR, $4.15B valuation), Drata ($100M), OneTrust ($500M+), DataGuard (€52M). The top 10 compliance automation providers alone generate >$1.1B revenue. Yet 80% of companies still manage compliance manually — market penetration is still early.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
206
pitch-deck/lib/admin-auth.ts
Normal file
206
pitch-deck/lib/admin-auth.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from './db'
|
||||
import { hashToken, generateToken, getClientIp, logAudit } from './auth'
|
||||
|
||||
const ADMIN_COOKIE_NAME = 'pitch_admin_session'
|
||||
const ADMIN_JWT_AUDIENCE = 'pitch-admin'
|
||||
const ADMIN_JWT_EXPIRY = '2h'
|
||||
const ADMIN_SESSION_EXPIRY_HOURS = 12
|
||||
|
||||
function getJwtSecret() {
|
||||
const secret = process.env.PITCH_JWT_SECRET
|
||||
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export interface Admin {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminJwtPayload {
|
||||
sub: string // admin id
|
||||
email: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export async function createAdminJwt(payload: AdminJwtPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(ADMIN_JWT_EXPIRY)
|
||||
.setAudience(ADMIN_JWT_AUDIENCE)
|
||||
.sign(getJwtSecret())
|
||||
}
|
||||
|
||||
export async function verifyAdminJwt(token: string): Promise<AdminJwtPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret(), { audience: ADMIN_JWT_AUDIENCE })
|
||||
return payload as unknown as AdminJwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminSession(
|
||||
adminId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
): Promise<{ sessionId: string; jwt: string }> {
|
||||
// Single session per admin
|
||||
await pool.query(
|
||||
`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1 AND revoked = false`,
|
||||
[adminId],
|
||||
)
|
||||
|
||||
const sessionToken = generateToken()
|
||||
const tokenHash = hashToken(sessionToken)
|
||||
const expiresAt = new Date(Date.now() + ADMIN_SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_admin_sessions (admin_id, token_hash, ip_address, user_agent, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[adminId, tokenHash, ip, userAgent, expiresAt],
|
||||
)
|
||||
|
||||
const sessionId = rows[0].id
|
||||
|
||||
const adminRes = await pool.query(`SELECT email FROM pitch_admins WHERE id = $1`, [adminId])
|
||||
const jwt = await createAdminJwt({
|
||||
sub: adminId,
|
||||
email: adminRes.rows[0].email,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return { sessionId, jwt }
|
||||
}
|
||||
|
||||
export async function validateAdminSession(sessionId: string, adminId: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id FROM pitch_admin_sessions s
|
||||
JOIN pitch_admins a ON a.id = s.admin_id
|
||||
WHERE s.id = $1 AND s.admin_id = $2 AND s.revoked = false AND s.expires_at > NOW() AND a.is_active = true`,
|
||||
[sessionId, adminId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
export async function revokeAdminSession(sessionId: string): Promise<void> {
|
||||
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId])
|
||||
}
|
||||
|
||||
export async function revokeAllAdminSessions(adminId: string): Promise<void> {
|
||||
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId])
|
||||
}
|
||||
|
||||
export async function setAdminCookie(jwt: string): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(ADMIN_COOKIE_NAME, jwt, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: ADMIN_SESSION_EXPIRY_HOURS * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearAdminCookie(): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete(ADMIN_COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function getAdminPayloadFromCookie(): Promise<AdminJwtPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(ADMIN_COOKIE_NAME)?.value
|
||||
if (!token) return null
|
||||
return verifyAdminJwt(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side: read the admin row from the cookie. Returns null if no valid session
|
||||
* or the admin is inactive. Use in layout.tsx and API routes.
|
||||
*/
|
||||
export async function getAdminFromCookie(): Promise<Admin | null> {
|
||||
const payload = await getAdminPayloadFromCookie()
|
||||
if (!payload) return null
|
||||
|
||||
const valid = await validateAdminSession(payload.sessionId, payload.sub)
|
||||
if (!valid) return null
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, is_active, last_login_at, created_at
|
||||
FROM pitch_admins WHERE id = $1`,
|
||||
[payload.sub],
|
||||
)
|
||||
if (rows.length === 0 || !rows[0].is_active) return null
|
||||
return rows[0] as Admin
|
||||
}
|
||||
|
||||
/**
|
||||
* API guard: returns the Admin row, OR a NextResponse 401/403 to return early.
|
||||
* Also accepts the legacy PITCH_ADMIN_SECRET bearer header for CLI/automation —
|
||||
* in that case the returned admin id is null but the request is allowed.
|
||||
*/
|
||||
export type AdminGuardResult =
|
||||
| { kind: 'admin'; admin: Admin }
|
||||
| { kind: 'cli' }
|
||||
| { kind: 'response'; response: NextResponse }
|
||||
|
||||
export async function requireAdmin(request: Request): Promise<AdminGuardResult> {
|
||||
// CLI fallback via shared secret
|
||||
const secret = process.env.PITCH_ADMIN_SECRET
|
||||
if (secret) {
|
||||
const auth = request.headers.get('authorization')
|
||||
if (auth === `Bearer ${secret}`) {
|
||||
return { kind: 'cli' }
|
||||
}
|
||||
}
|
||||
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return {
|
||||
kind: 'response',
|
||||
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
}
|
||||
}
|
||||
return { kind: 'admin', admin }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: log an admin-initiated audit event. Falls back to CLI actor when admin is null.
|
||||
*/
|
||||
export async function logAdminAudit(
|
||||
adminId: string | null,
|
||||
action: string,
|
||||
details: Record<string, unknown> = {},
|
||||
request?: Request,
|
||||
targetInvestorId?: string | null,
|
||||
): Promise<void> {
|
||||
await logAudit(
|
||||
null, // investor_id
|
||||
action,
|
||||
details,
|
||||
request,
|
||||
undefined, // slide_id
|
||||
undefined, // session_id
|
||||
adminId,
|
||||
targetInvestorId ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export { ADMIN_COOKIE_NAME }
|
||||
163
pitch-deck/lib/auth.ts
Normal file
163
pitch-deck/lib/auth.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { randomBytes, createHash } from 'crypto'
|
||||
import { cookies } from 'next/headers'
|
||||
import pool from './db'
|
||||
|
||||
const COOKIE_NAME = 'pitch_session'
|
||||
const JWT_EXPIRY = '1h'
|
||||
const SESSION_EXPIRY_HOURS = 24
|
||||
|
||||
function getJwtSecret() {
|
||||
const secret = process.env.PITCH_JWT_SECRET
|
||||
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function generateToken(): string {
|
||||
return randomBytes(48).toString('hex')
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string
|
||||
email: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export async function createJwt(payload: JwtPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(JWT_EXPIRY)
|
||||
.sign(getJwtSecret())
|
||||
}
|
||||
|
||||
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret())
|
||||
return payload as unknown as JwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
investorId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null
|
||||
): Promise<{ sessionId: string; jwt: string }> {
|
||||
// Revoke all existing sessions for this investor (single session enforcement)
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1 AND revoked = false`,
|
||||
[investorId]
|
||||
)
|
||||
|
||||
const sessionToken = generateToken()
|
||||
const tokenHash = hashToken(sessionToken)
|
||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_sessions (investor_id, token_hash, ip_address, user_agent, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[investorId, tokenHash, ip, userAgent, expiresAt]
|
||||
)
|
||||
|
||||
const sessionId = rows[0].id
|
||||
|
||||
// Get investor email for JWT
|
||||
const investor = await pool.query(
|
||||
`SELECT email FROM pitch_investors WHERE id = $1`,
|
||||
[investorId]
|
||||
)
|
||||
|
||||
const jwt = await createJwt({
|
||||
sub: investorId,
|
||||
email: investor.rows[0].email,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return { sessionId, jwt }
|
||||
}
|
||||
|
||||
export async function validateSession(sessionId: string, investorId: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM pitch_sessions
|
||||
WHERE id = $1 AND investor_id = $2 AND revoked = false AND expires_at > NOW()`,
|
||||
[sessionId, investorId]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true WHERE id = $1`,
|
||||
[sessionId]
|
||||
)
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(investorId: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1`,
|
||||
[investorId]
|
||||
)
|
||||
}
|
||||
|
||||
export async function setSessionCookie(jwt: string): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(COOKIE_NAME, jwt, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_EXPIRY_HOURS * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearSessionCookie(): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function getSessionFromCookie(): Promise<JwtPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value
|
||||
if (!token) return null
|
||||
return verifyJwt(token)
|
||||
}
|
||||
|
||||
export function getClientIp(request: Request): string | null {
|
||||
const forwarded = request.headers.get('x-forwarded-for')
|
||||
if (forwarded) return forwarded.split(',')[0].trim()
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateAdminSecret(request: Request): boolean {
|
||||
const secret = process.env.PITCH_ADMIN_SECRET
|
||||
if (!secret) return false
|
||||
const auth = request.headers.get('authorization')
|
||||
if (!auth) return false
|
||||
return auth === `Bearer ${secret}`
|
||||
}
|
||||
|
||||
export async function logAudit(
|
||||
investorId: string | null,
|
||||
action: string,
|
||||
details: Record<string, unknown> = {},
|
||||
request?: Request,
|
||||
slideId?: string,
|
||||
sessionId?: string,
|
||||
adminId?: string | null,
|
||||
targetInvestorId?: string | null,
|
||||
): Promise<void> {
|
||||
const ip = request ? getClientIp(request) : null
|
||||
const ua = request ? request.headers.get('user-agent') : null
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_audit_logs
|
||||
(investor_id, action, details, ip_address, user_agent, slide_id, session_id, admin_id, target_investor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId, adminId ?? null, targetInvestorId ?? null]
|
||||
)
|
||||
}
|
||||
91
pitch-deck/lib/email.ts
Normal file
91
pitch-deck/lib/email.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_PORT === '465',
|
||||
auth: {
|
||||
user: process.env.SMTP_USERNAME,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
const fromName = process.env.SMTP_FROM_NAME || 'BreakPilot'
|
||||
const fromAddr = process.env.SMTP_FROM_ADDR || 'noreply@breakpilot.ai'
|
||||
|
||||
export async function sendMagicLinkEmail(
|
||||
to: string,
|
||||
investorName: string | null,
|
||||
magicLinkUrl: string
|
||||
): Promise<void> {
|
||||
const greeting = investorName ? `Hello ${investorName}` : 'Hello'
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromAddr}>`,
|
||||
to,
|
||||
subject: 'Your BreakPilot Pitch Deck Access',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0a0a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0a0a1a;padding:40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#111127;border-radius:12px;border:1px solid rgba(99,102,241,0.2);">
|
||||
<tr>
|
||||
<td style="padding:40px 40px 20px;">
|
||||
<h1 style="margin:0;font-size:24px;color:#e0e0ff;font-weight:600;">
|
||||
BreakPilot ComplAI
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
|
||||
Investor Pitch Deck
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 40px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
|
||||
${greeting},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
|
||||
You have been invited to view the BreakPilot ComplAI investor pitch deck.
|
||||
Click the button below to access the interactive presentation.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:8px;padding:14px 32px;">
|
||||
<a href="${magicLinkUrl}" style="color:#ffffff;font-size:16px;font-weight:600;text-decoration:none;display:inline-block;">
|
||||
View Pitch Deck
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.5;">
|
||||
This link expires in ${process.env.MAGIC_LINK_TTL_HOURS || '72'} hours and can only be used once.
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.3);line-height:1.5;word-break:break-all;">
|
||||
${magicLinkUrl}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 40px 40px;border-top:1px solid rgba(255,255,255,0.05);">
|
||||
<p style="margin:0;font-size:12px;color:rgba(255,255,255,0.25);line-height:1.5;">
|
||||
If you did not expect this email, you can safely ignore it.
|
||||
This is an AI-first company — we don't do PDFs.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
})
|
||||
}
|
||||
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface AuditTrackerOptions {
|
||||
investorId: string | null
|
||||
currentSlide: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function useAuditTracker({ investorId, currentSlide, enabled }: AuditTrackerOptions) {
|
||||
const lastSlide = useRef<string>('')
|
||||
const slideTimestamps = useRef<Map<string, number>>(new Map())
|
||||
const pendingEvents = useRef<Array<{ action: string; details: Record<string, unknown>; slide_id?: string }>>([])
|
||||
const flushTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (pendingEvents.current.length === 0) return
|
||||
const events = [...pendingEvents.current]
|
||||
pendingEvents.current = []
|
||||
|
||||
// Send events one at a time (they're debounced so there shouldn't be many)
|
||||
for (const event of events) {
|
||||
try {
|
||||
await fetch('/api/audit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail - audit should not block UX
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useCallback((action: string, details: Record<string, unknown> = {}, slideId?: string) => {
|
||||
if (!enabled || !investorId) return
|
||||
|
||||
pendingEvents.current.push({ action, details, slide_id: slideId })
|
||||
|
||||
// Debounce flush by 500ms
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushTimer.current = setTimeout(flush, 500)
|
||||
}, [enabled, investorId, flush])
|
||||
|
||||
// Track slide views
|
||||
useEffect(() => {
|
||||
if (!enabled || !investorId || !currentSlide) return
|
||||
if (currentSlide === lastSlide.current) return
|
||||
|
||||
const now = Date.now()
|
||||
const prevTimestamp = slideTimestamps.current.get(lastSlide.current)
|
||||
const dwellTime = prevTimestamp ? now - prevTimestamp : 0
|
||||
|
||||
lastSlide.current = currentSlide
|
||||
slideTimestamps.current.set(currentSlide, now)
|
||||
|
||||
track('slide_viewed', {
|
||||
slide_id: currentSlide,
|
||||
previous_dwell_ms: dwellTime,
|
||||
}, currentSlide)
|
||||
}, [currentSlide, enabled, investorId, track])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flush()
|
||||
}
|
||||
}, [flush])
|
||||
|
||||
return { track }
|
||||
}
|
||||
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: string
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [investor, setInvestor] = useState<Investor | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInvestor(data.investor)
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchMe()
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/auth'
|
||||
}, [])
|
||||
|
||||
return { investor, loading, logout }
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { FMScenario, FMResult, FMComputeResponse } from '../types'
|
||||
import { FMScenario, FMResult, FMComputeResponse, InvestorSnapshot } from '../types'
|
||||
|
||||
export function useFinancialModel() {
|
||||
export function useFinancialModel(investorId?: string | null) {
|
||||
const [scenarios, setScenarios] = useState<FMScenario[]>([])
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [computing, setComputing] = useState(false)
|
||||
const [snapshotStatus, setSnapshotStatus] = useState<'default' | 'saving' | 'saved' | 'restored'>('default')
|
||||
const computeTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const snapshotTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const snapshotsLoaded = useRef(false)
|
||||
|
||||
// Load scenarios on mount
|
||||
// Load scenarios on mount, then apply snapshots if investor is logged in
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
let data: FMScenario[] = await res.json()
|
||||
|
||||
// If investor is logged in, restore their snapshots
|
||||
if (investorId && !snapshotsLoaded.current) {
|
||||
try {
|
||||
const snapRes = await fetch('/api/snapshots')
|
||||
if (snapRes.ok) {
|
||||
const { snapshots } = await snapRes.json() as { snapshots: InvestorSnapshot[] }
|
||||
if (snapshots.length > 0) {
|
||||
data = data.map(scenario => {
|
||||
const snapshot = snapshots.find(s => s.scenario_id === scenario.id)
|
||||
if (!snapshot) return scenario
|
||||
return {
|
||||
...scenario,
|
||||
assumptions: scenario.assumptions.map(a => {
|
||||
const savedValue = snapshot.assumptions[a.key]
|
||||
return savedValue !== undefined ? { ...a, value: savedValue } : a
|
||||
}),
|
||||
}
|
||||
})
|
||||
setSnapshotStatus('restored')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Snapshot restore failed — use defaults
|
||||
}
|
||||
snapshotsLoaded.current = true
|
||||
}
|
||||
|
||||
setScenarios(data)
|
||||
const defaultScenario = data.find(s => s.is_default) || data[0]
|
||||
if (defaultScenario) {
|
||||
@@ -32,7 +63,7 @@ export function useFinancialModel() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
}, [investorId])
|
||||
|
||||
// Compute when active scenario changes
|
||||
useEffect(() => {
|
||||
@@ -60,6 +91,28 @@ export function useFinancialModel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-save snapshot (debounced)
|
||||
const saveSnapshot = useCallback(async (scenarioId: string) => {
|
||||
if (!investorId) return
|
||||
const scenario = scenarios.find(s => s.id === scenarioId)
|
||||
if (!scenario) return
|
||||
|
||||
const assumptions: Record<string, number | number[]> = {}
|
||||
scenario.assumptions.forEach(a => { assumptions[a.key] = a.value })
|
||||
|
||||
setSnapshotStatus('saving')
|
||||
try {
|
||||
await fetch('/api/snapshots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenario_id: scenarioId, assumptions }),
|
||||
})
|
||||
setSnapshotStatus('saved')
|
||||
} catch {
|
||||
setSnapshotStatus('default')
|
||||
}
|
||||
}, [investorId, scenarios])
|
||||
|
||||
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
||||
// Optimistic update in local state
|
||||
setScenarios(prev => prev.map(s => {
|
||||
@@ -80,7 +133,33 @@ export function useFinancialModel() {
|
||||
// Debounced recompute
|
||||
if (computeTimer.current) clearTimeout(computeTimer.current)
|
||||
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
|
||||
}, [compute])
|
||||
|
||||
// Debounced snapshot save (2s after last change)
|
||||
if (snapshotTimer.current) clearTimeout(snapshotTimer.current)
|
||||
snapshotTimer.current = setTimeout(() => saveSnapshot(scenarioId), 2000)
|
||||
}, [compute, saveSnapshot])
|
||||
|
||||
const resetToDefaults = useCallback(async (scenarioId: string) => {
|
||||
// Reload from server (without snapshots)
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
const defaultScenario = data.find(s => s.id === scenarioId)
|
||||
if (defaultScenario) {
|
||||
setScenarios(prev => prev.map(s => s.id === scenarioId ? defaultScenario : s))
|
||||
// Delete snapshot
|
||||
if (investorId) {
|
||||
await fetch(`/api/snapshots?id=${scenarioId}`, { method: 'DELETE' })
|
||||
}
|
||||
setSnapshotStatus('default')
|
||||
compute(scenarioId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [compute, investorId])
|
||||
|
||||
const computeAll = useCallback(async () => {
|
||||
for (const s of scenarios) {
|
||||
@@ -105,5 +184,7 @@ export function useFinancialModel() {
|
||||
compute,
|
||||
computeAll,
|
||||
updateAssumption,
|
||||
resetToDefaults,
|
||||
snapshotStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface UseKeyboardProps {
|
||||
onFullscreen: () => void
|
||||
onLanguageToggle: () => void
|
||||
onMenuToggle: () => void
|
||||
onPresenterToggle?: () => void
|
||||
onGoToSlide: (index: number) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function useKeyboard({
|
||||
onFullscreen,
|
||||
onLanguageToggle,
|
||||
onMenuToggle,
|
||||
onPresenterToggle,
|
||||
onGoToSlide,
|
||||
enabled = true,
|
||||
}: UseKeyboardProps) {
|
||||
@@ -74,6 +76,11 @@ export function useKeyboard({
|
||||
e.preventDefault()
|
||||
onMenuToggle()
|
||||
break
|
||||
case 'p':
|
||||
case 'P':
|
||||
e.preventDefault()
|
||||
onPresenterToggle?.()
|
||||
break
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
@@ -88,7 +95,7 @@ export function useKeyboard({
|
||||
break
|
||||
}
|
||||
},
|
||||
[enabled, onNext, onPrev, onFirst, onLast, onOverview, onFullscreen, onLanguageToggle, onMenuToggle, onGoToSlide]
|
||||
[enabled, onNext, onPrev, onFirst, onLast, onOverview, onFullscreen, onLanguageToggle, onMenuToggle, onPresenterToggle, onGoToSlide]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
254
pitch-deck/lib/hooks/usePresenterMode.ts
Normal file
254
pitch-deck/lib/hooks/usePresenterMode.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Language, SlideId } from '../types'
|
||||
import { PresenterState } from '../presenter/types'
|
||||
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
|
||||
import { SLIDE_ORDER } from './useSlideNavigation'
|
||||
|
||||
interface UsePresenterModeConfig {
|
||||
goToSlide: (index: number) => void
|
||||
currentSlide: number
|
||||
totalSlides: number
|
||||
language: Language
|
||||
}
|
||||
|
||||
interface UsePresenterModeReturn {
|
||||
state: PresenterState
|
||||
currentParagraph: number
|
||||
start: () => void
|
||||
stop: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
skipSlide: () => void
|
||||
toggle: () => void
|
||||
displayText: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
export function usePresenterMode({
|
||||
goToSlide,
|
||||
currentSlide,
|
||||
totalSlides,
|
||||
language,
|
||||
}: UsePresenterModeConfig): UsePresenterModeReturn {
|
||||
const [state, setState] = useState<PresenterState>('idle')
|
||||
const [currentParagraph, setCurrentParagraph] = useState(0)
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const slideIndexRef = useRef(currentSlide)
|
||||
const paragraphIndexRef = useRef(0)
|
||||
const stateRef = useRef<PresenterState>('idle')
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
slideIndexRef.current = currentSlide
|
||||
}, [currentSlide])
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getScriptForIndex = useCallback((index: number) => {
|
||||
const slideId = SLIDE_ORDER[index]
|
||||
return PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
||||
}, [])
|
||||
|
||||
const showParagraph = useCallback((slideIdx: number, paraIdx: number) => {
|
||||
const script = getScriptForIndex(slideIdx)
|
||||
if (!script || paraIdx >= script.paragraphs.length) return null
|
||||
|
||||
const para = script.paragraphs[paraIdx]
|
||||
const text = language === 'de' ? para.text_de : para.text_en
|
||||
setDisplayText(text)
|
||||
setCurrentParagraph(paraIdx)
|
||||
paragraphIndexRef.current = paraIdx
|
||||
return para
|
||||
}, [language, getScriptForIndex])
|
||||
|
||||
const advancePresentation = useCallback(() => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
|
||||
const slideIdx = slideIndexRef.current
|
||||
const script = getScriptForIndex(slideIdx)
|
||||
|
||||
if (!script) {
|
||||
// No script for this slide, advance to next
|
||||
if (slideIdx < totalSlides - 1) {
|
||||
goToSlide(slideIdx + 1)
|
||||
paragraphIndexRef.current = 0
|
||||
// Schedule next after slide transition
|
||||
timerRef.current = setTimeout(() => advancePresentation(), 2000)
|
||||
} else {
|
||||
setState('idle')
|
||||
setDisplayText('')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextPara = paragraphIndexRef.current + 1
|
||||
|
||||
if (nextPara < script.paragraphs.length) {
|
||||
// Show next paragraph
|
||||
const para = showParagraph(slideIdx, nextPara)
|
||||
if (para) {
|
||||
// Calculate display time: ~150ms per word + pause
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
} else {
|
||||
// All paragraphs done for this slide
|
||||
// Show transition hint briefly
|
||||
if (script.transition_hint_de || script.transition_hint_en) {
|
||||
const hint = language === 'de'
|
||||
? (script.transition_hint_de || '')
|
||||
: (script.transition_hint_en || '')
|
||||
setDisplayText(hint)
|
||||
}
|
||||
|
||||
// Move to next slide
|
||||
if (slideIdx < totalSlides - 1) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
goToSlide(slideIdx + 1)
|
||||
paragraphIndexRef.current = -1 // Will be incremented to 0
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
const nextScript = getScriptForIndex(slideIdx + 1)
|
||||
if (nextScript && nextScript.paragraphs.length > 0) {
|
||||
const para = showParagraph(slideIdx + 1, 0)
|
||||
if (para) {
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
} else {
|
||||
advancePresentation()
|
||||
}
|
||||
}, 1500)
|
||||
}, 2000)
|
||||
} else {
|
||||
// Last slide — done
|
||||
timerRef.current = setTimeout(() => {
|
||||
setState('idle')
|
||||
setDisplayText('')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph])
|
||||
|
||||
const start = useCallback(() => {
|
||||
clearTimer()
|
||||
setState('presenting')
|
||||
|
||||
const slideIdx = slideIndexRef.current
|
||||
const script = getScriptForIndex(slideIdx)
|
||||
|
||||
if (script && script.paragraphs.length > 0) {
|
||||
const para = showParagraph(slideIdx, 0)
|
||||
if (para) {
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
} else {
|
||||
// No script, advance immediately
|
||||
timerRef.current = setTimeout(() => advancePresentation(), 1000)
|
||||
}
|
||||
}, [clearTimer, language, getScriptForIndex, showParagraph, advancePresentation])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
clearTimer()
|
||||
setState('idle')
|
||||
setDisplayText('')
|
||||
setCurrentParagraph(0)
|
||||
paragraphIndexRef.current = 0
|
||||
}, [clearTimer])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
clearTimer()
|
||||
setState('paused')
|
||||
}, [clearTimer])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setState('resuming')
|
||||
// Brief pause before continuing
|
||||
timerRef.current = setTimeout(() => {
|
||||
setState('presenting')
|
||||
// Continue from where we left off
|
||||
advancePresentation()
|
||||
}, 2000)
|
||||
}, [advancePresentation])
|
||||
|
||||
const skipSlide = useCallback(() => {
|
||||
clearTimer()
|
||||
const nextIdx = slideIndexRef.current + 1
|
||||
if (nextIdx < totalSlides) {
|
||||
goToSlide(nextIdx)
|
||||
paragraphIndexRef.current = -1
|
||||
|
||||
if (stateRef.current === 'presenting') {
|
||||
timerRef.current = setTimeout(() => {
|
||||
const script = getScriptForIndex(nextIdx)
|
||||
if (script && script.paragraphs.length > 0) {
|
||||
const para = showParagraph(nextIdx, 0)
|
||||
if (para) {
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}, [clearTimer, totalSlides, goToSlide, language, getScriptForIndex, showParagraph, advancePresentation])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (stateRef.current === 'idle') {
|
||||
start()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
}, [start, stop])
|
||||
|
||||
// Calculate overall progress
|
||||
const progress = (() => {
|
||||
if (state === 'idle') return 0
|
||||
const totalScripts = PRESENTER_SCRIPT.length
|
||||
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide])
|
||||
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
|
||||
|
||||
const script = PRESENTER_SCRIPT[currentScriptIdx]
|
||||
const slideProgress = script.paragraphs.length > 0
|
||||
? currentParagraph / script.paragraphs.length
|
||||
: 0
|
||||
return ((currentScriptIdx + slideProgress) / totalScripts) * 100
|
||||
})()
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => clearTimer()
|
||||
}, [clearTimer])
|
||||
|
||||
return {
|
||||
state,
|
||||
currentParagraph,
|
||||
start,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
skipSlide,
|
||||
toggle,
|
||||
displayText,
|
||||
progress,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { SlideId } from '../types'
|
||||
|
||||
const SLIDE_ORDER: SlideId[] = [
|
||||
export const SLIDE_ORDER: SlideId[] = [
|
||||
'intro-presenter',
|
||||
'cover',
|
||||
'problem',
|
||||
'solution',
|
||||
|
||||
@@ -8,6 +8,7 @@ const translations = {
|
||||
language: 'Sprache',
|
||||
},
|
||||
slideNames: [
|
||||
'Intro',
|
||||
'Cover',
|
||||
'Das Problem',
|
||||
'Die Loesung',
|
||||
@@ -25,6 +26,8 @@ const translations = {
|
||||
'Anhang: Architektur',
|
||||
'Anhang: Go-to-Market',
|
||||
'Anhang: Regulatorik',
|
||||
'Anhang: Engineering',
|
||||
'Anhang: KI-Pipeline',
|
||||
],
|
||||
cover: {
|
||||
tagline: 'Compliance & Code-Security fuer den Maschinenbau',
|
||||
@@ -137,7 +140,7 @@ const translations = {
|
||||
},
|
||||
competition: {
|
||||
title: 'Wettbewerb',
|
||||
subtitle: '44 Features vs. ~15-25 bei Wettbewerbern — 9 einzigartige USPs',
|
||||
subtitle: '44 Features, 9 USPs — kein Anbieter kombiniert DSGVO + Code-Security + Self-Hosted KI',
|
||||
feature: 'Feature',
|
||||
selfHosted: 'Self-Hosted',
|
||||
integratedAI: 'Integrierte KI',
|
||||
@@ -228,6 +231,7 @@ const translations = {
|
||||
language: 'Language',
|
||||
},
|
||||
slideNames: [
|
||||
'Intro',
|
||||
'Cover',
|
||||
'The Problem',
|
||||
'The Solution',
|
||||
@@ -245,6 +249,8 @@ const translations = {
|
||||
'Appendix: Architecture',
|
||||
'Appendix: Go-to-Market',
|
||||
'Appendix: Regulatory',
|
||||
'Appendix: Engineering',
|
||||
'Appendix: AI Pipeline',
|
||||
],
|
||||
cover: {
|
||||
tagline: 'Compliance & Code Security for Machine Manufacturers',
|
||||
@@ -357,7 +363,7 @@ const translations = {
|
||||
},
|
||||
competition: {
|
||||
title: 'Competition',
|
||||
subtitle: '44 features vs. ~15-25 competitors — 9 unique USPs',
|
||||
subtitle: '44 features, 9 USPs — no provider combines GDPR + code security + self-hosted AI',
|
||||
feature: 'Feature',
|
||||
selfHosted: 'Self-Hosted',
|
||||
integratedAI: 'Integrated AI',
|
||||
|
||||
72
pitch-deck/lib/presenter/faq-matcher.ts
Normal file
72
pitch-deck/lib/presenter/faq-matcher.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Language } from '../types'
|
||||
import { FAQEntry } from './types'
|
||||
import { PRESENTER_FAQ } from './presenter-faq'
|
||||
|
||||
/**
|
||||
* Match a user query against pre-cached FAQ entries.
|
||||
* Returns the best match if score exceeds threshold, or null for LLM fallback.
|
||||
*/
|
||||
export function matchFAQ(query: string, lang: Language): FAQEntry | null {
|
||||
const normalized = query.toLowerCase().trim()
|
||||
const queryWords = normalized.split(/\s+/)
|
||||
|
||||
let bestMatch: FAQEntry | null = null
|
||||
let bestScore = 0
|
||||
|
||||
for (const entry of PRESENTER_FAQ) {
|
||||
let score = 0
|
||||
|
||||
// Check keyword matches
|
||||
for (const keyword of entry.keywords) {
|
||||
const kwLower = keyword.toLowerCase()
|
||||
if (kwLower.includes(' ')) {
|
||||
// Multi-word keyword: check if phrase appears in query
|
||||
if (normalized.includes(kwLower)) {
|
||||
score += 3 * entry.priority / 10
|
||||
}
|
||||
} else {
|
||||
// Single keyword: check word-level match
|
||||
if (queryWords.some(w => w === kwLower || w.startsWith(kwLower) || kwLower.startsWith(w))) {
|
||||
score += 1
|
||||
}
|
||||
// Also check if keyword appears anywhere in query (partial match)
|
||||
if (normalized.includes(kwLower)) {
|
||||
score += 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if query matches the question text closely
|
||||
const questionText = lang === 'de' ? entry.question_de : entry.question_en
|
||||
const questionWords = questionText.toLowerCase().split(/\s+/)
|
||||
const overlapCount = queryWords.filter(w =>
|
||||
w.length > 2 && questionWords.some(qw => qw.includes(w) || w.includes(qw))
|
||||
).length
|
||||
if (overlapCount >= 2) {
|
||||
score += overlapCount * 0.5
|
||||
}
|
||||
|
||||
// Weight by priority
|
||||
score *= (entry.priority / 10)
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = entry
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold: need meaningful match to avoid false positives
|
||||
// Require at least 2 keyword hits or strong phrase match
|
||||
if (bestScore < 1.5) {
|
||||
return null
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FAQ answer text in the requested language
|
||||
*/
|
||||
export function getFAQAnswer(entry: FAQEntry, lang: Language): string {
|
||||
return lang === 'de' ? entry.answer_de : entry.answer_en
|
||||
}
|
||||
300
pitch-deck/lib/presenter/presenter-faq.ts
Normal file
300
pitch-deck/lib/presenter/presenter-faq.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { FAQEntry } from './types'
|
||||
|
||||
export const PRESENTER_FAQ: FAQEntry[] = [
|
||||
// === PRODUCT ===
|
||||
{
|
||||
id: 'product-what',
|
||||
keywords: ['was', 'macht', 'breakpilot', 'what', 'does', 'product', 'produkt'],
|
||||
question_de: 'Was macht BreakPilot?',
|
||||
question_en: 'What does BreakPilot do?',
|
||||
answer_de: 'BreakPilot ComplAI ist eine KI-gesteuerte Compliance- und Code-Security-Plattform fuer Maschinenbauer. Eine Self-Hosted Hardware-Appliance (Mac Mini/Studio) scannt Code und erstellt Compliance-Dokumente. 57 Module decken DSGVO, AI Act, CRA und NIS2 ab — mit 2.274 indexierten Rechtstexten.',
|
||||
answer_en: 'BreakPilot ComplAI is an AI-powered compliance and code security platform for machine manufacturers. A self-hosted hardware appliance (Mac Mini/Studio) scans code and creates compliance documents. 57 modules cover GDPR, AI Act, CRA and NIS2 — with 2,274 indexed legal texts.',
|
||||
goto_slide: 'solution',
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
id: 'product-modules',
|
||||
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: '57 Compliance-Module: DSGVO (VVT, DSFA, TOM, DSR, Loeschfristen), AI Act (Risikoklassifizierung, Dokumentation), CRA (Code-Security, SBOM), NIS2 (Incident Response, Notfallplan), plus Vendor Compliance, Audit-Management, Policy Generator und mehr.',
|
||||
answer_en: '57 compliance modules: GDPR (RoPA, DPIA, TOMs, DSR, deletion deadlines), AI Act (risk classification, documentation), CRA (code security, SBOM), NIS2 (incident response, emergency plans), plus vendor compliance, audit management, policy generator and more.',
|
||||
goto_slide: 'solution',
|
||||
priority: 8,
|
||||
},
|
||||
{
|
||||
id: 'product-dsgvo-vs-aiact',
|
||||
keywords: ['dsgvo', 'gdpr', 'ai act', 'unterschied', 'difference', 'versus', 'vs'],
|
||||
question_de: 'Was ist der Unterschied zwischen DSGVO und AI Act Compliance?',
|
||||
question_en: 'What is the difference between GDPR and AI Act compliance?',
|
||||
answer_de: 'DSGVO schuetzt personenbezogene Daten (Verarbeitungsverzeichnis, Loeschfristen, Betroffenenrechte). Der AI Act reguliert KI-Systeme (Risikoklassifizierung, Transparenz, Human Oversight). Maschinenbauer mit KI in ihren Produkten brauchen beides — und genau das liefern wir.',
|
||||
answer_en: 'GDPR protects personal data (records of processing, deletion deadlines, data subject rights). The AI Act regulates AI systems (risk classification, transparency, human oversight). Machine manufacturers with AI in their products need both — and that is exactly what we deliver.',
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
// === TECHNOLOGY ===
|
||||
{
|
||||
id: 'tech-llm',
|
||||
keywords: ['llm', 'modell', 'model', 'ki', 'ai', 'kuenstliche intelligenz', 'artificial intelligence', 'welches', 'which'],
|
||||
question_de: 'Welches KI-Modell nutzt ihr?',
|
||||
question_en: 'Which AI model do you use?',
|
||||
answer_de: 'Wir setzen auf eine proprietaere KI-Engine mit verschiedenen Modellgroessen: 32B Parameter lokal auf Mac Mini, 40B auf Mac Studio, und ein BSI-zertifiziertes 1000B Cloud-LLM fuer komplexe Aufgaben. Die lokalen Modelle machen die Vorarbeit, die Cloud implementiert Fixes.',
|
||||
answer_en: 'We use a proprietary AI engine with different model sizes: 32B parameters locally on Mac Mini, 40B on Mac Studio, and a BSI-certified 1000B cloud LLM for complex tasks. Local models do the preprocessing, the cloud implements fixes.',
|
||||
goto_slide: 'product',
|
||||
priority: 8,
|
||||
},
|
||||
{
|
||||
id: 'tech-opensource',
|
||||
keywords: ['open source', 'opensource', 'quellcode', 'source code', 'lizenz', 'license'],
|
||||
question_de: 'Ist die Plattform Open Source?',
|
||||
question_en: 'Is the platform open source?',
|
||||
answer_de: 'Die Plattform selbst ist proprietaer, nutzt aber ausschliesslich Open-Source-Dependencies mit kommerziell nutzbaren Lizenzen (MIT, Apache-2.0, BSD). Wir verwenden keine GPL/AGPL-abhaengigkeiten. Die Hardware laeuft auf Apple Silicon.',
|
||||
answer_en: 'The platform itself is proprietary but uses exclusively open source dependencies with commercially usable licenses (MIT, Apache-2.0, BSD). We use no GPL/AGPL dependencies. The hardware runs on Apple Silicon.',
|
||||
priority: 6,
|
||||
},
|
||||
{
|
||||
id: 'tech-security',
|
||||
keywords: ['sicherheit', 'security', 'datenschutz', 'privacy', 'verschluesselung', 'encryption', 'hosting'],
|
||||
question_de: 'Wie sicher sind die Daten?',
|
||||
question_en: 'How secure is the data?',
|
||||
answer_de: 'Maximale Datensouveraenitaet: Die Hardware steht im Serverraum des Kunden. Alle Daten bleiben on-premise. Nur anonymisierte Anfragen gehen an die BSI-zertifizierte Cloud. TLS 1.3 fuer alle Verbindungen, Vault fuer Secrets, Enterprise-Grade Verschluesselung.',
|
||||
answer_en: 'Maximum data sovereignty: The hardware sits in the customer server room. All data stays on-premise. Only anonymized queries go to the BSI-certified cloud. TLS 1.3 for all connections, Vault for secrets, enterprise-grade encryption.',
|
||||
goto_slide: 'annex-architecture',
|
||||
priority: 8,
|
||||
},
|
||||
{
|
||||
id: 'tech-selfhosted',
|
||||
keywords: ['self-hosted', 'selfhosted', 'self hosted', 'on-premise', 'onpremise', 'lokal', 'local', 'warum', 'why', 'mac'],
|
||||
question_de: 'Warum Self-Hosted auf Apple Hardware?',
|
||||
question_en: 'Why self-hosted on Apple hardware?',
|
||||
answer_de: 'Drei Gruende: 1) Datensouveraenitaet — Kundendaten verlassen nie das Unternehmen. 2) Apple Silicon bietet das beste Preis-Leistungs-Verhaeltnis fuer lokale LLM-Inferenz. 3) Hardware-Moat — die Appliance schafft einen natuerlichen Lock-in und differentiert uns vom Wettbewerb.',
|
||||
answer_en: 'Three reasons: 1) Data sovereignty — customer data never leaves the company. 2) Apple Silicon offers the best price-performance ratio for local LLM inference. 3) Hardware moat — the appliance creates a natural lock-in and differentiates us from competition.',
|
||||
goto_slide: 'solution',
|
||||
priority: 7,
|
||||
},
|
||||
{
|
||||
id: 'tech-code-scanning',
|
||||
keywords: ['code', 'scan', 'scanning', 'firmware', 'devsecops', 'trivy', 'semgrep', 'schwachstellen', 'vulnerability'],
|
||||
question_de: 'Wie funktioniert das Code-Scanning?',
|
||||
question_en: 'How does code scanning work?',
|
||||
answer_de: 'Integrierte DevSecOps-Tools (Trivy, Semgrep, Gitleaks) scannen automatisch Git-Repos und Firmware bei jedem Commit. Die lokale KI priorisiert Findings, das Cloud-LLM implementiert Fixes und schreibt Risikoanalysen fuer den CRA.',
|
||||
answer_en: 'Integrated DevSecOps tools (Trivy, Semgrep, Gitleaks) automatically scan Git repos and firmware on every commit. The local AI prioritizes findings, the cloud LLM implements fixes and writes risk assessments for the CRA.',
|
||||
goto_slide: 'how-it-works',
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
// === MARKET ===
|
||||
{
|
||||
id: 'market-tam',
|
||||
keywords: ['tam', 'sam', 'som', 'markt', 'market', 'marktgroesse', 'market size', 'adressierbar'],
|
||||
question_de: 'Wie gross ist der Markt?',
|
||||
question_en: 'How big is the market?',
|
||||
answer_de: 'TAM: 8,7 Mrd. EUR (globaler RegTech-Markt, +23% p.a.). SAM: 1,2 Mrd. EUR (DACH-Maschinenbauer mit Softwareentwicklung). SOM: 7,2 Mio. EUR (500 Kunden x 14.400 EUR/Jahr). Der CRA allein betrifft ueber 30.000 Hersteller in der EU.',
|
||||
answer_en: 'TAM: EUR 8.7B (global RegTech market, +23% p.a.). SAM: EUR 1.2B (DACH machine manufacturers with software development). SOM: EUR 7.2M (500 customers x EUR 14,400/year). The CRA alone affects over 30,000 manufacturers in the EU.',
|
||||
goto_slide: 'market',
|
||||
priority: 9,
|
||||
},
|
||||
{
|
||||
id: 'market-target',
|
||||
keywords: ['zielgruppe', 'target', 'kunden', 'customers', 'wer', 'who', 'maschinenbau', 'machine', 'vdma'],
|
||||
question_de: 'Wer ist die Zielgruppe?',
|
||||
question_en: 'Who is the target audience?',
|
||||
answer_de: 'Primaer: Maschinen- und Anlagenbauer (VDMA ~3.600 Mitglieder in DE, ~5.000 DACH) die eigene Software und Firmware entwickeln. Diese Unternehmen muessen CRA, DSGVO, AI Act und NIS2 einhalten — haben aber keine spezialisierten Compliance-Tools fuer ihren Code.',
|
||||
answer_en: 'Primary: Machine and plant manufacturers (VDMA ~3,600 members in DE, ~5,000 DACH) who develop their own software and firmware. These companies must comply with CRA, GDPR, AI Act and NIS2 — but lack specialized compliance tools for their code.',
|
||||
goto_slide: 'market',
|
||||
priority: 8,
|
||||
},
|
||||
|
||||
// === COMPETITION ===
|
||||
{
|
||||
id: 'competition-diff',
|
||||
keywords: ['wettbewerb', 'competition', 'konkurrenz', 'unterschied', 'differenzierung', 'differentiation', 'proliance', 'dataguard', 'heydata', 'vanta'],
|
||||
question_de: 'Was unterscheidet euch vom Wettbewerb?',
|
||||
question_en: 'What differentiates you from the competition?',
|
||||
answer_de: 'Drei entscheidende Unterschiede: 1) Code-Security — wir scannen Firmware und Software, das kann kein anderer Compliance-Anbieter. 2) Self-Hosted KI — maximale Datensouveraenitaet. 3) Produkt-Compliance — wir machen nicht nur das Unternehmen, sondern auch die Produkte compliant (CRA).',
|
||||
answer_en: 'Three decisive differences: 1) Code security — we scan firmware and software, no other compliance provider can do that. 2) Self-hosted AI — maximum data sovereignty. 3) Product compliance — we make not only the company, but also the products compliant (CRA).',
|
||||
goto_slide: 'competition',
|
||||
priority: 9,
|
||||
},
|
||||
{
|
||||
id: 'competition-proliance',
|
||||
keywords: ['proliance', 'dataguard', 'heydata', 'vergleich', 'comparison', 'versus'],
|
||||
question_de: 'Warum koennen 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, Firmware-Analyse oder CRA-Compliance. Sie machen das Unternehmen compliant, aber nicht die Produkte.',
|
||||
answer_en: 'Proliance, DataGuard and heyData focus on organizational GDPR compliance — records of processing, privacy policies, training. None offer code scanning, firmware analysis or CRA compliance. They make the company compliant, but not the products.',
|
||||
goto_slide: 'competition',
|
||||
priority: 8,
|
||||
},
|
||||
|
||||
// === BUSINESS MODEL ===
|
||||
{
|
||||
id: 'biz-pricing',
|
||||
keywords: ['preis', 'price', 'pricing', 'kosten', 'cost', 'kostet', 'costs', 'wie viel', 'how much', 'subscription'],
|
||||
question_de: 'Was kostet BreakPilot?',
|
||||
question_en: 'What does BreakPilot cost?',
|
||||
answer_de: 'Drei Tiers: ComplAI Starter (Mac Mini) ab 1.200 EUR/Monat, Professional (Mac Studio) ab 2.400 EUR/Monat, Enterprise mit Cloud-LLM ab 3.600 EUR/Monat. Die Hardware wird einmalig bereitgestellt, die Software laeuft als monatliche Subscription.',
|
||||
answer_en: 'Three tiers: ComplAI Starter (Mac Mini) from EUR 1,200/month, Professional (Mac Studio) from EUR 2,400/month, Enterprise with cloud LLM from EUR 3,600/month. Hardware is provided once, software runs as monthly subscription.',
|
||||
goto_slide: 'product',
|
||||
priority: 9,
|
||||
},
|
||||
{
|
||||
id: 'biz-revenue',
|
||||
keywords: ['umsatz', 'revenue', 'arr', 'mrr', 'recurring', 'einnahmen', 'income'],
|
||||
question_de: 'Wie sieht der Umsatzplan aus?',
|
||||
question_en: 'What does the revenue plan look like?',
|
||||
answer_de: 'Von 36k EUR (2026) auf 8,4 Mio EUR (2030). Rein recurring — monatliche Subscriptions. 380 Kunden bei 5,5 Mio EUR ARR in 2030. Break-Even voraussichtlich Ende 2028.',
|
||||
answer_en: 'From EUR 36k (2026) to EUR 8.4M (2030). Purely recurring — monthly subscriptions. 380 customers at EUR 5.5M ARR in 2030. Break-even expected end of 2028.',
|
||||
goto_slide: 'financials',
|
||||
priority: 8,
|
||||
},
|
||||
{
|
||||
id: 'biz-unit-economics',
|
||||
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 ueber 70%. Hardware-Kosten amortisieren sich in wenigen Monaten. LTV/CAC Ratio verbessert sich auf ueber 5x bis 2029. Die AI-First Architektur haelt die operativen Kosten pro Kunde extrem niedrig.',
|
||||
answer_en: 'Gross margin above 70%. Hardware costs amortize in a few months. LTV/CAC ratio improves to over 5x by 2029. The AI-first architecture keeps operational costs per customer extremely low.',
|
||||
goto_slide: 'business-model',
|
||||
priority: 7,
|
||||
},
|
||||
{
|
||||
id: 'biz-scalability',
|
||||
keywords: ['skalierung', 'scalability', 'skalierbar', 'scalable', 'wachstum', 'growth', 'personal', 'headcount'],
|
||||
question_de: 'Wie skaliert das Geschaeftsmodell?',
|
||||
question_en: 'How does the business model scale?',
|
||||
answer_de: 'AI-First bedeutet: 10x Kunden erfordert nicht 10x Personal. Die KI automatisiert Compliance-Analyse und Code-Scanning. Das Team waechst von 2 auf 18 Personen bei 380 Kunden. Die Hardware-Appliance ist Plug-and-Play — kein aufwendiges Onboarding noetig.',
|
||||
answer_en: 'AI-first means: 10x customers does not require 10x headcount. The AI automates compliance analysis and code scanning. The team grows from 2 to 18 people at 380 customers. The hardware appliance is plug-and-play — no extensive onboarding needed.',
|
||||
goto_slide: 'financials',
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
// === TEAM ===
|
||||
{
|
||||
id: 'team-founders',
|
||||
keywords: ['team', 'gruender', 'founders', 'wer', 'who', 'erfahrung', 'experience', 'hintergrund', 'background'],
|
||||
question_de: 'Wer sind die Gruender?',
|
||||
question_en: 'Who are the founders?',
|
||||
answer_de: 'Unser Gruenderteam vereint tiefe Domain-Expertise: Compliance-Wissen aus der Praxis, Software-Architektur fuer skalierbare Systeme, und KI-Erfahrung mit Large Language Models. Details finden Sie auf der Team-Slide.',
|
||||
answer_en: 'Our founding team combines deep domain expertise: Compliance knowledge from practice, software architecture for scalable systems, and AI experience with large language models. Details on the team slide.',
|
||||
goto_slide: 'team',
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
// === INVESTMENT ===
|
||||
{
|
||||
id: 'invest-amount',
|
||||
keywords: ['investment', 'investition', 'funding', 'finanzierung', 'wie viel', 'how much', 'kapital', 'capital', 'runde', 'round'],
|
||||
question_de: 'Wie viel Kapital sucht ihr?',
|
||||
question_en: 'How much capital are you seeking?',
|
||||
answer_de: 'Wir suchen eine Pre-Seed Finanzierung. Das genaue Volumen und die Konditionen besprechen wir gerne im Detail. Das Kapital fliesst in Engineering, Vertrieb, Hardware-Bestand und regulatorische Reserve.',
|
||||
answer_en: 'We are seeking pre-seed funding. We are happy to discuss the exact volume and terms in detail. Capital goes into engineering, sales, hardware inventory and regulatory reserve.',
|
||||
goto_slide: 'the-ask',
|
||||
priority: 9,
|
||||
},
|
||||
{
|
||||
id: 'invest-use-of-funds',
|
||||
keywords: ['use of funds', 'wofuer', 'what for', 'verwendung', 'allocation', 'mittelverwendung'],
|
||||
question_de: 'Wofuer wird das Kapital verwendet?',
|
||||
question_en: 'What will the capital be used for?',
|
||||
answer_de: 'Vier Bereiche: 1) Engineering — Produktreife und weitere Module. 2) Vertrieb — erste Pilotkunden im VDMA-Netzwerk. 3) Hardware — Mac Minis/Studios fuer schnelle Auslieferung. 4) Reserve — regulatorische Anforderungen und Working Capital.',
|
||||
answer_en: 'Four areas: 1) Engineering — product maturity and additional modules. 2) Sales — first pilot customers in VDMA network. 3) Hardware — Mac Minis/Studios for fast delivery. 4) Reserve — regulatory requirements and working capital.',
|
||||
goto_slide: 'the-ask',
|
||||
priority: 8,
|
||||
},
|
||||
{
|
||||
id: 'invest-runway',
|
||||
keywords: ['runway', 'burn', 'burn rate', 'reicht', 'lasts', 'monate', 'months', 'cashflow'],
|
||||
question_de: 'Wie lang reicht das Kapital?',
|
||||
question_en: 'How long does the capital last?',
|
||||
answer_de: 'Die Pre-Seed Runde finanziert uns bis zur naechsten Finanzierungsrunde. Dank AI-First Architektur und Self-Hosted Ansatz ist unsere Burn Rate signifikant niedriger als bei Cloud-basierten SaaS-Startups.',
|
||||
answer_en: 'The pre-seed round funds us until the next financing round. Thanks to AI-first architecture and self-hosted approach, our burn rate is significantly lower than cloud-based SaaS startups.',
|
||||
goto_slide: 'financials',
|
||||
priority: 7,
|
||||
},
|
||||
{
|
||||
id: 'invest-valuation',
|
||||
keywords: ['bewertung', 'valuation', 'konditionen', 'terms', 'instrument', 'safe', 'convertible'],
|
||||
question_de: 'Was ist die Bewertung?',
|
||||
question_en: 'What is the valuation?',
|
||||
answer_de: 'Die genaue Bewertung und das Instrument besprechen wir gerne im persoenlichen Gespraech. Wir sind offen fuer marktgerechte Strukturen, die fuer beide Seiten attraktiv sind.',
|
||||
answer_en: 'We are happy to discuss the exact valuation and instrument in a personal meeting. We are open to market-standard structures that are attractive for both sides.',
|
||||
goto_slide: 'the-ask',
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
// === REGULATORY ===
|
||||
{
|
||||
id: 'reg-cra',
|
||||
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 — ueber den gesamten Lebenszyklus. Fuer Maschinenbauer mit Firmware und embedded Software bedeutet das: Vulnerability Management, SBOM, Incident Reporting. Genau das automatisiert BreakPilot.',
|
||||
answer_en: 'The CRA obligates manufacturers to secure software in their products — throughout the entire lifecycle. For machine manufacturers with firmware and embedded software this means: vulnerability management, SBOM, incident reporting. That is exactly what BreakPilot automates.',
|
||||
goto_slide: 'annex-regulatory',
|
||||
priority: 7,
|
||||
},
|
||||
{
|
||||
id: 'reg-nis2',
|
||||
keywords: ['nis2', 'nis 2', 'cybersecurity', 'kritische infrastruktur', 'critical infrastructure'],
|
||||
question_de: 'Was bedeutet NIS2 fuer Maschinenbauer?',
|
||||
question_en: 'What does NIS2 mean for machine manufacturers?',
|
||||
answer_de: 'NIS2 erweitert die Cybersecurity-Pflichten auf den Maschinenbau. Unternehmen muessen Risikomanagement, Incident Response und Supply-Chain-Security implementieren. BreakPilot bietet Module fuer alle drei Bereiche.',
|
||||
answer_en: 'NIS2 extends cybersecurity obligations to machine manufacturing. Companies must implement risk management, incident response and supply chain security. BreakPilot offers modules for all three areas.',
|
||||
goto_slide: 'annex-regulatory',
|
||||
priority: 6,
|
||||
},
|
||||
|
||||
// === GTM ===
|
||||
{
|
||||
id: 'gtm-strategy',
|
||||
keywords: ['gtm', 'go-to-market', 'go to market', 'vertrieb', 'sales', 'strategie', 'strategy', 'akquise', 'acquisition'],
|
||||
question_de: 'Wie gewinnt ihr Kunden?',
|
||||
question_en: 'How do you acquire customers?',
|
||||
answer_de: 'Drei Kanaele: 1) VDMA-Netzwerk — direkter Zugang zu 3.600+ Maschinenbauern. 2) Partnerschaften mit Systemhaeusern und IT-Dienstleistern. 3) Content Marketing ueber Compliance-Webinare und CRA-Awareness. Start mit 5 Pilotkunden, dann skalieren.',
|
||||
answer_en: 'Three channels: 1) VDMA network — direct access to 3,600+ machine manufacturers. 2) Partnerships with system integrators and IT service providers. 3) Content marketing through compliance webinars and CRA awareness. Start with 5 pilot customers, then scale.',
|
||||
goto_slide: 'annex-gtm',
|
||||
priority: 7,
|
||||
},
|
||||
{
|
||||
id: 'gtm-timeline',
|
||||
keywords: ['timeline', 'zeitplan', 'schedule', 'wann', 'when', 'roadmap', 'meilensteine', 'milestones'],
|
||||
question_de: 'Wann kommen die ersten Kunden?',
|
||||
question_en: 'When will the first customers come?',
|
||||
answer_de: 'Die Plattform ist produktionsreif. Nach der Pre-Seed Runde starten wir sofort mit Pilotprojekten. Ziel: 5 zahlende Pilotkunden innerhalb von 6 Monaten, 20 Kunden nach 12 Monaten.',
|
||||
answer_en: 'The platform is production-ready. After the pre-seed round we start pilot projects immediately. Target: 5 paying pilot customers within 6 months, 20 customers after 12 months.',
|
||||
goto_slide: 'traction',
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
// === PENTESTING CONVERGENCE ===
|
||||
{
|
||||
id: 'pentest',
|
||||
keywords: ['pentesting', 'penetrationstest', 'penetration test', 'security testing', 'pentests'],
|
||||
question_de: 'Plant ihr auch Pentesting-Services?',
|
||||
question_en: 'Do you plan pentesting services?',
|
||||
answer_de: 'Ja, Pentesting ist eine natuerliche Erweiterung. Unsere Code-Security-Tools identifizieren bereits Schwachstellen — der naechste Schritt ist automatisiertes Pentesting. Der Pentesting-Markt konvergiert mit Compliance — wir sind an der Schnittstelle positioniert.',
|
||||
answer_en: 'Yes, pentesting is a natural extension. Our code security tools already identify vulnerabilities — the next step is automated pentesting. The pentesting market converges with compliance — we are positioned at the intersection.',
|
||||
priority: 6,
|
||||
},
|
||||
|
||||
// === MISC ===
|
||||
{
|
||||
id: 'misc-demo',
|
||||
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 Plattform live — inklusive Code-Scanning, Compliance-Module und KI-Analyse. Kontaktieren Sie uns fuer einen Termin.',
|
||||
answer_en: 'Absolutely! We will show you the platform live — including code scanning, compliance modules and AI analysis. Contact us for an appointment.',
|
||||
priority: 6,
|
||||
},
|
||||
{
|
||||
id: 'misc-contact',
|
||||
keywords: ['kontakt', 'contact', 'email', 'mail', 'erreichen', 'reach', 'termin', 'meeting', 'appointment'],
|
||||
question_de: 'Wie kann ich euch kontaktieren?',
|
||||
question_en: 'How can I contact you?',
|
||||
answer_de: 'Schreiben Sie uns eine E-Mail oder vereinbaren Sie direkt einen Termin ueber unsere Website. Wir freuen uns auf das Gespraech!',
|
||||
answer_en: 'Send us an email or schedule a meeting directly through our website. We look forward to the conversation!',
|
||||
priority: 5,
|
||||
},
|
||||
]
|
||||
463
pitch-deck/lib/presenter/presenter-script.ts
Normal file
463
pitch-deck/lib/presenter/presenter-script.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { SlideScript } from './types'
|
||||
|
||||
export const PRESENTER_SCRIPT: SlideScript[] = [
|
||||
// 0 — intro-presenter (45s)
|
||||
{
|
||||
slideId: 'intro-presenter',
|
||||
duration: 45,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Willkommen bei BreakPilot ComplAI — Compliance und Code-Security fuer den Maschinenbau.',
|
||||
text_en: 'Welcome to BreakPilot ComplAI — compliance and code security for machine manufacturing.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Ich bin Ihr KI-Praesentator und fuehre Sie durch unser Pitch Deck. Die Praesentation dauert etwa 15 Minuten.',
|
||||
text_en: 'I am your AI presenter and will guide you through our pitch deck. The presentation takes about 15 minutes.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Sie koennen jederzeit Fragen stellen — nutzen Sie einfach den Chat. Ich pausiere automatisch und antworte sofort.',
|
||||
text_en: 'You can ask questions at any time — just use the chat. I will pause automatically and respond immediately.',
|
||||
pause_after: 1000,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Lassen Sie uns beginnen.',
|
||||
transition_hint_en: 'Let us begin.',
|
||||
},
|
||||
|
||||
// 1 — cover (20s)
|
||||
{
|
||||
slideId: 'cover',
|
||||
duration: 20,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'BreakPilot ComplAI — Compliance und Code-Security auf Autopilot. Pre-Seed Runde, Q4 2026.',
|
||||
text_en: 'BreakPilot ComplAI — compliance and code security on autopilot. Pre-seed round, Q4 2026.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Schauen wir uns zunaechst das Problem an.',
|
||||
transition_hint_en: 'Let us first look at the problem.',
|
||||
},
|
||||
|
||||
// 2 — problem (60s)
|
||||
{
|
||||
slideId: 'problem',
|
||||
duration: 60,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Maschinenbauer entwickeln immer mehr eigene Software — Firmware, Steuerungen, Predictive Maintenance. Aber wer sichert Compliance und Code-Sicherheit?',
|
||||
text_en: 'Machine manufacturers develop more and more of their own software — firmware, controllers, predictive maintenance. But who ensures compliance and code security?',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Erstens: DSGVO. 4,1 Milliarden Euro Bussgelder seit 2018. Maschinenbauer verarbeiten Kundendaten, Telemetrie und Wartungsprotokolle — oft ohne DSGVO-Prozesse.',
|
||||
text_en: 'First: GDPR. EUR 4.1 billion in fines since 2018. Machine manufacturers process customer data, telemetry and maintenance logs — often without GDPR processes.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Zweitens: Der AI Act. Ab August 2025 muessen Maschinen mit KI-Komponenten klassifiziert und dokumentiert werden.',
|
||||
text_en: 'Second: The AI Act. From August 2025, machines with AI components must be classified and documented.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Drittens: CRA und NIS2. Der Cyber Resilience Act verpflichtet ueber 30.000 Hersteller, Software in ihren Produkten abzusichern. Das betrifft den gesamten Maschinenbau.',
|
||||
text_en: 'Third: CRA and NIS2. The Cyber Resilience Act obligates over 30,000 manufacturers to secure software in their products. This affects all of machine manufacturing.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Und genau dafuer haben wir eine Loesung.',
|
||||
transition_hint_en: 'And that is exactly what we have a solution for.',
|
||||
},
|
||||
|
||||
// 3 — solution (75s)
|
||||
{
|
||||
slideId: 'solution',
|
||||
duration: 75,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Unsere Loesung: ComplAI — Compliance und Code-Security auf Autopilot. Drei Saeulen machen uns einzigartig.',
|
||||
text_en: 'Our solution: ComplAI — compliance and code security on autopilot. Three pillars make us unique.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Erste Saeule: Self-Hosted Vorarbeit. Ein Mac Mini oder Mac Studio im Serverraum des Kunden scannt Code, analysiert Repositories und erstellt Compliance-Dokumente. Kein einziges Byte verlaesst das Unternehmen.',
|
||||
text_en: 'First pillar: Self-hosted preprocessing. A Mac Mini or Mac Studio in the customer server room scans code, analyzes repositories and creates compliance documents. Not a single byte leaves the company.',
|
||||
pause_after: 2500,
|
||||
},
|
||||
{
|
||||
text_de: 'Zweite Saeule: Code-Security und DevSecOps. Wir scannen Firmware und Software mit integrierten Tools wie Trivy, Semgrep und Gitleaks. Das 1000-Milliarden-Parameter Cloud-LLM implementiert Fixes und schreibt Risikoanalysen.',
|
||||
text_en: 'Second pillar: Code security and DevSecOps. We scan firmware and software with integrated tools like Trivy, Semgrep, and Gitleaks. The 1000-billion-parameter cloud LLM implements fixes and writes risk assessments.',
|
||||
pause_after: 2500,
|
||||
},
|
||||
{
|
||||
text_de: 'Dritte Saeule: Die Compliance-KI mit 57 Modulen. DSGVO, AI Act, CRA, NIS2, Hinweisgeberschutzgesetz — 19 Regularien, 2.274 indexierte Rechtstexte. Macht Ihr Unternehmen UND Ihre Produkte compliant.',
|
||||
text_en: 'Third pillar: The compliance AI with 57 modules. GDPR, AI Act, CRA, NIS2, Whistleblower Protection Act — 19 regulations, 2,274 indexed legal texts. Makes your company AND your products compliant.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Schauen wir uns die Produkte im Detail an.',
|
||||
transition_hint_en: 'Let us look at the products in detail.',
|
||||
},
|
||||
|
||||
// 4 — product (60s)
|
||||
{
|
||||
slideId: 'product',
|
||||
duration: 60,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Wir bieten drei Produkt-Tiers fuer jede Unternehmensgroesse an.',
|
||||
text_en: 'We offer three product tiers for every company size.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'ComplAI Starter auf dem Mac Mini fuer 1.200 Euro pro Monat. Ein 32-Milliarden-Parameter Modell — perfekt fuer KMU, die DSGVO-Compliance automatisieren wollen.',
|
||||
text_en: 'ComplAI Starter on Mac Mini for EUR 1,200 per month. A 32-billion-parameter model — perfect for SMEs looking to automate GDPR compliance.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'ComplAI Professional auf dem Mac Studio fuer 2.400 Euro pro Monat mit einem 40-Milliarden-Parameter Modell. Inklusive Code-Security-Scanning und erweiterte Risikoanalyse.',
|
||||
text_en: 'ComplAI Professional on Mac Studio for EUR 2,400 per month with a 40-billion-parameter model. Including code security scanning and extended risk assessment.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Und ComplAI Enterprise — unser Flaggschiff. Self-Hosted plus BSI-zertifiziertes 1000-Milliarden-Parameter Cloud-LLM. Fuer Unternehmen, die maximale Compliance und Code-Security benoetigen.',
|
||||
text_en: 'And ComplAI Enterprise — our flagship. Self-hosted plus BSI-certified 1000-billion-parameter cloud LLM. For companies needing maximum compliance and code security.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Wie funktioniert das Ganze in der Praxis?',
|
||||
transition_hint_en: 'How does this work in practice?',
|
||||
},
|
||||
|
||||
// 5 — how-it-works (50s)
|
||||
{
|
||||
slideId: 'how-it-works',
|
||||
duration: 50,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'In vier einfachen Schritten zu Compliance und Code-Security.',
|
||||
text_en: 'Compliance and code security in four simple steps.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Schritt 1: Hardware aufstellen. Mac Mini oder Mac Studio im Serverraum anschliessen — Plug and Play, scannt ab Tag 1.',
|
||||
text_en: 'Step 1: Set up hardware. Connect Mac Mini or Mac Studio in the server room — plug and play, scans from day one.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Schritt 2: Code-Repos verbinden. Git-Repos, CI/CD Pipelines und Firmware-Projekte anbinden.',
|
||||
text_en: 'Step 2: Connect code repos. Link Git repos, CI/CD pipelines and firmware projects.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Schritt 3: Automatisierung. Laufende Code-Analyse und Risikoanalysen bei jeder Aenderung. Bei kritischen Fixes schaltet sich das Cloud-LLM zu.',
|
||||
text_en: 'Step 3: Automation. Continuous code analysis and risk assessments on every change. For critical fixes, the cloud LLM steps in.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Schritt 4: Audit bestehen. Vollstaendige Dokumentation fuer DSGVO, AI Act, CRA und NIS2 auf Knopfdruck.',
|
||||
text_en: 'Step 4: Pass audits. Complete documentation for GDPR, AI Act, CRA and NIS2 at the push of a button.',
|
||||
pause_after: 1000,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Jetzt zur Marktchance.',
|
||||
transition_hint_en: 'Now to the market opportunity.',
|
||||
},
|
||||
|
||||
// 6 — market (60s)
|
||||
{
|
||||
slideId: 'market',
|
||||
duration: 60,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Der Markt fuer Compliance und Code-Security im Maschinenbau ist enorm.',
|
||||
text_en: 'The market for compliance and code security in machine manufacturing is enormous.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Unser Total Addressable Market liegt bei 8,7 Milliarden Euro — der globale Markt fuer Regulatory Technology waechst mit 23 Prozent pro Jahr.',
|
||||
text_en: 'Our Total Addressable Market is EUR 8.7 billion — the global regulatory technology market grows at 23 percent per year.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Der Serviceable Addressable Market in DACH: 1,2 Milliarden Euro. Das sind ueber 5.000 Maschinenbauer mit eigener Softwareentwicklung.',
|
||||
text_en: 'The Serviceable Addressable Market in DACH: EUR 1.2 billion. That is over 5,000 machine manufacturers with in-house software development.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Unser Serviceable Obtainable Market: 7,2 Millionen Euro. 500 DACH-Maschinenbauer mal 14.400 Euro pro Jahr. Das ist unser realistisches Ziel fuer die naechsten 5 Jahre.',
|
||||
text_en: 'Our Serviceable Obtainable Market: EUR 7.2 million. 500 DACH machine manufacturers times EUR 14,400 per year. That is our realistic target for the next 5 years.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Wie verdienen wir Geld?',
|
||||
transition_hint_en: 'How do we make money?',
|
||||
},
|
||||
|
||||
// 7 — business-model (45s)
|
||||
{
|
||||
slideId: 'business-model',
|
||||
duration: 45,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Unser Geschaeftsmodell: Recurring Revenue mit Hardware-Moat.',
|
||||
text_en: 'Our business model: Recurring revenue with hardware moat.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Die Hardware-Appliance im Serverraum des Kunden schafft einen natuerlichen Lock-in. Monatliche Subscriptions von 1.200 bis 3.600 Euro garantieren planbare Einnahmen.',
|
||||
text_en: 'The hardware appliance in the customer server room creates a natural lock-in. Monthly subscriptions from EUR 1,200 to 3,600 ensure predictable revenue.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Die Unit Economics sind ueberzeugend: Hardware-Kosten amortisieren sich in wenigen Monaten, und die Bruttomarge liegt bei ueber 70 Prozent.',
|
||||
text_en: 'The unit economics are compelling: hardware costs amortize in a few months, and gross margin exceeds 70 percent.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Was haben wir bisher erreicht?',
|
||||
transition_hint_en: 'What have we achieved so far?',
|
||||
},
|
||||
|
||||
// 8 — traction (45s)
|
||||
{
|
||||
slideId: 'traction',
|
||||
duration: 45,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Unsere bisherige Traction und Meilensteine.',
|
||||
text_en: 'Our traction and milestones so far.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: '761.000 Zeilen Code geschrieben. 45 Container in Produktion. 57 Compliance-Module implementiert. 2.274 Rechtstexte indexiert und durchsuchbar.',
|
||||
text_en: '761,000 lines of code written. 45 containers in production. 57 compliance modules implemented. 2,274 legal texts indexed and searchable.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Die komplette Plattform ist funktionsfaehig — wir sind bereit fuer die ersten zahlenden Kunden.',
|
||||
text_en: 'The complete platform is functional — we are ready for our first paying customers.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Wie stehen wir im Vergleich zum Wettbewerb?',
|
||||
transition_hint_en: 'How do we compare to the competition?',
|
||||
},
|
||||
|
||||
// 9 — competition (60s)
|
||||
{
|
||||
slideId: 'competition',
|
||||
duration: 60,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Der Wettbewerb: 44 Features, 9 USPs — kein Anbieter kombiniert DSGVO, Code-Security und Self-Hosted KI.',
|
||||
text_en: 'The competition: 44 features, 9 USPs — no provider combines GDPR, code security and self-hosted AI.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Proliance, DataGuard und heyData bieten organisatorische Compliance — aber keiner scannt Code, keiner analysiert Firmware, keiner bietet Self-Hosted KI.',
|
||||
text_en: 'Proliance, DataGuard and heyData offer organizational compliance — but none scan code, none analyze firmware, none offer self-hosted AI.',
|
||||
pause_after: 2500,
|
||||
},
|
||||
{
|
||||
text_de: 'Vanta und Drata kommen aus dem US-Markt mit SOC2-Fokus. Sie verstehen weder CRA noch die spezifischen Anforderungen des deutschen Maschinenbaus.',
|
||||
text_en: 'Vanta and Drata come from the US market with SOC2 focus. They understand neither CRA nor the specific requirements of German machine manufacturing.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Unser Alleinstellungsmerkmal: Wir machen nicht nur das Unternehmen compliant, sondern auch die Produkte. Das ist der entscheidende Unterschied.',
|
||||
text_en: 'Our unique selling proposition: We make not only the company compliant, but also the products. That is the decisive difference.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Lernen Sie unser Team kennen.',
|
||||
transition_hint_en: 'Meet our team.',
|
||||
},
|
||||
|
||||
// 10 — team (30s)
|
||||
{
|
||||
slideId: 'team',
|
||||
duration: 30,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Unser Gruenderteam vereint tiefe Domain-Expertise in Compliance, Software-Architektur und KI.',
|
||||
text_en: 'Our founding team combines deep domain expertise in compliance, software architecture and AI.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Wir kennen die Schmerzen der Branche aus erster Hand und haben die technische Kompetenz, sie zu loesen.',
|
||||
text_en: 'We know the pain points of the industry firsthand and have the technical competence to solve them.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Schauen wir uns die Finanzprognosen an.',
|
||||
transition_hint_en: 'Let us look at the financial projections.',
|
||||
},
|
||||
|
||||
// 11 — financials (45s)
|
||||
{
|
||||
slideId: 'financials',
|
||||
duration: 45,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Unsere Finanzprognose basiert auf einer AI-First Kostenstruktur — das heisst: 10x Kunden bedeutet nicht 10x Personal.',
|
||||
text_en: 'Our financial projection is based on an AI-first cost structure — meaning: 10x customers does not mean 10x headcount.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Von 36.000 Euro Umsatz in 2026 auf 8,4 Millionen Euro in 2030. Das Team waechst dabei nur von 2 auf 18 Personen. 380 Kunden bei 5,5 Millionen Euro ARR.',
|
||||
text_en: 'From EUR 36,000 revenue in 2026 to EUR 8.4 million in 2030. The team grows from just 2 to 18 people. 380 customers at EUR 5.5 million ARR.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Break-Even erreichen wir voraussichtlich Ende 2028. Die Burn Rate bleibt dank Self-Hosted Architektur und Apple-Silicon Effizienz niedrig.',
|
||||
text_en: 'We expect to reach break-even by end of 2028. The burn rate stays low thanks to self-hosted architecture and Apple Silicon efficiency.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Und damit kommen wir zum Ask.',
|
||||
transition_hint_en: 'And that brings us to the ask.',
|
||||
},
|
||||
|
||||
// 12 — the-ask (45s)
|
||||
{
|
||||
slideId: 'the-ask',
|
||||
duration: 45,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Wir suchen eine Pre-Seed Finanzierung fuer den Go-to-Market.',
|
||||
text_en: 'We are seeking pre-seed funding for go-to-market.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
{
|
||||
text_de: 'Das Investment fliesst in vier Bereiche: Engineering fuer die Produktreife, Vertrieb fuer die ersten Pilotkunden, Hardware-Bestand fuer schnelle Auslieferung, und eine Reserve fuer regulatorische Anforderungen.',
|
||||
text_en: 'The investment flows into four areas: Engineering for product maturity, sales for first pilot customers, hardware inventory for fast delivery, and a reserve for regulatory requirements.',
|
||||
pause_after: 2500,
|
||||
},
|
||||
{
|
||||
text_de: 'Mit diesem Kapital erreichen wir die ersten 20 zahlenden Kunden und beweisen Product-Market-Fit im deutschen Maschinenbau.',
|
||||
text_en: 'With this capital we reach our first 20 paying customers and prove product-market fit in German machine manufacturing.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Haben Sie Fragen? Unser KI-Agent steht bereit.',
|
||||
transition_hint_en: 'Have questions? Our AI agent is ready.',
|
||||
},
|
||||
|
||||
// 13 — ai-qa (30s)
|
||||
{
|
||||
slideId: 'ai-qa',
|
||||
duration: 30,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Auf dieser Slide koennen Sie direkt mit unserem KI-Agent interagieren. Stellen Sie Ihre Investorenfragen — der Agent antwortet mit Echtdaten aus unserer Datenbank.',
|
||||
text_en: 'On this slide you can interact directly with our AI agent. Ask your investor questions — the agent responds with real data from our database.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Nutzen Sie den Chat rechts unten oder die vorgeschlagenen Fragen.',
|
||||
text_en: 'Use the chat in the bottom right or the suggested questions.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
transition_hint_de: 'Im Anhang finden Sie weitere Details.',
|
||||
transition_hint_en: 'You will find further details in the appendix.',
|
||||
},
|
||||
|
||||
// 14 — annex-assumptions (35s)
|
||||
{
|
||||
slideId: 'annex-assumptions',
|
||||
duration: 35,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Im Anhang: Unsere Annahmen und Sensitivitaetsanalyse. Drei Szenarien — konservativ, base case und optimistisch — fuer robuste Planung.',
|
||||
text_en: 'In the appendix: Our assumptions and sensitivity analysis. Three scenarios — conservative, base case and optimistic — for robust planning.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
{
|
||||
text_de: 'Alle Finanzprognosen basieren auf validierten Marktdaten und realistischen Wachstumsannahmen.',
|
||||
text_en: 'All financial projections are based on validated market data and realistic growth assumptions.',
|
||||
pause_after: 1500,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 15 — annex-architecture (35s)
|
||||
{
|
||||
slideId: 'annex-architecture',
|
||||
duration: 35,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Die technische Architektur: Self-Hosted KI-Stack fuer maximale Datensouveraenitaet. Alle Daten bleiben on-premise, nur anonymisierte Anfragen gehen an die BSI-zertifizierte Cloud.',
|
||||
text_en: 'The technical architecture: Self-hosted AI stack for maximum data sovereignty. All data stays on-premise, only anonymized queries go to the BSI-certified cloud.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 16 — annex-gtm (35s)
|
||||
{
|
||||
slideId: 'annex-gtm',
|
||||
duration: 35,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Unsere Go-to-Market Strategie: Vom Pilotprojekt zum skalierbaren Vertrieb. Wir starten mit VDMA-Mitgliedern und skalieren ueber Partnerschaften mit Systemhaeusern.',
|
||||
text_en: 'Our go-to-market strategy: From pilot project to scalable sales. We start with VDMA members and scale through partnerships with system integrators.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 17 — annex-regulatory (35s)
|
||||
{
|
||||
slideId: 'annex-regulatory',
|
||||
duration: 35,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Die vier Saeulen der EU-Compliance fuer Maschinenbauer: DSGVO, AI Act, Cyber Resilience Act und NIS2. Alle vier Regularien zusammen erzeugen einen massiven Compliance-Druck — und genau hier setzen wir an.',
|
||||
text_en: 'The four pillars of EU compliance for machine manufacturers: GDPR, AI Act, Cyber Resilience Act and NIS2. All four regulations together create massive compliance pressure — and that is exactly where we come in.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 18 — annex-engineering (35s)
|
||||
{
|
||||
slideId: 'annex-engineering',
|
||||
duration: 35,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Engineering Deep Dive: 761.000 Zeilen Code, 45 Container, 100 Prozent Self-Hosted. Unsere Plattform ist produktionsreif und skalierbar.',
|
||||
text_en: 'Engineering deep dive: 761,000 lines of code, 45 containers, 100 percent self-hosted. Our platform is production-ready and scalable.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 19 — annex-aipipeline (35s)
|
||||
{
|
||||
slideId: 'annex-aipipeline',
|
||||
duration: 35,
|
||||
paragraphs: [
|
||||
{
|
||||
text_de: 'Die KI-Pipeline im Detail: RAG, Multi-Agent-System, Document Intelligence und Quality Assurance. Vielen Dank fuer Ihre Aufmerksamkeit — ich stehe fuer weitere Fragen bereit.',
|
||||
text_en: 'The AI pipeline in detail: RAG, multi-agent system, document intelligence and quality assurance. Thank you for your attention — I am available for further questions.',
|
||||
pause_after: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getScriptForSlide(slideId: string): SlideScript | undefined {
|
||||
return PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
||||
}
|
||||
|
||||
export function getScriptByIndex(index: number): SlideScript | undefined {
|
||||
return PRESENTER_SCRIPT[index]
|
||||
}
|
||||
|
||||
export function getTotalDuration(): number {
|
||||
return PRESENTER_SCRIPT.reduce((sum, s) => sum + s.duration, 0)
|
||||
}
|
||||
20
pitch-deck/lib/presenter/tts-client.ts
Normal file
20
pitch-deck/lib/presenter/tts-client.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Language } from '../types'
|
||||
|
||||
/**
|
||||
* TTS Stub — prepared for future Piper TTS integration via compliance-tts-service (:8095)
|
||||
* POST /synthesize { text, voice: 'de_DE-thorsten-high' }
|
||||
* Returns audio URL from MinIO
|
||||
*/
|
||||
export async function synthesizeSpeech(text: string, lang: Language): Promise<string | null> {
|
||||
// TODO: Connect to compliance-tts-service
|
||||
// const voice = lang === 'de' ? 'de_DE-thorsten-high' : 'en_US-lessac-high'
|
||||
// const res = await fetch('http://compliance-tts-service:8095/synthesize', {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ text, voice }),
|
||||
// })
|
||||
// if (!res.ok) return null
|
||||
// const data = await res.json()
|
||||
// return data.audio_url
|
||||
return null
|
||||
}
|
||||
40
pitch-deck/lib/presenter/types.ts
Normal file
40
pitch-deck/lib/presenter/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Language, SlideId } from '../types'
|
||||
|
||||
export type PresenterState = 'idle' | 'presenting' | 'paused' | 'answering' | 'resuming'
|
||||
|
||||
export interface PresenterConfig {
|
||||
autoAdvance: boolean
|
||||
speechRate: number
|
||||
pauseBetweenSlides: number
|
||||
language: Language
|
||||
}
|
||||
|
||||
export interface SlideScript {
|
||||
slideId: SlideId
|
||||
duration: number
|
||||
paragraphs: {
|
||||
text_de: string
|
||||
text_en: string
|
||||
pause_after: number
|
||||
}[]
|
||||
transition_hint_de?: string
|
||||
transition_hint_en?: string
|
||||
}
|
||||
|
||||
export interface FAQEntry {
|
||||
id: string
|
||||
keywords: string[]
|
||||
question_de: string
|
||||
question_en: string
|
||||
answer_de: string
|
||||
answer_en: string
|
||||
goto_slide?: SlideId
|
||||
priority: number
|
||||
}
|
||||
|
||||
export const DEFAULT_PRESENTER_CONFIG: PresenterConfig = {
|
||||
autoAdvance: true,
|
||||
speechRate: 1.0,
|
||||
pauseBetweenSlides: 2000,
|
||||
language: 'de',
|
||||
}
|
||||
52
pitch-deck/lib/rate-limit.ts
Normal file
52
pitch-deck/lib/rate-limit.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>()
|
||||
|
||||
// Cleanup stale entries every 60 seconds
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) store.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/** Max requests in the window */
|
||||
limit: number
|
||||
/** Window size in seconds */
|
||||
windowSec: number
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
export function checkRateLimit(key: string, config: RateLimitConfig): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const entry = store.get(key)
|
||||
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
store.set(key, { count: 1, resetAt: now + config.windowSec * 1000 })
|
||||
return { allowed: true, remaining: config.limit - 1, resetAt: now + config.windowSec * 1000 }
|
||||
}
|
||||
|
||||
if (entry.count >= config.limit) {
|
||||
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return { allowed: true, remaining: config.limit - entry.count, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
// Preset configurations
|
||||
export const RATE_LIMITS = {
|
||||
magicLink: { limit: 3, windowSec: 3600 } as RateLimitConfig, // 3 per email per hour
|
||||
authVerify: { limit: 10, windowSec: 900 } as RateLimitConfig, // 10 per IP per 15min
|
||||
api: { limit: 60, windowSec: 60 } as RateLimitConfig, // 60 per session per minute
|
||||
chat: { limit: 20, windowSec: 60 } as RateLimitConfig, // 20 per session per minute
|
||||
} as const
|
||||
@@ -193,6 +193,27 @@ export interface FMComputeResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Investor Auth Types
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: 'invited' | 'active' | 'revoked'
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface InvestorSnapshot {
|
||||
id: string
|
||||
scenario_id: string
|
||||
assumptions: Record<string, number | number[]>
|
||||
label: string | null
|
||||
is_latest: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Language = 'de' | 'en'
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -201,6 +222,7 @@ export interface ChatMessage {
|
||||
}
|
||||
|
||||
export type SlideId =
|
||||
| 'intro-presenter'
|
||||
| 'cover'
|
||||
| 'problem'
|
||||
| 'solution'
|
||||
|
||||
102
pitch-deck/middleware.ts
Normal file
102
pitch-deck/middleware.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { jwtVerify } from 'jose'
|
||||
|
||||
// Paths that bypass auth entirely
|
||||
const PUBLIC_PATHS = [
|
||||
'/auth', // investor login pages
|
||||
'/api/auth', // investor auth API
|
||||
'/api/health',
|
||||
'/api/admin-auth', // admin login API
|
||||
'/pitch-admin/login', // admin login page
|
||||
'/_next',
|
||||
'/manifest.json',
|
||||
'/sw.js',
|
||||
'/icons',
|
||||
'/favicon.ico',
|
||||
]
|
||||
|
||||
// Paths gated on the admin session cookie
|
||||
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin']
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
|
||||
}
|
||||
|
||||
function isAdminGatedPath(pathname: string): boolean {
|
||||
return ADMIN_GATED_PREFIXES.some(p => pathname === p || pathname.startsWith(p + '/'))
|
||||
}
|
||||
|
||||
const ADMIN_AUDIENCE = 'pitch-admin'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
const secret = process.env.PITCH_JWT_SECRET
|
||||
|
||||
// Allow public paths
|
||||
if (isPublicPath(pathname)) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// ----- Admin-gated routes -----
|
||||
if (isAdminGatedPath(pathname)) {
|
||||
// Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves
|
||||
// also check this and log as actor='cli'). The bearer header is opaque to the JWT
|
||||
// path, so we just let it through here and let the route handler enforce.
|
||||
if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const adminToken = request.cookies.get('pitch_admin_session')?.value
|
||||
if (!adminToken || !secret) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.redirect(new URL('/pitch-admin/login', request.url))
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(adminToken, new TextEncoder().encode(secret), { audience: ADMIN_AUDIENCE })
|
||||
return NextResponse.next()
|
||||
} catch {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const response = NextResponse.redirect(new URL('/pitch-admin/login', request.url))
|
||||
response.cookies.delete('pitch_admin_session')
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Investor-gated routes (everything else) -----
|
||||
const token = request.cookies.get('pitch_session')?.value
|
||||
|
||||
if (!token || !secret) {
|
||||
return NextResponse.redirect(new URL('/auth', request.url))
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret))
|
||||
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('x-investor-id', payload.sub as string)
|
||||
response.headers.set('x-investor-email', payload.email as string)
|
||||
response.headers.set('x-session-id', payload.sessionId as string)
|
||||
|
||||
const exp = payload.exp as number
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const timeLeft = exp - now
|
||||
if (timeLeft < 900 && timeLeft > 0) {
|
||||
response.headers.set('x-token-refresh-needed', 'true')
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
const response = NextResponse.redirect(new URL('/auth', request.url))
|
||||
response.cookies.delete('pitch_session')
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image).*)'],
|
||||
}
|
||||
79
pitch-deck/migrations/001_investor_auth.sql
Normal file
79
pitch-deck/migrations/001_investor_auth.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- =========================================================
|
||||
-- Pitch Deck: Investor Auth, Audit Logs, Snapshots
|
||||
-- =========================================================
|
||||
|
||||
-- Invited investors
|
||||
CREATE TABLE IF NOT EXISTS pitch_investors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
company VARCHAR(255),
|
||||
invited_by VARCHAR(255) NOT NULL DEFAULT 'admin',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'invited'
|
||||
CHECK (status IN ('invited', 'active', 'revoked')),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
login_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_investors_email ON pitch_investors(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_investors_status ON pitch_investors(status);
|
||||
|
||||
-- Single-use magic link tokens
|
||||
CREATE TABLE IF NOT EXISTS pitch_magic_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
investor_id UUID NOT NULL REFERENCES pitch_investors(id) ON DELETE CASCADE,
|
||||
token VARCHAR(128) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_magic_links_token ON pitch_magic_links(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_magic_links_investor ON pitch_magic_links(investor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_magic_links_expires ON pitch_magic_links(expires_at);
|
||||
|
||||
-- Audit log for all investor activity
|
||||
CREATE TABLE IF NOT EXISTS pitch_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
investor_id UUID REFERENCES pitch_investors(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
details JSONB DEFAULT '{}',
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
slide_id VARCHAR(50),
|
||||
session_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_audit_created ON pitch_audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_audit_investor ON pitch_audit_logs(investor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_audit_action ON pitch_audit_logs(action);
|
||||
|
||||
-- Per-investor financial model snapshots (JSONB)
|
||||
CREATE TABLE IF NOT EXISTS pitch_investor_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
investor_id UUID NOT NULL REFERENCES pitch_investors(id) ON DELETE CASCADE,
|
||||
scenario_id UUID NOT NULL,
|
||||
assumptions JSONB NOT NULL,
|
||||
label VARCHAR(255),
|
||||
is_latest BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_snapshots_investor ON pitch_investor_snapshots(investor_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pitch_snapshots_latest
|
||||
ON pitch_investor_snapshots(investor_id, scenario_id) WHERE is_latest = true;
|
||||
|
||||
-- Active sessions
|
||||
CREATE TABLE IF NOT EXISTS pitch_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
investor_id UUID NOT NULL REFERENCES pitch_investors(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(128) NOT NULL,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_sessions_investor ON pitch_sessions(investor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_sessions_token ON pitch_sessions(token_hash);
|
||||
40
pitch-deck/migrations/002_admin_users.sql
Normal file
40
pitch-deck/migrations/002_admin_users.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- =========================================================
|
||||
-- Pitch Deck: Admin Users + Audit Log Extensions
|
||||
-- =========================================================
|
||||
|
||||
-- Admin users (real accounts with bcrypt passwords)
|
||||
CREATE TABLE IF NOT EXISTS pitch_admins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_admins_email ON pitch_admins(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_admins_active ON pitch_admins(is_active);
|
||||
|
||||
-- Admin sessions (mirrors pitch_sessions structure)
|
||||
CREATE TABLE IF NOT EXISTS pitch_admin_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
admin_id UUID NOT NULL REFERENCES pitch_admins(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(128) NOT NULL,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_admin_sessions_admin ON pitch_admin_sessions(admin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_admin_sessions_token ON pitch_admin_sessions(token_hash);
|
||||
|
||||
-- Extend audit log: track admin actor + target investor for admin actions
|
||||
ALTER TABLE pitch_audit_logs
|
||||
ADD COLUMN IF NOT EXISTS admin_id UUID REFERENCES pitch_admins(id) ON DELETE SET NULL;
|
||||
ALTER TABLE pitch_audit_logs
|
||||
ADD COLUMN IF NOT EXISTS target_investor_id UUID REFERENCES pitch_investors(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_audit_admin ON pitch_audit_logs(admin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_audit_target_investor ON pitch_audit_logs(target_investor_id);
|
||||
@@ -5,6 +5,21 @@ const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'X-Robots-Tag', value: 'noindex, nofollow, noarchive, nosnippet' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
4371
pitch-deck/package-lock.json
generated
Normal file
4371
pitch-deck/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,25 +5,36 @@
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3012",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3012"
|
||||
"start": "next start -p 3012",
|
||||
"admin:create": "tsx scripts/create-admin.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jose": "^6.2.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.1.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.13.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitest/expect": "^4.1.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2"
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pitch-deck/public/icons/icon-192.png
Normal file
BIN
pitch-deck/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
pitch-deck/public/icons/icon-512.png
Normal file
BIN
pitch-deck/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
pitch-deck/public/icons/icon-maskable-512.png
Normal file
BIN
pitch-deck/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
28
pitch-deck/public/manifest.json
Normal file
28
pitch-deck/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "BreakPilot ComplAI — Investor Pitch",
|
||||
"short_name": "BreakPilot Pitch",
|
||||
"description": "Interactive investor pitch deck for BreakPilot ComplAI",
|
||||
"start_url": "/",
|
||||
"display": "fullscreen",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a1a",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
70
pitch-deck/public/sw.js
Normal file
70
pitch-deck/public/sw.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const CACHE_NAME = 'breakpilot-pitch-v1'
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
]
|
||||
|
||||
// Install: cache the app shell
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
// Fetch: network-first for API, cache-first for static assets
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return
|
||||
|
||||
// Network-first for API routes and auth
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth')) {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache-first for static assets (JS, CSS, images, fonts)
|
||||
if (
|
||||
url.pathname.startsWith('/_next/static/') ||
|
||||
url.pathname.startsWith('/icons/') ||
|
||||
url.pathname.endsWith('.js') ||
|
||||
url.pathname.endsWith('.css')
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached
|
||||
return fetch(event.request).then((response) => {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||
return response
|
||||
})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Network-first for everything else (HTML pages)
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||
return response
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
)
|
||||
})
|
||||
68
pitch-deck/scripts/create-admin.ts
Normal file
68
pitch-deck/scripts/create-admin.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Bootstrap a new pitch admin user.
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/create-admin.ts --email=ben@breakpilot.ai --name="Benjamin" --password='...'
|
||||
*
|
||||
* Or via env vars (useful in CI):
|
||||
* PITCH_ADMIN_BOOTSTRAP_EMAIL=... PITCH_ADMIN_BOOTSTRAP_NAME=... PITCH_ADMIN_BOOTSTRAP_PASSWORD=... \
|
||||
* tsx scripts/create-admin.ts
|
||||
*
|
||||
* Idempotent: if an admin with the email already exists, the password is updated
|
||||
* (so you can use it to reset). The script always re-activates the account.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const prefix = `--${name}=`
|
||||
const m = process.argv.find(a => a.startsWith(prefix))
|
||||
return m ? m.slice(prefix.length) : undefined
|
||||
}
|
||||
|
||||
const email = (arg('email') || process.env.PITCH_ADMIN_BOOTSTRAP_EMAIL || '').trim().toLowerCase()
|
||||
const name = arg('name') || process.env.PITCH_ADMIN_BOOTSTRAP_NAME || 'Admin'
|
||||
const password = arg('password') || process.env.PITCH_ADMIN_BOOTSTRAP_PASSWORD || ''
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('ERROR: --email and --password are required (or set env vars).')
|
||||
console.error(' tsx scripts/create-admin.ts --email=user@example.com --name="Name" --password=secret')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (password.length < 12) {
|
||||
console.error('ERROR: password must be at least 12 characters.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db',
|
||||
})
|
||||
|
||||
async function main() {
|
||||
const hash = await bcrypt.hash(password, 12)
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_admins (email, name, password_hash, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
is_active = true,
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, name, created_at`,
|
||||
[email, name, hash],
|
||||
)
|
||||
const row = rows[0]
|
||||
console.log(`✓ Admin ready: ${row.email} (${row.name})`)
|
||||
console.log(` id: ${row.id}`)
|
||||
console.log(` created_at: ${row.created_at.toISOString()}`)
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('ERROR:', err.message)
|
||||
pool.end().catch(() => {})
|
||||
process.exit(1)
|
||||
})
|
||||
16
pitch-deck/vitest.config.ts
Normal file
16
pitch-deck/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['__tests__/**/*.test.ts'],
|
||||
setupFiles: ['./__tests__/setup.ts'],
|
||||
globals: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user