Compare commits
35 Commits
coolify
...
bd2835dec4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd2835dec4 | ||
|
|
f565dfdb15 | ||
|
|
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)
|
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||||
|
|
||||||
### Zwei-Rechner-Setup
|
### Zwei-Rechner-Setup + Coolify
|
||||||
|
|
||||||
| Geraet | Rolle | Aufgaben |
|
| Geraet | Rolle | Aufgaben |
|
||||||
|--------|-------|----------|
|
|--------|-------|----------|
|
||||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
| **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
|
```bash
|
||||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
# 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
|
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 && 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 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
|
```bash
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||||
@@ -51,6 +76,14 @@ networks:
|
|||||||
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
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)
|
## Haupt-URLs (via Nginx Reverse Proxy)
|
||||||
@@ -161,7 +194,7 @@ networks:
|
|||||||
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DB-Zugang
|
# DB-Zugang (lokal)
|
||||||
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -193,7 +226,14 @@ breakpilot-core/
|
|||||||
|
|
||||||
## Haeufige Befehle
|
## 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
|
```bash
|
||||||
# Alle Core-Services starten
|
# 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).
|
**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
|
### Git
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
# 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:
|
# Remotes:
|
||||||
# origin: lokale Gitea (macmini:3003)
|
# origin: lokale Gitea (macmini:3003)
|
||||||
# gitea: gitea.meghsakha.com
|
# gitea: gitea.meghsakha.com
|
||||||
# all: beide gleichzeitig
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -140,117 +140,20 @@ jobs:
|
|||||||
python -m pytest tests/bqas/ -v --tb=short || true
|
python -m pytest tests/bqas/ -v --tb=short || true
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Build & Deploy auf Hetzner (nur main, kein PR)
|
# Deploy via Coolify (nur main, kein PR)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
deploy-hetzner:
|
deploy-coolify:
|
||||||
|
name: Deploy
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs:
|
needs:
|
||||||
- test-go-consent
|
- test-go-consent
|
||||||
container: docker:27-cli
|
container:
|
||||||
|
image: alpine:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy
|
- name: Trigger Coolify deploy
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
apk add --no-cache curl
|
||||||
DEPLOY_DIR="/opt/breakpilot-core"
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||||
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.hetzner.yml"
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
COMMIT_SHA="${GITHUB_SHA:-unknown}"
|
|
||||||
SHORT_SHA="${COMMIT_SHA:0:8}"
|
|
||||||
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
|
||||||
|
|
||||||
# Services die deployed werden
|
|
||||||
SERVICES="postgres valkey qdrant minio ollama mailpit embedding-service rag-service backend-core consent-service health-aggregator"
|
|
||||||
|
|
||||||
echo "=== BreakPilot Core Deploy ==="
|
|
||||||
echo "Commit: ${SHORT_SHA}"
|
|
||||||
echo "Deploy Dir: ${DEPLOY_DIR}"
|
|
||||||
echo "Services: ${SERVICES}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 1. Repo auf dem Host erstellen/aktualisieren via Helper-Container
|
|
||||||
echo "=== Updating code on host ==="
|
|
||||||
docker run --rm \
|
|
||||||
-v "${DEPLOY_DIR}:${DEPLOY_DIR}" \
|
|
||||||
--entrypoint sh \
|
|
||||||
alpine/git:latest \
|
|
||||||
-c "
|
|
||||||
if [ ! -d '${DEPLOY_DIR}/.git' ]; then
|
|
||||||
echo 'Erstmaliges Klonen nach ${DEPLOY_DIR}...'
|
|
||||||
git clone '${REPO_URL}' '${DEPLOY_DIR}'
|
|
||||||
else
|
|
||||||
cd '${DEPLOY_DIR}'
|
|
||||||
git fetch origin main
|
|
||||||
git reset --hard origin/main
|
|
||||||
fi
|
|
||||||
"
|
|
||||||
echo "Code aktualisiert auf ${SHORT_SHA}"
|
|
||||||
|
|
||||||
# 2. .env sicherstellen
|
|
||||||
docker run --rm -v "${DEPLOY_DIR}:${DEPLOY_DIR}" alpine \
|
|
||||||
sh -c "
|
|
||||||
if [ ! -f '${DEPLOY_DIR}/.env' ]; then
|
|
||||||
echo 'WARNUNG: ${DEPLOY_DIR}/.env fehlt!'
|
|
||||||
echo 'Erstelle .env aus .env.example mit Defaults...'
|
|
||||||
if [ -f '${DEPLOY_DIR}/.env.example' ]; then
|
|
||||||
cp '${DEPLOY_DIR}/.env.example' '${DEPLOY_DIR}/.env'
|
|
||||||
echo '.env aus .env.example erstellt'
|
|
||||||
else
|
|
||||||
echo 'Kein .env.example gefunden — Services starten mit Defaults'
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo '.env vorhanden'
|
|
||||||
fi
|
|
||||||
"
|
|
||||||
|
|
||||||
# 3. Shared Network erstellen (falls noch nicht vorhanden)
|
|
||||||
docker network create breakpilot-network 2>/dev/null || true
|
|
||||||
|
|
||||||
# 4. Build + Deploy via Helper-Container
|
|
||||||
echo ""
|
|
||||||
echo "=== Building + Deploying ==="
|
|
||||||
docker run --rm \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-v "${DEPLOY_DIR}:${DEPLOY_DIR}" \
|
|
||||||
-w "${DEPLOY_DIR}" \
|
|
||||||
docker:27-cli \
|
|
||||||
sh -c "
|
|
||||||
set -e
|
|
||||||
COMPOSE_FILES='-f docker-compose.yml -f docker-compose.hetzner.yml'
|
|
||||||
|
|
||||||
echo '=== Building Docker Images ==='
|
|
||||||
docker compose \${COMPOSE_FILES} build --parallel \
|
|
||||||
backend-core consent-service rag-service embedding-service health-aggregator
|
|
||||||
|
|
||||||
echo ''
|
|
||||||
echo '=== Starting infrastructure ==='
|
|
||||||
docker compose \${COMPOSE_FILES} up -d postgres valkey qdrant minio mailpit
|
|
||||||
|
|
||||||
echo 'Warte auf DB + Cache...'
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo ''
|
|
||||||
echo '=== Starting Ollama + pulling bge-m3 ==='
|
|
||||||
docker compose \${COMPOSE_FILES} up -d ollama
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# bge-m3 Modell pullen (nur beim ersten Mal ~670MB)
|
|
||||||
echo 'Pulling bge-m3 model (falls noch nicht vorhanden)...'
|
|
||||||
docker exec bp-core-ollama ollama pull bge-m3 2>&1 || echo 'WARNUNG: bge-m3 pull fehlgeschlagen (wird spaeter nachgeholt)'
|
|
||||||
|
|
||||||
echo ''
|
|
||||||
echo '=== Starting application services ==='
|
|
||||||
docker compose \${COMPOSE_FILES} up -d \
|
|
||||||
embedding-service rag-service backend-core consent-service health-aggregator
|
|
||||||
|
|
||||||
echo ''
|
|
||||||
echo '=== Health Checks ==='
|
|
||||||
sleep 15
|
|
||||||
for svc in bp-core-postgres bp-core-valkey bp-core-qdrant bp-core-ollama bp-core-embedding-service bp-core-rag-service bp-core-backend bp-core-consent-service bp-core-health; do
|
|
||||||
STATUS=\$(docker inspect --format='{{.State.Status}}' \"\${svc}\" 2>/dev/null || echo 'not found')
|
|
||||||
echo \"\${svc}: \${STATUS}\"
|
|
||||||
done
|
|
||||||
"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Deploy abgeschlossen: ${SHORT_SHA} ==="
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
valkey_data:
|
valkey_data:
|
||||||
embedding_models:
|
embedding_models:
|
||||||
|
paddleocr_models:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -141,6 +142,68 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- breakpilot-network
|
- breakpilot-network
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# OCR SERVICE (PaddleOCR PP-OCRv5)
|
||||||
|
# =========================================================
|
||||||
|
paddleocr-service:
|
||||||
|
build:
|
||||||
|
context: ./paddleocr-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bp-core-paddleocr
|
||||||
|
expose:
|
||||||
|
- "8095"
|
||||||
|
environment:
|
||||||
|
PADDLEOCR_API_KEY: ${PADDLEOCR_API_KEY:-}
|
||||||
|
FLAGS_use_mkldnn: "0"
|
||||||
|
volumes:
|
||||||
|
- paddleocr_models:/root/.paddleocr
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8095/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 300s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
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}
|
||||||
|
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
|
# HEALTH AGGREGATOR
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -153,7 +216,7 @@ services:
|
|||||||
- "8099"
|
- "8099"
|
||||||
environment:
|
environment:
|
||||||
PORT: 8099
|
PORT: 8099
|
||||||
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087"
|
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095,pitch-deck:3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -1,194 +1,77 @@
|
|||||||
# Umgebungs-Architektur
|
# Umgebungs-Architektur
|
||||||
|
|
||||||
## Übersicht
|
## Uebersicht
|
||||||
|
|
||||||
BreakPilot verwendet eine 3-Umgebungs-Strategie für sichere Entwicklung und Deployment:
|
BreakPilot verwendet zwei Umgebungen:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
│ Development │────▶│ Staging │────▶│ Production │
|
│ Development │───── git push ────▶│ Production │
|
||||||
│ (develop) │ │ (staging) │ │ (main) │
|
│ (Mac Mini) │ │ (Coolify) │
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
└─────────────────┘ └─────────────────┘
|
||||||
Tägliche Getesteter Code Produktionsreif
|
Lokale Automatisch
|
||||||
Entwicklung
|
Entwicklung via Coolify
|
||||||
```
|
```
|
||||||
|
|
||||||
## Umgebungen
|
## Umgebungen
|
||||||
|
|
||||||
### Development (Dev)
|
### Development (Lokal — Mac Mini)
|
||||||
|
|
||||||
**Zweck:** Tägliche Entwicklungsarbeit
|
**Zweck:** Lokale Entwicklung und Tests
|
||||||
|
|
||||||
| Eigenschaft | Wert |
|
| Eigenschaft | Wert |
|
||||||
|-------------|------|
|
|-------------|------|
|
||||||
| Git Branch | `develop` |
|
| Git Branch | `main` |
|
||||||
| Compose File | `docker-compose.yml` + `docker-compose.override.yml` (auto) |
|
| Compose File | `docker-compose.yml` |
|
||||||
| Env File | `.env.dev` |
|
| Database | Lokale PostgreSQL |
|
||||||
| Database | `breakpilot_dev` |
|
|
||||||
| Debug | Aktiviert |
|
| Debug | Aktiviert |
|
||||||
| Hot-Reload | Aktiviert |
|
| Hot-Reload | Aktiviert |
|
||||||
|
|
||||||
**Start:**
|
**Start:**
|
||||||
```bash
|
```bash
|
||||||
./scripts/start.sh dev
|
ssh macmini "cd ~/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
||||||
# oder einfach:
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Staging
|
### Production (Coolify)
|
||||||
|
|
||||||
**Zweck:** Getesteter, freigegebener Code vor Produktion
|
**Zweck:** Live-System
|
||||||
|
|
||||||
| 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)
|
|
||||||
|
|
||||||
| Eigenschaft | Wert |
|
| Eigenschaft | Wert |
|
||||||
|-------------|------|
|
|-------------|------|
|
||||||
| Git Branch | `main` |
|
| Git Branch | `main` |
|
||||||
| Compose File | `docker-compose.yml` + `docker-compose.prod.yml` |
|
| Deployment | Coolify (automatisch bei Push auf gitea) |
|
||||||
| Env File | `.env.prod` (NICHT im Repository!) |
|
| Database | Externe PostgreSQL (TLS) |
|
||||||
| Database | `breakpilot_prod` (separates Volume) |
|
|
||||||
| Debug | Deaktiviert |
|
| 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
|
```bash
|
||||||
# develop → staging
|
git push origin main && git push gitea main
|
||||||
./scripts/promote.sh dev-to-staging
|
# Coolify baut und deployt automatisch
|
||||||
|
|
||||||
# staging → main (Production)
|
|
||||||
./scripts/promote.sh staging-to-prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 Architektur
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml ← Basis-Konfiguration
|
docker-compose.yml ← Basis-Konfiguration (lokal, arm64)
|
||||||
│
|
│
|
||||||
├── docker-compose.override.yml ← Dev (auto-geladen)
|
└── docker-compose.coolify.yml ← Production Override (amd64)
|
||||||
│
|
|
||||||
├── docker-compose.staging.yml ← Staging (explizit)
|
|
||||||
│
|
|
||||||
└── docker-compose.prod.yml ← Production (explizit)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Automatisches Laden
|
Coolify verwendet automatisch beide Compose-Files fuer den Production-Build.
|
||||||
|
|
||||||
Docker Compose lädt automatisch:
|
## Secrets Management
|
||||||
1. `docker-compose.yml`
|
|
||||||
2. `docker-compose.override.yml` (falls vorhanden)
|
|
||||||
|
|
||||||
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 |
|
Siehe auch: [Secrets Management](./secrets-management.md)
|
||||||
|--------|--------------|
|
|
||||||
| `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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verwandte Dokumentation
|
## Verwandte Dokumentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
# CI/CD Pipeline
|
# CI/CD Pipeline
|
||||||
|
|
||||||
Übersicht über den Deployment-Prozess für Breakpilot.
|
Uebersicht ueber den Deployment-Prozess fuer BreakPilot.
|
||||||
|
|
||||||
## Übersicht
|
## Uebersicht
|
||||||
|
|
||||||
| Komponente | Build-Tool | Deployment |
|
| Repo | Deployment | Trigger | Compose File |
|
||||||
|------------|------------|------------|
|
|------|-----------|---------|--------------|
|
||||||
| Frontend (Next.js) | Docker | Mac Mini |
|
| **breakpilot-core** | Coolify (automatisch) | Push auf `coolify` Branch | `docker-compose.coolify.yml` |
|
||||||
| Backend (FastAPI) | Docker | Mac Mini |
|
| **breakpilot-compliance** | Coolify (automatisch) | Push auf `main` Branch | `docker-compose.yml` + `docker-compose.coolify.yml` |
|
||||||
| Go Services | Docker (Multi-stage) | Mac Mini |
|
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell `docker compose` | `docker-compose.yml` |
|
||||||
| Documentation | MkDocs | Docker (Nginx) |
|
|
||||||
|
|
||||||
## Deployment-Architektur
|
## Deployment-Architektur
|
||||||
|
|
||||||
@@ -17,287 +16,146 @@
|
|||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ Entwickler-MacBook │
|
│ Entwickler-MacBook │
|
||||||
│ │
|
│ │
|
||||||
│ breakpilot-core/ │
|
│ breakpilot-core/ → git push gitea coolify │
|
||||||
│ ├── admin-core/ (Next.js Admin, Port 3008) │
|
│ breakpilot-compliance/ → git push gitea main │
|
||||||
│ ├── backend-core/ (Python FastAPI, Port 8000) │
|
│ breakpilot-lehrer/ → git push + ssh macmini docker ... │
|
||||||
│ ├── consent-service/ (Go Service, Port 8081) │
|
|
||||||
│ ├── billing-service/ (Go Service, Port 8083) │
|
|
||||||
│ └── docs-src/ (MkDocs) │
|
|
||||||
│ │
|
│ │
|
||||||
│ git push → Gitea Actions (automatisch) │
|
|
||||||
│ oder manuell: git push && ssh macmini docker compose build │
|
|
||||||
└───────────────────────────────┬─────────────────────────────────┘
|
└───────────────────────────────┬─────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ git push origin main
|
┌───────────┴───────────┐
|
||||||
│
|
│ │
|
||||||
▼
|
▼ ▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||||
│ Mac Mini Server (bp-core-*) │
|
│ Coolify (Production) │ │ Mac Mini (Lokal/Dev) │
|
||||||
│ │
|
│ │ │ │
|
||||||
│ Docker Compose │
|
│ Gitea Actions │ │ breakpilot-lehrer │
|
||||||
│ ├── admin-core (Port 3008) │
|
│ ├── Tests │ │ ├── studio-v2 │
|
||||||
│ ├── backend-core (Port 8000) │
|
│ └── Coolify API Deploy │ │ ├── klausur-service │
|
||||||
│ ├── consent-service (Port 8081) │
|
│ │ │ ├── backend-lehrer │
|
||||||
│ ├── billing-service (Port 8083) │
|
│ Core Services: │ │ └── voice-service │
|
||||||
│ ├── gitea (Port 3003) + gitea-runner (Gitea Actions) │
|
│ ├── consent-service │ │ │
|
||||||
│ ├── docs (Port 8011) │
|
│ ├── rag-service │ │ Core Services (lokal): │
|
||||||
│ ├── postgres, valkey, qdrant, minio │
|
│ ├── embedding-service │ │ ├── postgres │
|
||||||
│ └── vault, nginx, night-scheduler, health │
|
│ ├── 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
|
### Pipeline
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# .gitea/workflows/deploy-coolify.yml
|
||||||
services:
|
on:
|
||||||
backend:
|
push:
|
||||||
environment:
|
branches: [coolify]
|
||||||
- DATABASE_URL=postgresql://...
|
|
||||||
- REDIS_URL=redis://valkey:6379
|
jobs:
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
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:
|
### Workflow
|
||||||
- `.env` Datei auf dem Server pflegen
|
|
||||||
- Secrets über HashiCorp Vault (siehe unten)
|
```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
|
## 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 |
|
| Komponente | Container | Beschreibung |
|
||||||
|------------|-----------|--------------|
|
|------------|-----------|--------------|
|
||||||
| Gitea | `bp-core-gitea` (Port 3003) | Git-Server + Actions-Trigger |
|
| 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
|
### Pipeline-Konfiguration
|
||||||
|
|
||||||
Workflows liegen im Repo unter `.gitea/workflows/`:
|
Workflows liegen in jedem Repo unter `.gitea/workflows/`:
|
||||||
|
|
||||||
```yaml
|
| Repo | Workflow | Branch | Aktion |
|
||||||
# .gitea/workflows/main.yml
|
|------|----------|--------|--------|
|
||||||
on:
|
| breakpilot-core | `deploy-coolify.yml` | `coolify` | Coolify API Deploy |
|
||||||
push:
|
| breakpilot-compliance | `ci.yaml` | `main` | Tests + Coolify Deploy |
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Build & Test
|
|
||||||
run: docker compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runner-Token erneuern
|
### Runner-Token erneuern
|
||||||
|
|
||||||
@@ -314,12 +172,79 @@ ssh macmini "/usr/local/bin/docker compose \
|
|||||||
up -d --force-recreate gitea-runner"
|
up -d --force-recreate gitea-runner"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pipeline-Status prüfen
|
### Pipeline-Status pruefen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Runner-Logs
|
# Runner-Logs
|
||||||
ssh macmini "/usr/local/bin/docker logs -f bp-core-gitea-runner"
|
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-lehrer** | Bildungs-Stack (Team A) | `bp-lehrer-*` | Blau |
|
||||||
| **breakpilot-compliance** | DSGVO/Compliance-Stack (Team B) | `bp-compliance-*` | Lila |
|
| **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
|
## Core Services
|
||||||
|
|
||||||
| Service | Container | Port | Beschreibung |
|
| Service | Container | Port | Beschreibung |
|
||||||
@@ -30,32 +38,11 @@ BreakPilot besteht aus drei unabhaengigen Projekten:
|
|||||||
| Admin Core | bp-core-admin | 3008 | Admin-Dashboard (Next.js) |
|
| Admin Core | bp-core-admin | 3008 | Admin-Dashboard (Next.js) |
|
||||||
| Health Aggregator | bp-core-health | 8099 | Service-Health Monitoring |
|
| Health Aggregator | bp-core-health | 8099 | Service-Health Monitoring |
|
||||||
| Night Scheduler | bp-core-night-scheduler | 8096 | Nachtabschaltung |
|
| 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) |
|
| Mailpit | bp-core-mailpit | 8025 | E-Mail (Entwicklung) |
|
||||||
| Gitea | bp-core-gitea | 3003 | Git-Server |
|
| Gitea | bp-core-gitea | 3003 | Git-Server |
|
||||||
| Gitea Runner | bp-core-gitea-runner | - | CI/CD (Gitea Actions) |
|
| Gitea Runner | bp-core-gitea-runner | - | CI/CD (Gitea Actions) |
|
||||||
| Jitsi (5 Container) | bp-core-jitsi-* | 8443 | Videokonferenzen |
|
| 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
|
## Architektur
|
||||||
|
|
||||||
- [System-Architektur](architecture/system-architecture.md)
|
- [System-Architektur](architecture/system-architecture.md)
|
||||||
|
|||||||
16
paddleocr-service/Dockerfile
Normal file
16
paddleocr-service/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 libglib2.0-0 libgomp1 curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8095
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
||||||
|
CMD curl -f http://127.0.0.1:8095/health || exit 1
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8095"]
|
||||||
115
paddleocr-service/main.py
Normal file
115
paddleocr-service/main.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""PaddleOCR Remote Service — PP-OCRv5 Latin auf x86_64."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="PaddleOCR Service")
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_ready = False
|
||||||
|
_loading = False
|
||||||
|
API_KEY = os.environ.get("PADDLEOCR_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model():
|
||||||
|
"""Load PaddleOCR model in background thread."""
|
||||||
|
global _engine, _ready
|
||||||
|
try:
|
||||||
|
logger.info("Importing paddleocr...")
|
||||||
|
from paddleocr import PaddleOCR
|
||||||
|
|
||||||
|
logger.info("Import done. Loading PaddleOCR model...")
|
||||||
|
# Try multiple init strategies for different PaddleOCR versions
|
||||||
|
inits = [
|
||||||
|
# PaddleOCR 3.x (no show_log)
|
||||||
|
dict(lang="en", ocr_version="PP-OCRv5", use_angle_cls=True),
|
||||||
|
# PaddleOCR 3.x with show_log
|
||||||
|
dict(lang="en", ocr_version="PP-OCRv5", use_angle_cls=True, show_log=False),
|
||||||
|
# PaddleOCR 2.8+ (latin)
|
||||||
|
dict(lang="latin", use_angle_cls=True, show_log=False),
|
||||||
|
# PaddleOCR 2.8+ (en, no version)
|
||||||
|
dict(lang="en", use_angle_cls=True, show_log=False),
|
||||||
|
]
|
||||||
|
for i, kwargs in enumerate(inits):
|
||||||
|
try:
|
||||||
|
_engine = PaddleOCR(**kwargs)
|
||||||
|
logger.info(f"PaddleOCR init succeeded with strategy {i}: {kwargs}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"PaddleOCR init strategy {i} failed: {e}")
|
||||||
|
else:
|
||||||
|
raise RuntimeError("All PaddleOCR init strategies failed")
|
||||||
|
_ready = True
|
||||||
|
logger.info("PaddleOCR model loaded successfully — ready to serve")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load PaddleOCR model: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_load_model():
|
||||||
|
"""Start model loading in background so health check passes immediately."""
|
||||||
|
global _loading
|
||||||
|
_loading = True
|
||||||
|
thread = threading.Thread(target=_load_model, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
logger.info("Model loading started in background thread")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
if _ready:
|
||||||
|
return {"status": "ok", "model": "PP-OCRv5-latin"}
|
||||||
|
if _loading:
|
||||||
|
return {"status": "loading"}
|
||||||
|
return {"status": "error"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ocr")
|
||||||
|
async def ocr(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
x_api_key: str = Header(default=""),
|
||||||
|
):
|
||||||
|
if API_KEY and x_api_key != API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
|
||||||
|
if not _ready:
|
||||||
|
raise HTTPException(status_code=503, detail="Model still loading")
|
||||||
|
|
||||||
|
img_bytes = await file.read()
|
||||||
|
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
|
img_np = np.array(img)
|
||||||
|
|
||||||
|
result = _engine.ocr(img_np)
|
||||||
|
|
||||||
|
words = []
|
||||||
|
for line in result[0] or []:
|
||||||
|
box, (text, conf) = line[0], line[1]
|
||||||
|
x_min = min(p[0] for p in box)
|
||||||
|
y_min = min(p[1] for p in box)
|
||||||
|
x_max = max(p[0] for p in box)
|
||||||
|
y_max = max(p[1] for p in box)
|
||||||
|
words.append(
|
||||||
|
{
|
||||||
|
"text": text.strip(),
|
||||||
|
"left": int(x_min),
|
||||||
|
"top": int(y_min),
|
||||||
|
"width": int(x_max - x_min),
|
||||||
|
"height": int(y_max - y_min),
|
||||||
|
"conf": round(conf * 100, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"words": words,
|
||||||
|
"image_width": img_np.shape[1],
|
||||||
|
"image_height": img_np.shape[0],
|
||||||
|
}
|
||||||
7
paddleocr-service/requirements.txt
Normal file
7
paddleocr-service/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
paddlepaddle>=3.0.0
|
||||||
|
paddleocr>=2.9.0
|
||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn>=0.25.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
Pillow>=10.0.0
|
||||||
|
numpy>=1.24.0
|
||||||
42
pitch-deck/app/api/admin/audit-logs/route.ts
Normal file
42
pitch-deck/app/api/admin/audit-logs/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import pool from '@/lib/db'
|
||||||
|
import { validateAdminSecret } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const investorId = searchParams.get('investor_id')
|
||||||
|
const action = searchParams.get('action')
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0')
|
||||||
|
|
||||||
|
const conditions: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (investorId) {
|
||||||
|
conditions.push(`a.investor_id = $${paramIdx++}`)
|
||||||
|
params.push(investorId)
|
||||||
|
}
|
||||||
|
if (action) {
|
||||||
|
conditions.push(`a.action = $${paramIdx++}`)
|
||||||
|
params.push(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
FROM pitch_audit_logs a
|
||||||
|
LEFT JOIN pitch_investors i ON i.id = a.investor_id
|
||||||
|
${where}
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ logs: rows })
|
||||||
|
}
|
||||||
19
pitch-deck/app/api/admin/investors/route.ts
Normal file
19
pitch-deck/app/api/admin/investors/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import pool from '@/lib/db'
|
||||||
|
import { validateAdminSecret } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
68
pitch-deck/app/api/admin/invite/route.ts
Normal file
68
pitch-deck/app/api/admin/invite/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import pool from '@/lib/db'
|
||||||
|
import { validateAdminSecret, generateToken, logAudit, getClientIp } from '@/lib/auth'
|
||||||
|
import { sendMagicLinkEmail } from '@/lib/email'
|
||||||
|
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { email, name, company } = body
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit by email
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build magic link URL
|
||||||
|
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||||
|
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl)
|
||||||
|
|
||||||
|
await logAudit(investor.id, 'magic_link_sent', { email: normalizedEmail }, request)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
investor_id: investor.id,
|
||||||
|
email: normalizedEmail,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
26
pitch-deck/app/api/admin/revoke/route.ts
Normal file
26
pitch-deck/app/api/admin/revoke/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import pool from '@/lib/db'
|
||||||
|
import { validateAdminSecret, revokeAllSessions, logAudit } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { investor_id } = body
|
||||||
|
|
||||||
|
if (!investor_id) {
|
||||||
|
return NextResponse.json({ error: 'investor_id required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE pitch_investors SET status = 'revoked', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[investor_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
await revokeAllSessions(investor_id)
|
||||||
|
await logAudit(investor_id, 'investor_revoked', {}, request)
|
||||||
|
|
||||||
|
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: '/' })
|
||||||
|
}
|
||||||
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() })
|
||||||
|
}
|
||||||
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'
|
import './globals.css'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'BreakPilot ComplAI — Investor Pitch Deck',
|
title: 'BreakPilot ComplAI — Investor Pitch Deck',
|
||||||
description: 'Datensouveraenitaet meets KI-Compliance. Pre-Seed Q4 2026.',
|
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({
|
export default function RootLayout({
|
||||||
@@ -13,8 +27,22 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="de" className="dark">
|
<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">
|
<body className="bg-[#0a0a1a] text-white antialiased overflow-hidden h-screen">
|
||||||
{children}
|
{children}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Language } from '@/lib/types'
|
import { Language } from '@/lib/types'
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth'
|
||||||
import PitchDeck from '@/components/PitchDeck'
|
import PitchDeck from '@/components/PitchDeck'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [lang, setLang] = useState<Language>('de')
|
const [lang, setLang] = useState<Language>('de')
|
||||||
|
const { investor, loading, logout } = useAuth()
|
||||||
|
|
||||||
const toggleLanguage = useCallback(() => {
|
const toggleLanguage = useCallback(() => {
|
||||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { AnimatePresence } from 'framer-motion'
|
|||||||
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
||||||
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||||
import { usePitchData } from '@/lib/hooks/usePitchData'
|
import { usePitchData } from '@/lib/hooks/usePitchData'
|
||||||
|
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
|
||||||
import { Language, PitchData } from '@/lib/types'
|
import { Language, PitchData } from '@/lib/types'
|
||||||
|
import { Investor } from '@/lib/hooks/useAuth'
|
||||||
|
|
||||||
import ParticleBackground from './ParticleBackground'
|
import ParticleBackground from './ParticleBackground'
|
||||||
import ProgressBar from './ProgressBar'
|
import ProgressBar from './ProgressBar'
|
||||||
@@ -14,6 +16,7 @@ import NavigationFAB from './NavigationFAB'
|
|||||||
import ChatFAB from './ChatFAB'
|
import ChatFAB from './ChatFAB'
|
||||||
import SlideOverview from './SlideOverview'
|
import SlideOverview from './SlideOverview'
|
||||||
import SlideContainer from './SlideContainer'
|
import SlideContainer from './SlideContainer'
|
||||||
|
import Watermark from './Watermark'
|
||||||
|
|
||||||
import CoverSlide from './slides/CoverSlide'
|
import CoverSlide from './slides/CoverSlide'
|
||||||
import ProblemSlide from './slides/ProblemSlide'
|
import ProblemSlide from './slides/ProblemSlide'
|
||||||
@@ -38,13 +41,22 @@ import AIPipelineSlide from './slides/AIPipelineSlide'
|
|||||||
interface PitchDeckProps {
|
interface PitchDeckProps {
|
||||||
lang: Language
|
lang: Language
|
||||||
onToggleLanguage: () => void
|
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 { data, loading, error } = usePitchData()
|
||||||
const nav = useSlideNavigation()
|
const nav = useSlideNavigation()
|
||||||
const [fabOpen, setFabOpen] = useState(false)
|
const [fabOpen, setFabOpen] = useState(false)
|
||||||
|
|
||||||
|
// Audit tracking
|
||||||
|
useAuditTracker({
|
||||||
|
investorId: investor?.id || null,
|
||||||
|
currentSlide: nav.currentSlide,
|
||||||
|
enabled: !!investor,
|
||||||
|
})
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen()
|
document.documentElement.requestFullscreen()
|
||||||
@@ -117,7 +129,7 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
|||||||
case 'team':
|
case 'team':
|
||||||
return <TeamSlide lang={lang} team={data.team} />
|
return <TeamSlide lang={lang} team={data.team} />
|
||||||
case 'financials':
|
case 'financials':
|
||||||
return <FinancialsSlide lang={lang} />
|
return <FinancialsSlide lang={lang} investorId={investor?.id || null} />
|
||||||
case 'the-ask':
|
case 'the-ask':
|
||||||
return <TheAskSlide lang={lang} funding={data.funding} />
|
return <TheAskSlide lang={lang} funding={data.funding} />
|
||||||
case 'ai-qa':
|
case 'ai-qa':
|
||||||
@@ -140,10 +152,16 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 />
|
<ParticleBackground />
|
||||||
<ProgressBar current={nav.currentIndex} total={nav.totalSlides} />
|
<ProgressBar current={nav.currentIndex} total={nav.totalSlides} />
|
||||||
|
|
||||||
|
{/* Investor watermark */}
|
||||||
|
{investor && <Watermark text={investor.email} />}
|
||||||
|
|
||||||
<SlideContainer slideKey={nav.currentSlide} direction={nav.direction}>
|
<SlideContainer slideKey={nav.currentSlide} direction={nav.direction}>
|
||||||
{renderSlide()}
|
{renderSlide()}
|
||||||
</SlideContainer>
|
</SlideContainer>
|
||||||
|
|||||||
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-50 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,11 +20,12 @@ type FinTab = 'overview' | 'guv' | 'cashflow'
|
|||||||
|
|
||||||
interface FinancialsSlideProps {
|
interface FinancialsSlideProps {
|
||||||
lang: Language
|
lang: Language
|
||||||
|
investorId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
export default function FinancialsSlide({ lang, investorId }: FinancialsSlideProps) {
|
||||||
const i = t(lang)
|
const i = t(lang)
|
||||||
const fm = useFinancialModel()
|
const fm = useFinancialModel(investorId)
|
||||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
||||||
const de = lang === 'de'
|
const de = lang === 'de'
|
||||||
|
|
||||||
@@ -268,6 +269,26 @@ export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
|||||||
{de ? 'Berechne...' : 'Computing...'}
|
{de ? 'Berechne...' : 'Computing...'}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</FadeInView>
|
</FadeInView>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
pitch-deck/lib/auth.ts
Normal file
160
pitch-deck/lib/auth.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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
|
||||||
|
): 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)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId]
|
||||||
|
)
|
||||||
|
}
|
||||||
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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 [scenarios, setScenarios] = useState<FMScenario[]>([])
|
||||||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
||||||
const [compareMode, setCompareMode] = useState(false)
|
const [compareMode, setCompareMode] = useState(false)
|
||||||
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
|
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [computing, setComputing] = useState(false)
|
const [computing, setComputing] = useState(false)
|
||||||
|
const [snapshotStatus, setSnapshotStatus] = useState<'default' | 'saving' | 'saved' | 'restored'>('default')
|
||||||
const computeTimer = useRef<NodeJS.Timeout | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/financial-model')
|
const res = await fetch('/api/financial-model')
|
||||||
if (res.ok) {
|
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)
|
setScenarios(data)
|
||||||
const defaultScenario = data.find(s => s.is_default) || data[0]
|
const defaultScenario = data.find(s => s.is_default) || data[0]
|
||||||
if (defaultScenario) {
|
if (defaultScenario) {
|
||||||
@@ -32,7 +63,7 @@ export function useFinancialModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [])
|
}, [investorId])
|
||||||
|
|
||||||
// Compute when active scenario changes
|
// Compute when active scenario changes
|
||||||
useEffect(() => {
|
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[]) => {
|
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
||||||
// Optimistic update in local state
|
// Optimistic update in local state
|
||||||
setScenarios(prev => prev.map(s => {
|
setScenarios(prev => prev.map(s => {
|
||||||
@@ -80,7 +133,33 @@ export function useFinancialModel() {
|
|||||||
// Debounced recompute
|
// Debounced recompute
|
||||||
if (computeTimer.current) clearTimeout(computeTimer.current)
|
if (computeTimer.current) clearTimeout(computeTimer.current)
|
||||||
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
|
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 () => {
|
const computeAll = useCallback(async () => {
|
||||||
for (const s of scenarios) {
|
for (const s of scenarios) {
|
||||||
@@ -105,5 +184,7 @@ export function useFinancialModel() {
|
|||||||
compute,
|
compute,
|
||||||
computeAll,
|
computeAll,
|
||||||
updateAssumption,
|
updateAssumption,
|
||||||
|
resetToDefaults,
|
||||||
|
snapshotStatus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 type Language = 'de' | 'en'
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
80
pitch-deck/middleware.ts
Normal file
80
pitch-deck/middleware.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = [
|
||||||
|
'/auth',
|
||||||
|
'/api/auth',
|
||||||
|
'/api/health',
|
||||||
|
'/api/admin',
|
||||||
|
'/_next',
|
||||||
|
'/manifest.json',
|
||||||
|
'/sw.js',
|
||||||
|
'/icons',
|
||||||
|
'/favicon.ico',
|
||||||
|
]
|
||||||
|
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Allow public paths
|
||||||
|
if (isPublicPath(pathname)) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session cookie
|
||||||
|
const token = request.cookies.get('pitch_session')?.value
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL('/auth', request.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT
|
||||||
|
const secret = process.env.PITCH_JWT_SECRET
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.redirect(new URL('/auth', request.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret))
|
||||||
|
|
||||||
|
// Add investor info to headers for downstream use
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Auto-refresh JWT if within last 15 minutes of expiry
|
||||||
|
const exp = payload.exp as number
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const timeLeft = exp - now
|
||||||
|
|
||||||
|
if (timeLeft < 900 && timeLeft > 0) {
|
||||||
|
// Import dynamically to avoid Edge runtime issues with pg
|
||||||
|
// The actual refresh happens server-side in the API routes
|
||||||
|
response.headers.set('x-token-refresh-needed', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch {
|
||||||
|
// Invalid or expired JWT
|
||||||
|
const response = NextResponse.redirect(new URL('/auth', request.url))
|
||||||
|
response.cookies.delete('pitch_session')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!_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);
|
||||||
@@ -5,6 +5,21 @@ const nextConfig = {
|
|||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
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
|
module.exports = nextConfig
|
||||||
|
|||||||
2723
pitch-deck/package-lock.json
generated
Normal file
2723
pitch-deck/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "^18.3.16",
|
"@types/react": "^18.3.16",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
|
|||||||
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))
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user