feat: Add Academy, Whistleblower, Incidents SDK modules, pitch-deck, blog and CI/CD config
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled

- Academy, Whistleblower, Incidents frontend pages with API proxies and types
- Vendor compliance API proxy route
- Go backend handlers and models for all new SDK modules
- Investor pitch-deck app with interactive slides
- Blog section with DSGVO, AI Act, NIS2, glossary articles
- MkDocs documentation site
- CI/CD pipelines (Woodpecker, GitHub Actions), security scanning config
- Planning and implementation documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-13 21:12:16 +01:00
parent d7ba705562
commit 557305db5d
208 changed files with 141969 additions and 5680 deletions

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Build CI Docker Images for BreakPilot
# Run this script on the Mac Mini to build the custom CI images
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Building BreakPilot CI Images ==="
echo "Project directory: $PROJECT_DIR"
cd "$PROJECT_DIR"
# Build Python CI image with WeasyPrint
echo ""
echo "Building breakpilot/python-ci:3.12 ..."
docker build \
-t breakpilot/python-ci:3.12 \
-t breakpilot/python-ci:latest \
-f .docker/python-ci.Dockerfile \
.
echo ""
echo "=== Build complete ==="
echo ""
echo "Images built:"
docker images | grep breakpilot/python-ci
echo ""
echo "To use in Woodpecker CI, the image is already configured in .woodpecker/main.yml"

View File

@@ -0,0 +1,51 @@
# Custom Python CI Image with WeasyPrint Dependencies
# Build: docker build -t breakpilot/python-ci:3.12 -f .docker/python-ci.Dockerfile .
#
# This image includes all system libraries needed for:
# - WeasyPrint (PDF generation)
# - psycopg2 (PostgreSQL)
# - General Python testing
FROM python:3.12-slim
LABEL maintainer="BreakPilot Team"
LABEL description="Python 3.12 with WeasyPrint and test dependencies for CI"
# Install system dependencies in a single layer
RUN apt-get update && apt-get install -y --no-install-recommends \
# WeasyPrint dependencies
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
libcairo2 \
libcairo2-dev \
libgirepository1.0-dev \
gir1.2-pango-1.0 \
# PostgreSQL client (for psycopg2)
libpq-dev \
# Build tools (for some pip packages)
gcc \
g++ \
# Useful utilities
curl \
git \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Pre-install commonly used Python packages for faster CI
RUN pip install --no-cache-dir \
pytest \
pytest-cov \
pytest-asyncio \
pytest-json-report \
psycopg2-binary \
weasyprint \
httpx
# Set working directory
WORKDIR /app
# Default command
CMD ["python", "--version"]

124
admin-v2/.env.example Normal file
View File

@@ -0,0 +1,124 @@
# BreakPilot PWA - Environment Configuration
# Kopieren Sie diese Datei nach .env und passen Sie die Werte an
# ================================================
# Allgemein
# ================================================
ENVIRONMENT=development
# ENVIRONMENT=production
# ================================================
# Sicherheit
# ================================================
# WICHTIG: In Produktion sichere Schluessel verwenden!
# Generieren mit: openssl rand -hex 32
JWT_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32
JWT_REFRESH_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32
# ================================================
# Keycloak (Optional - fuer Produktion empfohlen)
# ================================================
# Wenn Keycloak konfiguriert ist, wird es fuer Authentifizierung verwendet.
# Ohne Keycloak wird lokales JWT verwendet (gut fuer Entwicklung).
#
# KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app
# KEYCLOAK_REALM=breakpilot
# KEYCLOAK_CLIENT_ID=breakpilot-backend
# KEYCLOAK_CLIENT_SECRET=your-client-secret
# KEYCLOAK_VERIFY_SSL=true
# ================================================
# E-Mail Konfiguration
# ================================================
# === ENTWICKLUNG (Mailpit - Standardwerte) ===
# Mailpit fängt alle E-Mails ab und zeigt sie unter http://localhost:8025
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_NAME=BreakPilot
SMTP_FROM_ADDR=noreply@breakpilot.app
FRONTEND_URL=http://localhost:8000
# === PRODUKTION (Beispiel für verschiedene Provider) ===
# --- Option 1: Eigener Mailserver ---
# SMTP_HOST=mail.ihredomain.de
# SMTP_PORT=587
# SMTP_USERNAME=noreply@ihredomain.de
# SMTP_PASSWORD=ihr-sicheres-passwort
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@ihredomain.de
# FRONTEND_URL=https://app.ihredomain.de
# --- Option 2: SendGrid ---
# SMTP_HOST=smtp.sendgrid.net
# SMTP_PORT=587
# SMTP_USERNAME=apikey
# SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@ihredomain.de
# --- Option 3: Mailgun ---
# SMTP_HOST=smtp.mailgun.org
# SMTP_PORT=587
# SMTP_USERNAME=postmaster@mg.ihredomain.de
# SMTP_PASSWORD=ihr-mailgun-passwort
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@mg.ihredomain.de
# --- Option 4: Amazon SES ---
# SMTP_HOST=email-smtp.eu-central-1.amazonaws.com
# SMTP_PORT=587
# SMTP_USERNAME=AKIAXXXXXXXXXXXXXXXX
# SMTP_PASSWORD=ihr-ses-secret
# SMTP_FROM_NAME=BreakPilot
# SMTP_FROM_ADDR=noreply@ihredomain.de
# ================================================
# Datenbank
# ================================================
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=breakpilot123
POSTGRES_DB=breakpilot_db
DATABASE_URL=postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable
# ================================================
# Optional: AI Integration
# ================================================
# ANTHROPIC_API_KEY=your-anthropic-api-key-here
# ================================================
# Breakpilot Drive - Lernspiel
# ================================================
# Aktiviert Datenbank-Speicherung fuer Spielsessions
GAME_USE_DATABASE=true
# LLM fuer Quiz-Fragen-Generierung (optional)
# Wenn nicht gesetzt, werden statische Fragen verwendet
GAME_LLM_MODEL=llama-3.1-8b
GAME_LLM_FALLBACK_MODEL=claude-3-haiku
# Feature Flags
GAME_REQUIRE_AUTH=false
GAME_REQUIRE_BILLING=false
GAME_ENABLE_LEADERBOARDS=true
# Task-Kosten fuer Billing (wenn aktiviert)
GAME_SESSION_TASK_COST=1.0
GAME_QUICK_SESSION_TASK_COST=0.5
# ================================================
# Woodpecker CI/CD
# ================================================
# URL zum Woodpecker Server
WOODPECKER_URL=http://woodpecker-server:8000
# API Token für Dashboard-Integration (Pipeline-Start)
# Erstellen unter: http://macmini:8090 → User Settings → Personal Access Tokens
WOODPECKER_TOKEN=
# ================================================
# Debug
# ================================================
DEBUG=false

132
admin-v2/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
# Dependabot Configuration for BreakPilot PWA
# This file configures Dependabot to automatically check for outdated dependencies
# and create pull requests to update them
version: 2
updates:
# Go dependencies (consent-service)
- package-ecosystem: "gomod"
directory: "/consent-service"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "go"
- "security"
commit-message:
prefix: "deps(go):"
groups:
go-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# Python dependencies (backend)
- package-ecosystem: "pip"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "python"
- "security"
commit-message:
prefix: "deps(python):"
groups:
python-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# Node.js dependencies (website)
- package-ecosystem: "npm"
directory: "/website"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "javascript"
- "security"
commit-message:
prefix: "deps(npm):"
groups:
npm-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "deps(actions):"
# Docker base images
- package-ecosystem: "docker"
directory: "/consent-service"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
labels:
- "dependencies"
- "docker"
- "security"
commit-message:
prefix: "deps(docker):"
- package-ecosystem: "docker"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
labels:
- "dependencies"
- "docker"
- "security"
commit-message:
prefix: "deps(docker):"
- package-ecosystem: "docker"
directory: "/website"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
labels:
- "dependencies"
- "docker"
- "security"
commit-message:
prefix: "deps(docker):"

503
admin-v2/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,503 @@
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
GO_VERSION: '1.21'
PYTHON_VERSION: '3.11'
NODE_VERSION: '20'
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot123
POSTGRES_DB: breakpilot_test
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository_owner }}/breakpilot
jobs:
# ==========================================
# Go Consent Service Tests
# ==========================================
go-tests:
name: Go Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: ${{ env.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ env.POSTGRES_DB }}
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: consent-service/go.sum
- name: Download dependencies
working-directory: ./consent-service
run: go mod download
- name: Run Go Vet
working-directory: ./consent-service
run: go vet ./...
- name: Run Unit Tests
working-directory: ./consent-service
run: go test -v -race -coverprofile=coverage.out ./...
env:
DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}?sslmode=disable
JWT_SECRET: test-jwt-secret-for-ci
JWT_REFRESH_SECRET: test-refresh-secret-for-ci
- name: Check Coverage
working-directory: ./consent-service
run: |
go tool cover -func=coverage.out
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 50" | bc -l) )); then
echo "::warning::Coverage is below 50%"
fi
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./consent-service/coverage.out
flags: go
name: go-coverage
continue-on-error: true
# ==========================================
# Python Backend Tests
# ==========================================
python-tests:
name: Python Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
cache-dependency-path: backend/requirements.txt
- name: Install dependencies
working-directory: ./backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-asyncio httpx
- name: Run Python Tests
working-directory: ./backend
run: pytest -v --cov=. --cov-report=xml --cov-report=term-missing
continue-on-error: true
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./backend/coverage.xml
flags: python
name: python-coverage
continue-on-error: true
# ==========================================
# Node.js Website Tests
# ==========================================
website-tests:
name: Website Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: website/package-lock.json
- name: Install dependencies
working-directory: ./website
run: npm ci
- name: Run TypeScript check
working-directory: ./website
run: npx tsc --noEmit
continue-on-error: true
- name: Run ESLint
working-directory: ./website
run: npm run lint
continue-on-error: true
- name: Build website
working-directory: ./website
run: npm run build
env:
NEXT_PUBLIC_BILLING_API_URL: http://localhost:8083
NEXT_PUBLIC_APP_URL: http://localhost:3000
# ==========================================
# Linting
# ==========================================
lint:
name: Linting
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: latest
working-directory: ./consent-service
args: --timeout=5m
continue-on-error: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Python linters
run: pip install flake8 black isort
- name: Run flake8
working-directory: ./backend
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
continue-on-error: true
- name: Check Black formatting
working-directory: ./backend
run: black --check --diff .
continue-on-error: true
# ==========================================
# Security Scan
# ==========================================
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
exit-code: '0'
continue-on-error: true
- name: Run Go security check
uses: securego/gosec@master
with:
args: '-no-fail -fmt sarif -out results.sarif ./consent-service/...'
continue-on-error: true
# ==========================================
# Docker Build & Push
# ==========================================
docker-build:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: [go-tests, python-tests, website-tests]
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for consent-service
id: meta-consent
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push consent-service
uses: docker/build-push-action@v5
with:
context: ./consent-service
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-consent.outputs.tags }}
labels: ${{ steps.meta-consent.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for website
id: meta-website
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push website
uses: docker/build-push-action@v5
with:
context: ./website
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-website.outputs.tags }}
labels: ${{ steps.meta-website.outputs.labels }}
build-args: |
NEXT_PUBLIC_BILLING_API_URL=${{ vars.NEXT_PUBLIC_BILLING_API_URL || 'http://localhost:8083' }}
NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==========================================
# Integration Tests
# ==========================================
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: [docker-build]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start services with Docker Compose
run: |
docker compose up -d postgres mailpit
sleep 10
- name: Run consent-service
working-directory: ./consent-service
run: |
go build -o consent-service ./cmd/server
./consent-service &
sleep 5
env:
DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable
JWT_SECRET: test-jwt-secret
JWT_REFRESH_SECRET: test-refresh-secret
SMTP_HOST: localhost
SMTP_PORT: 1025
- name: Health Check
run: |
curl -f http://localhost:8081/health || exit 1
- name: Run Integration Tests
run: |
# Test Auth endpoints
curl -s http://localhost:8081/api/v1/auth/health
# Test Document endpoints
curl -s http://localhost:8081/api/v1/documents
continue-on-error: true
- name: Stop services
if: always()
run: docker compose down
# ==========================================
# Deploy to Staging
# ==========================================
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [docker-build, integration-tests]
if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
environment:
name: staging
url: https://staging.breakpilot.app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to staging server
env:
STAGING_HOST: ${{ secrets.STAGING_HOST }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
# This is a placeholder for actual deployment
# Configure based on your staging infrastructure
echo "Deploying to staging environment..."
echo "Images to deploy:"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:develop"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:develop"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:develop"
# Example: SSH deployment (uncomment when configured)
# mkdir -p ~/.ssh
# echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
# chmod 600 ~/.ssh/id_rsa
# ssh -o StrictHostKeyChecking=no $STAGING_USER@$STAGING_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d"
- name: Notify deployment
run: |
echo "## Staging Deployment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Successfully deployed to staging environment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY
echo "- consent-service: \`develop\`" >> $GITHUB_STEP_SUMMARY
echo "- backend: \`develop\`" >> $GITHUB_STEP_SUMMARY
echo "- website: \`develop\`" >> $GITHUB_STEP_SUMMARY
# ==========================================
# Deploy to Production
# ==========================================
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [docker-build, integration-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://breakpilot.app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to production server
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: |
# This is a placeholder for actual deployment
# Configure based on your production infrastructure
echo "Deploying to production environment..."
echo "Images to deploy:"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:latest"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:latest"
# Example: SSH deployment (uncomment when configured)
# mkdir -p ~/.ssh
# echo "$PROD_SSH_KEY" > ~/.ssh/id_rsa
# chmod 600 ~/.ssh/id_rsa
# ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d"
- name: Notify deployment
run: |
echo "## Production Deployment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Successfully deployed to production environment" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY
echo "- consent-service: \`latest\`" >> $GITHUB_STEP_SUMMARY
echo "- backend: \`latest\`" >> $GITHUB_STEP_SUMMARY
echo "- website: \`latest\`" >> $GITHUB_STEP_SUMMARY
# ==========================================
# Summary
# ==========================================
summary:
name: CI Summary
runs-on: ubuntu-latest
needs: [go-tests, python-tests, website-tests, lint, security, docker-build, integration-tests]
if: always()
steps:
- name: Check job results
run: |
echo "## CI/CD Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Go Tests | ${{ needs.go-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Tests | ${{ needs.python-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Website Tests | ${{ needs.website-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Linting | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Build | ${{ needs.docker-build.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Integration Tests | ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Docker Images" >> $GITHUB_STEP_SUMMARY
echo "Images are pushed to: \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-*\`" >> $GITHUB_STEP_SUMMARY

222
admin-v2/.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,222 @@
name: Security Scanning
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
# Run security scans weekly on Sundays at midnight
- cron: '0 0 * * 0'
jobs:
# ==========================================
# Secret Scanning
# ==========================================
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: TruffleHog Secret Scan
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
- name: GitLeaks Secret Scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
# ==========================================
# Dependency Vulnerability Scanning
# ==========================================
dependency-scan:
name: Dependency Vulnerability Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner (filesystem)
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-fs-results.sarif'
continue-on-error: true
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-fs-results.sarif'
continue-on-error: true
# ==========================================
# Go Security Scan
# ==========================================
go-security:
name: Go Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
args: '-no-fail -fmt sarif -out gosec-results.sarif ./consent-service/...'
continue-on-error: true
- name: Upload Gosec results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'gosec-results.sarif'
continue-on-error: true
- name: Run govulncheck
working-directory: ./consent-service
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./... || true
# ==========================================
# Python Security Scan
# ==========================================
python-security:
name: Python Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install safety
run: pip install safety bandit
- name: Run Safety (dependency check)
working-directory: ./backend
run: safety check -r requirements.txt --full-report || true
- name: Run Bandit (code security scan)
working-directory: ./backend
run: bandit -r . -f sarif -o bandit-results.sarif --exit-zero
- name: Upload Bandit results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: './backend/bandit-results.sarif'
continue-on-error: true
# ==========================================
# Node.js Security Scan
# ==========================================
node-security:
name: Node.js Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
working-directory: ./website
run: npm ci
- name: Run npm audit
working-directory: ./website
run: npm audit --audit-level=high || true
# ==========================================
# Docker Image Scanning
# ==========================================
docker-security:
name: Docker Image Security
runs-on: ubuntu-latest
needs: [go-security, python-security, node-security]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build consent-service image
run: docker build -t breakpilot/consent-service:scan ./consent-service
- name: Run Trivy on consent-service
uses: aquasecurity/trivy-action@master
with:
image-ref: 'breakpilot/consent-service:scan'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-consent-results.sarif'
continue-on-error: true
- name: Build backend image
run: docker build -t breakpilot/backend:scan ./backend
- name: Run Trivy on backend
uses: aquasecurity/trivy-action@master
with:
image-ref: 'breakpilot/backend:scan'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-backend-results.sarif'
continue-on-error: true
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-consent-results.sarif'
continue-on-error: true
# ==========================================
# Security Summary
# ==========================================
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [secret-scan, dependency-scan, go-security, python-security, node-security, docker-security]
if: always()
steps:
- name: Create security summary
run: |
echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Scan Type | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Secret Scanning | ${{ needs.secret-scan.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dependency Scanning | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Security | ${{ needs.go-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Security | ${{ needs.python-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Node.js Security | ${{ needs.node-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Security | ${{ needs.docker-security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Notes" >> $GITHUB_STEP_SUMMARY
echo "- Results are uploaded to the GitHub Security tab" >> $GITHUB_STEP_SUMMARY
echo "- Weekly scheduled scans run on Sundays" >> $GITHUB_STEP_SUMMARY

244
admin-v2/.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,244 @@
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
go-tests:
name: Go Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot123
POSTGRES_DB: breakpilot_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
cache-dependency-path: consent-service/go.sum
- name: Install Dependencies
working-directory: ./consent-service
run: go mod download
- name: Run Tests
working-directory: ./consent-service
env:
DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_test?sslmode=disable
JWT_SECRET: test-secret-key-for-ci
JWT_REFRESH_SECRET: test-refresh-secret-for-ci
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
- name: Check Coverage Threshold
working-directory: ./consent-service
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold 70%"
exit 1
fi
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./consent-service/coverage.out
flags: go
name: go-coverage
python-tests:
name: Python Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
- name: Install Dependencies
working-directory: ./backend
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-asyncio
- name: Run Tests
working-directory: ./backend
env:
CONSENT_SERVICE_URL: http://localhost:8081
JWT_SECRET: test-secret-key-for-ci
run: |
pytest -v --cov=. --cov-report=xml --cov-report=term
- name: Check Coverage Threshold
working-directory: ./backend
run: |
COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); print(tree.getroot().attrib['line-rate'])")
COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc)
echo "Total Coverage: ${COVERAGE_PCT}%"
if (( $(echo "$COVERAGE_PCT < 60.0" | bc -l) )); then
echo "Coverage ${COVERAGE_PCT}% is below threshold 60%"
exit 1
fi
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./backend/coverage.xml
flags: python
name: python-coverage
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Start Services
run: |
docker-compose up -d
docker-compose ps
- name: Wait for Postgres
run: |
timeout 60 bash -c 'until docker-compose exec -T postgres pg_isready -U breakpilot; do sleep 2; done'
- name: Wait for Consent Service
run: |
timeout 60 bash -c 'until curl -f http://localhost:8081/health; do sleep 2; done'
- name: Wait for Backend
run: |
timeout 60 bash -c 'until curl -f http://localhost:8000/health; do sleep 2; done'
- name: Wait for Mailpit
run: |
timeout 60 bash -c 'until curl -f http://localhost:8025/api/v1/info; do sleep 2; done'
- name: Run Integration Tests
run: |
chmod +x ./scripts/integration-tests.sh
./scripts/integration-tests.sh
- name: Show Service Logs on Failure
if: failure()
run: |
echo "=== Consent Service Logs ==="
docker-compose logs consent-service
echo "=== Backend Logs ==="
docker-compose logs backend
echo "=== Postgres Logs ==="
docker-compose logs postgres
- name: Cleanup
if: always()
run: docker-compose down -v
lint-go:
name: Go Lint
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
working-directory: consent-service
args: --timeout=5m
lint-python:
name: Python Lint
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Dependencies
run: |
pip install flake8 black mypy
- name: Run Black
working-directory: ./backend
run: black --check .
- name: Run Flake8
working-directory: ./backend
run: flake8 . --max-line-length=120 --exclude=venv
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Run Trivy Security Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy Results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'
all-checks:
name: All Checks Passed
runs-on: ubuntu-latest
needs: [go-tests, python-tests, integration-tests, lint-go, lint-python, security-scan]
steps:
- name: All Tests Passed
run: echo "All tests and checks passed successfully!"

167
admin-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,167 @@
# ============================================
# BreakPilot PWA - Git Ignore
# ============================================
# Environment files (keep examples only)
.env
.env.local
*.env.local
# Keep examples and environment templates
!.env.example
!.env.dev
!.env.staging
# .env.prod should NOT be in repo (contains production secrets)
# ============================================
# Python
# ============================================
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
ENV/
.venv/
*.egg-info/
.eggs/
*.egg
.pytest_cache/
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover
# ============================================
# Node.js
# ============================================
node_modules/
.next/
out/
dist/
build/
.npm
.yarn-integrity
*.tsbuildinfo
# ============================================
# Go
# ============================================
*.exe
*.exe~
*.dll
*.dylib
*.test
*.out
vendor/
# ============================================
# Docker
# ============================================
# Don't ignore docker-compose files
# Ignore volume data if mounted locally
backups/
*.sql.gz
*.sql
# ============================================
# IDE & Editors
# ============================================
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.settings/
*.sublime-workspace
*.sublime-project
# ============================================
# OS Files
# ============================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# ============================================
# Secrets & Credentials
# ============================================
secrets/
*.pem
*.key
*.crt
*.p12
*.pfx
credentials.json
service-account.json
# ============================================
# Logs
# ============================================
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ============================================
# Build Artifacts
# ============================================
*.zip
*.tar.gz
*.rar
# ============================================
# Temporary Files
# ============================================
tmp/
temp/
*.tmp
*.temp
# ============================================
# Test Results
# ============================================
test-results/
playwright-report/
coverage/
# ============================================
# ML Models (large files)
# ============================================
*.pt
*.pth
*.onnx
*.safetensors
models/
.claude/settings.local.json
# ============================================
# IDE Plugins & AI Tools
# ============================================
.continue/
CLAUDE_CONTINUE.md
# ============================================
# Misplaced / Large Directories
# ============================================
backend/BreakpilotDrive/
backend/website/
backend/screenshots/
**/za-download-9/
# ============================================
# Debug & Temp Artifacts
# ============================================
*.command
ssh_key*.txt
anleitung.txt
fix_permissions.txt

77
admin-v2/.gitleaks.toml Normal file
View File

@@ -0,0 +1,77 @@
# Gitleaks Configuration for BreakPilot
# https://github.com/gitleaks/gitleaks
#
# Run locally: gitleaks detect --source . -v
# Pre-commit: gitleaks protect --staged -v
title = "BreakPilot Gitleaks Configuration"
# Use the default rules plus custom rules
[extend]
useDefault = true
# Custom rules for BreakPilot-specific patterns
[[rules]]
id = "anthropic-api-key"
description = "Anthropic API Key"
regex = '''sk-ant-api[0-9a-zA-Z-_]{20,}'''
tags = ["api", "anthropic"]
keywords = ["sk-ant-api"]
[[rules]]
id = "vast-api-key"
description = "vast.ai API Key"
regex = '''(?i)(vast[_-]?api[_-]?key|vast[_-]?key)\s*[=:]\s*['"]?([a-zA-Z0-9-_]{20,})['"]?'''
tags = ["api", "vast"]
keywords = ["vast"]
[[rules]]
id = "stripe-secret-key"
description = "Stripe Secret Key"
regex = '''sk_live_[0-9a-zA-Z]{24,}'''
tags = ["api", "stripe"]
keywords = ["sk_live"]
[[rules]]
id = "stripe-restricted-key"
description = "Stripe Restricted Key"
regex = '''rk_live_[0-9a-zA-Z]{24,}'''
tags = ["api", "stripe"]
keywords = ["rk_live"]
[[rules]]
id = "jwt-secret-hardcoded"
description = "Hardcoded JWT Secret"
regex = '''(?i)(jwt[_-]?secret|jwt[_-]?key)\s*[=:]\s*['"]([^'"]{32,})['"]'''
tags = ["secret", "jwt"]
keywords = ["jwt"]
# Allowlist for false positives
[allowlist]
description = "Global allowlist"
paths = [
'''\.env\.example$''',
'''\.env\.template$''',
'''docs/.*\.md$''',
'''SBOM\.md$''',
'''.*_test\.py$''',
'''.*_test\.go$''',
'''test_.*\.py$''',
'''.*\.bak$''',
'''node_modules/.*''',
'''venv/.*''',
'''\.git/.*''',
]
# Specific commit allowlist (for already-rotated secrets)
commits = []
# Regex patterns to ignore
regexes = [
'''REPLACE_WITH_REAL_.*''',
'''your-.*-key-change-in-production''',
'''breakpilot-dev-.*''',
'''DEVELOPMENT-ONLY-.*''',
'''placeholder.*''',
'''example.*key''',
]

View File

@@ -0,0 +1,152 @@
# Pre-commit Hooks für BreakPilot
# Installation: pip install pre-commit && pre-commit install
# Aktivierung: pre-commit install
repos:
# Go Hooks
- repo: local
hooks:
- id: go-test
name: Go Tests
entry: bash -c 'cd consent-service && go test -short ./...'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
- id: go-fmt
name: Go Format
entry: bash -c 'cd consent-service && gofmt -l -w .'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
- id: go-vet
name: Go Vet
entry: bash -c 'cd consent-service && go vet ./...'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
- id: golangci-lint
name: Go Lint (golangci-lint)
entry: bash -c 'cd consent-service && golangci-lint run --timeout=5m'
language: system
pass_filenames: false
files: \.go$
stages: [commit]
# Python Hooks
- repo: local
hooks:
- id: pytest
name: Python Tests
entry: bash -c 'cd backend && pytest -x'
language: system
pass_filenames: false
files: \.py$
stages: [commit]
- id: black
name: Black Format
entry: black
language: python
types: [python]
args: [--line-length=120]
stages: [commit]
- id: flake8
name: Flake8 Lint
entry: flake8
language: python
types: [python]
args: [--max-line-length=120, --exclude=venv]
stages: [commit]
# General Hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
name: Trim Trailing Whitespace
- id: end-of-file-fixer
name: Fix End of Files
- id: check-yaml
name: Check YAML
args: [--allow-multiple-documents]
- id: check-json
name: Check JSON
- id: check-added-large-files
name: Check Large Files
args: [--maxkb=500]
- id: detect-private-key
name: Detect Private Keys
- id: mixed-line-ending
name: Fix Mixed Line Endings
# Security Checks
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
name: Detect Secrets
args: ['--baseline', '.secrets.baseline']
exclude: |
(?x)^(
.*\.lock|
.*\.sum|
package-lock\.json
)$
# =============================================
# DevSecOps: Gitleaks (Secrets Detection)
# =============================================
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.1
hooks:
- id: gitleaks
name: Gitleaks (secrets detection)
entry: gitleaks protect --staged -v --config .gitleaks.toml
language: golang
pass_filenames: false
# =============================================
# DevSecOps: Semgrep (SAST)
# =============================================
- repo: https://github.com/returntocorp/semgrep
rev: v1.52.0
hooks:
- id: semgrep
name: Semgrep (SAST)
args:
- --config=auto
- --config=.semgrep.yml
- --severity=ERROR
types_or: [python, javascript, typescript, go]
stages: [commit]
# =============================================
# DevSecOps: Bandit (Python Security)
# =============================================
- repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
name: Bandit (Python security)
args: ["-r", "backend/", "-ll", "-x", "backend/tests/*"]
files: ^backend/.*\.py$
stages: [commit]
# Branch Protection
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: no-commit-to-branch
name: Protect main/develop branches
args: ['--branch', 'main', '--branch', 'develop']
# Configuration
default_stages: [commit]
fail_fast: false

147
admin-v2/.semgrep.yml Normal file
View File

@@ -0,0 +1,147 @@
# Semgrep Configuration for BreakPilot
# https://semgrep.dev/
#
# Run locally: semgrep scan --config auto
# Run with this config: semgrep scan --config .semgrep.yml
rules:
# =============================================
# Python/FastAPI Security Rules
# =============================================
- id: hardcoded-secret-in-string
patterns:
- pattern-either:
- pattern: |
$VAR = "...$SECRET..."
- pattern: |
$VAR = '...$SECRET...'
message: "Potential hardcoded secret detected. Use environment variables or Vault."
languages: [python]
severity: WARNING
metadata:
category: security
cwe: "CWE-798: Use of Hard-coded Credentials"
- id: sql-injection-fastapi
patterns:
- pattern-either:
- pattern: |
$CURSOR.execute(f"...{$USER_INPUT}...")
- pattern: |
$CURSOR.execute("..." + $USER_INPUT + "...")
- pattern: |
$CURSOR.execute("..." % $USER_INPUT)
message: "Potential SQL injection. Use parameterized queries."
languages: [python]
severity: ERROR
metadata:
category: security
cwe: "CWE-89: SQL Injection"
owasp: "A03:2021 - Injection"
- id: command-injection
patterns:
- pattern-either:
- pattern: os.system($USER_INPUT)
- pattern: subprocess.call($USER_INPUT, shell=True)
- pattern: subprocess.run($USER_INPUT, shell=True)
- pattern: subprocess.Popen($USER_INPUT, shell=True)
message: "Potential command injection. Avoid shell=True with user input."
languages: [python]
severity: ERROR
metadata:
category: security
cwe: "CWE-78: OS Command Injection"
owasp: "A03:2021 - Injection"
- id: insecure-jwt-algorithm
patterns:
- pattern: jwt.decode(..., algorithms=["none"], ...)
- pattern: jwt.decode(..., algorithms=["HS256"], verify=False, ...)
message: "Insecure JWT algorithm or verification disabled."
languages: [python]
severity: ERROR
metadata:
category: security
cwe: "CWE-347: Improper Verification of Cryptographic Signature"
- id: path-traversal
patterns:
- pattern: open(... + $USER_INPUT + ...)
- pattern: open(f"...{$USER_INPUT}...")
- pattern: Path(...) / $USER_INPUT
message: "Potential path traversal. Validate and sanitize file paths."
languages: [python]
severity: WARNING
metadata:
category: security
cwe: "CWE-22: Path Traversal"
- id: insecure-pickle
patterns:
- pattern: pickle.loads($DATA)
- pattern: pickle.load($FILE)
message: "Pickle deserialization is insecure. Use JSON or other safe formats."
languages: [python]
severity: WARNING
metadata:
category: security
cwe: "CWE-502: Deserialization of Untrusted Data"
# =============================================
# Go Security Rules
# =============================================
- id: go-sql-injection
patterns:
- pattern: |
$DB.Query(fmt.Sprintf("...", $USER_INPUT))
- pattern: |
$DB.Exec(fmt.Sprintf("...", $USER_INPUT))
message: "Potential SQL injection in Go. Use parameterized queries."
languages: [go]
severity: ERROR
metadata:
category: security
cwe: "CWE-89: SQL Injection"
- id: go-hardcoded-credentials
patterns:
- pattern: |
$VAR := "..."
- metavariable-regex:
metavariable: $VAR
regex: (password|secret|apiKey|api_key|token)
message: "Potential hardcoded credential. Use environment variables."
languages: [go]
severity: WARNING
metadata:
category: security
cwe: "CWE-798: Use of Hard-coded Credentials"
# =============================================
# JavaScript/TypeScript Security Rules
# =============================================
- id: js-xss-innerhtml
patterns:
- pattern: $EL.innerHTML = $USER_INPUT
message: "Potential XSS via innerHTML. Use textContent or sanitize input."
languages: [javascript, typescript]
severity: WARNING
metadata:
category: security
cwe: "CWE-79: Cross-site Scripting"
owasp: "A03:2021 - Injection"
- id: js-eval
patterns:
- pattern: eval($CODE)
- pattern: new Function($CODE)
message: "Avoid eval() and new Function() with dynamic input."
languages: [javascript, typescript]
severity: ERROR
metadata:
category: security
cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"

66
admin-v2/.trivy.yaml Normal file
View File

@@ -0,0 +1,66 @@
# Trivy Configuration for BreakPilot
# https://trivy.dev/
#
# Run: trivy image breakpilot-pwa-backend:latest
# Run filesystem: trivy fs .
# Run config: trivy config .
# Scan settings
scan:
# Security checks to perform
security-checks:
- vuln # Vulnerabilities
- config # Misconfigurations
- secret # Secrets in files
# Vulnerability settings
vulnerability:
# Vulnerability types to scan for
type:
- os # OS packages
- library # Application dependencies
# Ignore unfixed vulnerabilities
ignore-unfixed: false
# Severity settings
severity:
- CRITICAL
- HIGH
- MEDIUM
# - LOW # Uncomment to include low severity
# Output format
format: table
# Exit code on findings
exit-code: 1
# Timeout
timeout: 10m
# Cache directory
cache-dir: /tmp/trivy-cache
# Skip files/directories
skip-dirs:
- node_modules
- venv
- .venv
- __pycache__
- .git
- .idea
- .vscode
skip-files:
- "*.md"
- "*.txt"
- "*.log"
# Ignore specific vulnerabilities (add after review)
ignorefile: .trivyignore
# SBOM generation
sbom:
format: cyclonedx
output: sbom.json

9
admin-v2/.trivyignore Normal file
View File

@@ -0,0 +1,9 @@
# Trivy Ignore File for BreakPilot
# Add vulnerability IDs to ignore after security review
# Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx
# Example (remove after adding real ignores):
# CVE-2021-12345 # Reason: Not exploitable in our context
# Reviewed and accepted risks:
# (Add vulnerabilities here after security team review)

View File

@@ -0,0 +1,132 @@
# Woodpecker CI Auto-Fix Pipeline
# Automatische Reparatur fehlgeschlagener Tests
#
# Laeuft taeglich um 2:00 Uhr nachts
# Analysiert offene Backlog-Items und versucht automatische Fixes
when:
- event: cron
cron: "0 2 * * *" # Taeglich um 2:00 Uhr
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
steps:
# ========================================
# 1. Fetch Failed Tests from Backlog
# ========================================
fetch-backlog:
image: curlimages/curl:latest
commands:
- |
curl -s "http://backend:8000/api/tests/backlog?status=open&priority=critical" \
-o backlog-critical.json
curl -s "http://backend:8000/api/tests/backlog?status=open&priority=high" \
-o backlog-high.json
- echo "=== Kritische Tests ==="
- cat backlog-critical.json | head -50
- echo "=== Hohe Prioritaet ==="
- cat backlog-high.json | head -50
# ========================================
# 2. Analyze and Classify Errors
# ========================================
analyze-errors:
image: python:3.12-slim
commands:
- pip install --quiet jq-py
- |
python3 << 'EOF'
import json
import os
def classify_error(error_type, error_msg):
"""Klassifiziert Fehler nach Auto-Fix-Potential"""
auto_fixable = {
'nil_pointer': 'high',
'import_error': 'high',
'undefined_variable': 'medium',
'type_error': 'medium',
'assertion': 'low',
'timeout': 'low',
'logic_error': 'manual'
}
return auto_fixable.get(error_type, 'manual')
# Lade Backlog
try:
with open('backlog-critical.json') as f:
critical = json.load(f)
with open('backlog-high.json') as f:
high = json.load(f)
except:
print("Keine Backlog-Daten gefunden")
exit(0)
all_items = critical.get('items', []) + high.get('items', [])
auto_fix_candidates = []
for item in all_items:
fix_potential = classify_error(
item.get('error_type', 'unknown'),
item.get('error_message', '')
)
if fix_potential in ['high', 'medium']:
auto_fix_candidates.append({
'id': item.get('id'),
'test_name': item.get('test_name'),
'error_type': item.get('error_type'),
'fix_potential': fix_potential
})
print(f"Auto-Fix Kandidaten: {len(auto_fix_candidates)}")
with open('auto-fix-candidates.json', 'w') as f:
json.dump(auto_fix_candidates, f, indent=2)
EOF
depends_on:
- fetch-backlog
# ========================================
# 3. Generate Fix Suggestions (Placeholder)
# ========================================
generate-fixes:
image: python:3.12-slim
commands:
- |
echo "Auto-Fix Generation ist in Phase 4 geplant"
echo "Aktuell werden nur Vorschlaege generiert"
# Hier wuerde Claude API oder anderer LLM aufgerufen werden
# python3 scripts/auto-fix-agent.py auto-fix-candidates.json
echo "Fix-Vorschlaege wuerden hier generiert werden"
depends_on:
- analyze-errors
# ========================================
# 4. Report Results
# ========================================
report-results:
image: curlimages/curl:latest
commands:
- |
curl -X POST "http://backend:8000/api/tests/auto-fix/report" \
-H "Content-Type: application/json" \
-d "{
\"run_date\": \"$(date -Iseconds)\",
\"candidates_found\": $(cat auto-fix-candidates.json | wc -l),
\"fixes_attempted\": 0,
\"fixes_successful\": 0,
\"status\": \"analysis_only\"
}" || true
when:
status: [success, failure]

View File

@@ -0,0 +1,37 @@
# One-time pipeline to build the custom Python CI image
# Trigger manually, then delete this file
#
# This builds the breakpilot/python-ci:3.12 image on the CI runner
when:
- event: manual
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
steps:
build-python-ci-image:
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- |
echo "=== Building breakpilot/python-ci:3.12 ==="
docker build \
-t breakpilot/python-ci:3.12 \
-t breakpilot/python-ci:latest \
-f .docker/python-ci.Dockerfile \
.
echo ""
echo "=== Build complete ==="
docker images | grep breakpilot/python-ci
echo ""
echo "Image is now available for CI pipelines!"

View File

@@ -0,0 +1,161 @@
# Integration Tests Pipeline
# Separate Datei weil Services auf Pipeline-Ebene definiert werden muessen
#
# Diese Pipeline laeuft parallel zur main.yml und testet:
# - Database Connectivity (PostgreSQL)
# - Cache Connectivity (Valkey/Redis)
# - Service-to-Service Kommunikation
#
# Dokumentation: docs/testing/integration-test-environment.md
when:
- event: [push, pull_request]
branch: [main, develop]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
# Services auf Pipeline-Ebene (NICHT Step-Ebene!)
# Diese Services sind fuer ALLE Steps verfuegbar
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot_test
POSTGRES_DB: breakpilot_test
valkey:
image: valkey/valkey:8-alpine
steps:
wait-for-services:
image: postgres:16-alpine
commands:
- |
echo "=== Waiting for PostgreSQL ==="
for i in $(seq 1 30); do
if pg_isready -h postgres -U breakpilot; then
echo "PostgreSQL ready after $i attempts!"
break
fi
echo "Attempt $i/30: PostgreSQL not ready, waiting..."
sleep 2
done
# Final check
if ! pg_isready -h postgres -U breakpilot; then
echo "ERROR: PostgreSQL not ready after 30 attempts"
exit 1
fi
- |
echo "=== Waiting for Valkey ==="
# Install redis-cli in postgres alpine image
apk add --no-cache redis > /dev/null 2>&1 || true
for i in $(seq 1 30); do
if redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then
echo "Valkey ready after $i attempts!"
break
fi
echo "Attempt $i/30: Valkey not ready, waiting..."
sleep 2
done
# Final check
if ! redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then
echo "ERROR: Valkey not ready after 30 attempts"
exit 1
fi
- echo "=== All services ready ==="
integration-tests:
image: breakpilot/python-ci:3.12
environment:
CI: "true"
DATABASE_URL: postgresql://breakpilot:breakpilot_test@postgres:5432/breakpilot_test
VALKEY_URL: redis://valkey:6379
REDIS_URL: redis://valkey:6379
SKIP_INTEGRATION_TESTS: "false"
SKIP_DB_TESTS: "false"
SKIP_WEASYPRINT_TESTS: "false"
# Test-spezifische Umgebungsvariablen
ENVIRONMENT: "testing"
JWT_SECRET: "test-secret-key-for-integration-tests"
TEACHER_REQUIRE_AUTH: "false"
GAME_USE_DATABASE: "false"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
cd backend
# PYTHONPATH setzen damit lokale Module gefunden werden
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
echo "=== Installing dependencies ==="
pip install --quiet --no-cache-dir -r requirements.txt
echo "=== Running Integration Tests ==="
set +e
python -m pytest tests/test_integration/ -v \
--tb=short \
--json-report \
--json-report-file=../.ci-results/test-integration.json
TEST_EXIT=$?
set -e
# Ergebnisse auswerten
if [ -f ../.ci-results/test-integration.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
echo "WARNUNG: Keine JSON-Ergebnisse gefunden"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"integration-tests\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-integration.json
cat ../.ci-results/results-integration.json
echo ""
echo "=== Integration Test Summary ==="
echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED"
if [ "$TEST_EXIT" -ne "0" ]; then
echo "Integration tests failed with exit code $TEST_EXIT"
exit 1
fi
depends_on:
- wait-for-services
report-integration-results:
image: curlimages/curl:8.10.1
commands:
- |
set -uo pipefail
echo "=== Sende Integration Test-Ergebnisse an Dashboard ==="
if [ -f .ci-results/results-integration.json ]; then
echo "Sending integration test results..."
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
-H "Content-Type: application/json" \
-d "{
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
\"commit\": \"${CI_COMMIT_SHA}\",
\"branch\": \"${CI_COMMIT_BRANCH}\",
\"status\": \"${CI_PIPELINE_STATUS:-unknown}\",
\"test_results\": $(cat .ci-results/results-integration.json)
}" || echo "WARNUNG: Konnte Ergebnisse nicht an Dashboard senden"
else
echo "Keine Integration-Ergebnisse zum Senden gefunden"
fi
echo "=== Integration Test-Ergebnisse gesendet ==="
when:
status: [success, failure]
depends_on:
- integration-tests

View File

@@ -0,0 +1,669 @@
# Woodpecker CI Main Pipeline
# BreakPilot PWA - CI/CD Pipeline
#
# Plattform: ARM64 (Apple Silicon Mac Mini)
#
# Strategie:
# - Tests laufen bei JEDEM Push/PR
# - Test-Ergebnisse werden an Dashboard gesendet
# - Builds/Scans laufen nur bei Tags oder manuell
# - Deployment nur manuell (Sicherheit)
when:
- event: [push, pull_request, manual, tag]
branch: [main, develop]
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
variables:
- &golang_image golang:1.23-alpine
- &python_image python:3.12-slim
- &python_ci_image breakpilot/python-ci:3.12 # Custom image with WeasyPrint
- &nodejs_image node:20-alpine
- &docker_image docker:27-cli
steps:
# ========================================
# STAGE 1: Lint (nur bei PRs)
# ========================================
go-lint:
image: golangci/golangci-lint:v1.55-alpine
commands:
- cd consent-service && golangci-lint run --timeout 5m ./...
- cd ../billing-service && golangci-lint run --timeout 5m ./...
- cd ../school-service && golangci-lint run --timeout 5m ./...
when:
event: pull_request
python-lint:
image: *python_image
commands:
- pip install --quiet ruff black
- ruff check backend/ --output-format=github || true
- black --check backend/ || true
when:
event: pull_request
# ========================================
# STAGE 2: Unit Tests mit JSON-Ausgabe
# Ergebnisse werden im Workspace gespeichert (.ci-results/)
# ========================================
test-go-consent:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "consent-service" ]; then
echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json
echo "WARNUNG: consent-service Verzeichnis nicht gefunden"
exit 0
fi
cd consent-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-consent.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json
cat ../.ci-results/results-consent.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-billing:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "billing-service" ]; then
echo '{"service":"billing-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-billing.json
echo "WARNUNG: billing-service Verzeichnis nicht gefunden"
exit 0
fi
cd billing-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-billing.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-billing.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"billing-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-billing.json
cat ../.ci-results/results-billing.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-school:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "school-service" ]; then
echo '{"service":"school-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-school.json
echo "WARNUNG: school-service Verzeichnis nicht gefunden"
exit 0
fi
cd school-service
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-school.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-school.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"school-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-school.json
cat ../.ci-results/results-school.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-edu-search:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "edu-search-service" ]; then
echo '{"service":"edu-search-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-edu-search.json
echo "WARNUNG: edu-search-service Verzeichnis nicht gefunden"
exit 0
fi
cd edu-search-service
set +e
go test -v -json -coverprofile=coverage.out ./internal/... 2>&1 | tee ../.ci-results/test-edu-search.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-edu-search.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"edu-search-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-edu-search.json
cat ../.ci-results/results-edu-search.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-go-ai-compliance:
image: *golang_image
environment:
CGO_ENABLED: "0"
commands:
- |
set -euo pipefail
apk add --no-cache jq bash
mkdir -p .ci-results
if [ ! -d "ai-compliance-sdk" ]; then
echo '{"service":"ai-compliance-sdk","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-ai-compliance.json
echo "WARNUNG: ai-compliance-sdk Verzeichnis nicht gefunden"
exit 0
fi
cd ai-compliance-sdk
set +e
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-ai-compliance.json
TEST_EXIT=$?
set -e
# JSON-Zeilen extrahieren und mit jq zählen
JSON_FILE="../.ci-results/test-ai-compliance.json"
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
else
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
[ -z "$COVERAGE" ] && COVERAGE=0
echo "{\"service\":\"ai-compliance-sdk\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-ai-compliance.json
cat ../.ci-results/results-ai-compliance.json
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
if [ "$FAILED" -gt "0" ]; then
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
fi
test-python-backend:
image: *python_ci_image
environment:
CI: "true"
DATABASE_URL: "postgresql://test:test@localhost:5432/test_db"
SKIP_DB_TESTS: "true"
SKIP_WEASYPRINT_TESTS: "false"
SKIP_INTEGRATION_TESTS: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "backend" ]; then
echo '{"service":"backend","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-backend.json
echo "WARNUNG: backend Verzeichnis nicht gefunden"
exit 0
fi
cd backend
# Set PYTHONPATH to current directory (backend) so local packages like classroom_engine, alerts_agent are found
# IMPORTANT: Use absolute path and export before pip install to ensure modules are available
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
# Test tools are pre-installed in breakpilot/python-ci image
# Only install project-specific dependencies
pip install --quiet --no-cache-dir -r requirements.txt
# NOTE: PostgreSQL service removed - tests that require DB are skipped via SKIP_DB_TESTS=true
# For full integration tests, use: docker compose -f docker-compose.test.yml up -d
set +e
# Use python -m pytest to ensure PYTHONPATH is properly applied before pytest starts
python -m pytest tests/ -v --tb=short --cov=. --cov-report=term-missing --json-report --json-report-file=../.ci-results/test-backend.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-backend.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"backend\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-backend.json
cat ../.ci-results/results-backend.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-python-voice:
image: *python_image
environment:
CI: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service" ]; then
echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json
echo "WARNUNG: voice-service Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt
pip install --quiet --no-cache-dir pytest-json-report
set +e
python -m pytest tests/ -v --tb=short --json-report --json-report-file=../.ci-results/test-voice.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-voice.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json
cat ../.ci-results/results-voice.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-bqas-golden:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt
pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-golden.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json
cat ../.ci-results/results-bqas-golden.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
test-bqas-rag:
image: *python_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "voice-service/tests/bqas" ]; then
echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
exit 0
fi
cd voice-service
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt
pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio
set +e
python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-bqas-rag.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json
cat ../.ci-results/results-bqas-rag.json
# BQAS tests may skip if Ollama not available - don't fail pipeline
if [ "$FAILED" -gt "0" ]; then exit 1; fi
test-python-klausur:
image: *python_image
environment:
CI: "true"
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "klausur-service/backend" ]; then
echo '{"service":"klausur-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-klausur.json
echo "WARNUNG: klausur-service/backend Verzeichnis nicht gefunden"
exit 0
fi
cd klausur-service/backend
# Set PYTHONPATH to current directory so local modules like hyde, hybrid_search, etc. are found
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio pytest-json-report
pip install --quiet --no-cache-dir pytest-json-report
set +e
python -m pytest tests/ -v --tb=short --json-report --json-report-file=../../.ci-results/test-klausur.json
TEST_EXIT=$?
set -e
if [ -f ../../.ci-results/test-klausur.json ]; then
TOTAL=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
PASSED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
FAILED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
SKIPPED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
echo "{\"service\":\"klausur-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../../.ci-results/results-klausur.json
cat ../../.ci-results/results-klausur.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
test-nodejs-h5p:
image: *nodejs_image
commands:
- |
set -uo pipefail
mkdir -p .ci-results
if [ ! -d "h5p-service" ]; then
echo '{"service":"h5p-service","framework":"jest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-h5p.json
echo "WARNUNG: h5p-service Verzeichnis nicht gefunden"
exit 0
fi
cd h5p-service
npm ci --silent 2>/dev/null || npm install --silent
set +e
npm run test:ci -- --json --outputFile=../.ci-results/test-h5p.json 2>&1
TEST_EXIT=$?
set -e
if [ -f ../.ci-results/test-h5p.json ]; then
TOTAL=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numTotalTests || 0)" 2>/dev/null || echo "0")
PASSED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPassedTests || 0)" 2>/dev/null || echo "0")
FAILED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numFailedTests || 0)" 2>/dev/null || echo "0")
SKIPPED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPendingTests || 0)" 2>/dev/null || echo "0")
else
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
fi
[ -z "$TOTAL" ] && TOTAL=0
[ -z "$PASSED" ] && PASSED=0
[ -z "$FAILED" ] && FAILED=0
[ -z "$SKIPPED" ] && SKIPPED=0
echo "{\"service\":\"h5p-service\",\"framework\":\"jest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-h5p.json
cat ../.ci-results/results-h5p.json
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
# ========================================
# STAGE 2.5: Integration Tests
# ========================================
# Integration Tests laufen in separater Pipeline:
# .woodpecker/integration.yml
# (benötigt Pipeline-Level Services für PostgreSQL und Valkey)
# ========================================
# STAGE 3: Test-Ergebnisse an Dashboard senden
# ========================================
report-test-results:
image: curlimages/curl:8.10.1
commands:
- |
set -uo pipefail
echo "=== Sende Test-Ergebnisse an Dashboard ==="
echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}"
ls -la .ci-results/ || echo "Verzeichnis nicht gefunden"
PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}"
for f in .ci-results/results-*.json; do
[ -f "$f" ] || continue
echo "Sending: $f"
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
-H "Content-Type: application/json" \
-d "{
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
\"commit\": \"${CI_COMMIT_SHA}\",
\"branch\": \"${CI_COMMIT_BRANCH}\",
\"status\": \"${PIPELINE_STATUS}\",
\"test_results\": $(cat "$f")
}" || echo "WARNUNG: Konnte $f nicht senden"
done
echo "=== Test-Ergebnisse gesendet ==="
when:
status: [success, failure]
depends_on:
- test-go-consent
- test-go-billing
- test-go-school
- test-go-edu-search
- test-go-ai-compliance
- test-python-backend
- test-python-voice
- test-bqas-golden
- test-bqas-rag
- test-python-klausur
- test-nodejs-h5p
# ========================================
# STAGE 4: Build & Security (nur Tags/manuell)
# ========================================
build-consent-service:
image: *docker_image
commands:
- docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service
- docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest
- echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}"
when:
- event: tag
- event: manual
build-backend:
image: *docker_image
commands:
- docker build -t breakpilot/backend:${CI_COMMIT_SHA:0:8} ./backend
- docker tag breakpilot/backend:${CI_COMMIT_SHA:0:8} breakpilot/backend:latest
- echo "Built breakpilot/backend:${CI_COMMIT_SHA:0:8}"
when:
- event: tag
- event: manual
build-voice-service:
image: *docker_image
commands:
- |
if [ -d ./voice-service ]; then
docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service
docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest
echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}"
else
echo "voice-service Verzeichnis nicht gefunden - ueberspringe"
fi
when:
- event: tag
- event: manual
generate-sbom:
image: *golang_image
commands:
- |
echo "Installing syft for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
syft dir:./consent-service -o cyclonedx-json > sbom-consent.json
syft dir:./backend -o cyclonedx-json > sbom-backend.json
if [ -d ./voice-service ]; then
syft dir:./voice-service -o cyclonedx-json > sbom-voice.json
fi
echo "SBOMs generated successfully"
when:
- event: tag
- event: manual
vulnerability-scan:
image: *golang_image
commands:
- |
echo "Installing grype for ARM64..."
wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype sbom:sbom-consent.json -o table --fail-on critical || true
grype sbom:sbom-backend.json -o table --fail-on critical || true
if [ -f sbom-voice.json ]; then
grype sbom:sbom-voice.json -o table --fail-on critical || true
fi
when:
- event: tag
- event: manual
depends_on:
- generate-sbom
# ========================================
# STAGE 5: Deploy (nur manuell)
# ========================================
deploy-production:
image: *docker_image
commands:
- echo "Deploying to production..."
- docker compose -f docker-compose.yml pull || true
- docker compose -f docker-compose.yml up -d --remove-orphans || true
when:
event: manual
depends_on:
- build-consent-service
- build-backend

View File

@@ -0,0 +1,314 @@
# Woodpecker CI Security Pipeline
# Dedizierte Security-Scans fuer DevSecOps
#
# Laeuft taeglich via Cron und bei jedem PR
when:
- event: cron
cron: "0 3 * * *" # Taeglich um 3:00 Uhr
- event: pull_request
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
extra_hosts:
- macmini:192.168.178.100
steps:
# ========================================
# Static Analysis
# ========================================
semgrep-scan:
image: returntocorp/semgrep:latest
commands:
- semgrep scan --config auto --json -o semgrep-results.json . || true
- |
if [ -f semgrep-results.json ]; then
echo "=== Semgrep Findings ==="
cat semgrep-results.json | head -100
fi
when:
event: [pull_request, cron]
bandit-python:
image: python:3.12-slim
commands:
- pip install --quiet bandit
- bandit -r backend/ -f json -o bandit-results.json || true
- |
if [ -f bandit-results.json ]; then
echo "=== Bandit Findings ==="
cat bandit-results.json | head -50
fi
when:
event: [pull_request, cron]
gosec-go:
image: securego/gosec:latest
commands:
- gosec -fmt json -out gosec-consent.json ./consent-service/... || true
- gosec -fmt json -out gosec-billing.json ./billing-service/... || true
- echo "Go Security Scan abgeschlossen"
when:
event: [pull_request, cron]
# ========================================
# Secrets Detection
# ========================================
gitleaks-scan:
image: zricethezav/gitleaks:latest
commands:
- gitleaks detect --source . --report-format json --report-path gitleaks-report.json || true
- |
if [ -s gitleaks-report.json ]; then
echo "=== WARNUNG: Potentielle Secrets gefunden ==="
cat gitleaks-report.json
else
echo "Keine Secrets gefunden"
fi
trufflehog-scan:
image: trufflesecurity/trufflehog:latest
commands:
- trufflehog filesystem . --json > trufflehog-results.json 2>&1 || true
- echo "TruffleHog Scan abgeschlossen"
# ========================================
# Dependency Vulnerabilities
# ========================================
npm-audit:
image: node:20-alpine
commands:
- cd website && npm audit --json > ../npm-audit-website.json || true
- cd ../studio-v2 && npm audit --json > ../npm-audit-studio.json || true
- cd ../admin-v2 && npm audit --json > ../npm-audit-admin.json || true
- echo "NPM Audit abgeschlossen"
when:
event: [pull_request, cron]
pip-audit:
image: python:3.12-slim
commands:
- pip install --quiet pip-audit
- pip-audit -r backend/requirements.txt --format json -o pip-audit-backend.json || true
- pip-audit -r voice-service/requirements.txt --format json -o pip-audit-voice.json || true
- echo "Pip Audit abgeschlossen"
when:
event: [pull_request, cron]
go-vulncheck:
image: golang:1.21-alpine
commands:
- go install golang.org/x/vuln/cmd/govulncheck@latest
- cd consent-service && govulncheck ./... || true
- cd ../billing-service && govulncheck ./... || true
- echo "Go Vulncheck abgeschlossen"
when:
event: [pull_request, cron]
# ========================================
# Container Security
# ========================================
trivy-filesystem:
image: aquasec/trivy:latest
commands:
- trivy fs --severity HIGH,CRITICAL --format json -o trivy-fs.json . || true
- echo "Trivy Filesystem Scan abgeschlossen"
when:
event: cron
# ========================================
# SBOM Generation (taeglich)
# ========================================
daily-sbom:
image: anchore/syft:latest
commands:
- mkdir -p sbom-reports
- syft dir:. -o cyclonedx-json > sbom-reports/sbom-full-$(date +%Y%m%d).json
- echo "SBOM generiert"
when:
event: cron
# ========================================
# AUTO-FIX: Dependency Vulnerabilities
# Laeuft nur bei Cron (nightly), nicht bei PRs
# ========================================
auto-fix-npm:
image: node:20-alpine
commands:
- apk add --no-cache git
- |
echo "=== Auto-Fix: NPM Dependencies ==="
FIXES_APPLIED=0
for dir in website studio-v2 admin-v2 h5p-service; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
echo "Pruefe $dir..."
cd $dir
# Speichere Hash vor Fix
BEFORE=$(md5sum package-lock.json 2>/dev/null || echo "none")
# npm audit fix (ohne --force fuer sichere Updates)
npm audit fix --package-lock-only 2>/dev/null || true
# Pruefe ob Aenderungen
AFTER=$(md5sum package-lock.json 2>/dev/null || echo "none")
if [ "$BEFORE" != "$AFTER" ]; then
echo " -> Fixes angewendet in $dir"
FIXES_APPLIED=$((FIXES_APPLIED + 1))
fi
cd ..
fi
done
echo "NPM Auto-Fix abgeschlossen: $FIXES_APPLIED Projekte aktualisiert"
echo "NPM_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env
when:
event: cron
auto-fix-python:
image: python:3.12-slim
commands:
- apt-get update && apt-get install -y git
- pip install --quiet pip-audit
- |
echo "=== Auto-Fix: Python Dependencies ==="
FIXES_APPLIED=0
for reqfile in backend/requirements.txt voice-service/requirements.txt klausur-service/backend/requirements.txt; do
if [ -f "$reqfile" ]; then
echo "Pruefe $reqfile..."
DIR=$(dirname $reqfile)
# pip-audit mit --fix (aktualisiert requirements.txt)
pip-audit -r $reqfile --fix 2>/dev/null || true
# Pruefe ob requirements.txt geaendert wurde
if git diff --quiet $reqfile 2>/dev/null; then
echo " -> Keine Aenderungen in $reqfile"
else
echo " -> Fixes angewendet in $reqfile"
FIXES_APPLIED=$((FIXES_APPLIED + 1))
fi
fi
done
echo "Python Auto-Fix abgeschlossen: $FIXES_APPLIED Dateien aktualisiert"
echo "PYTHON_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env
when:
event: cron
auto-fix-go:
image: golang:1.21-alpine
commands:
- apk add --no-cache git
- |
echo "=== Auto-Fix: Go Dependencies ==="
FIXES_APPLIED=0
for dir in consent-service billing-service school-service edu-search ai-compliance-sdk; do
if [ -d "$dir" ] && [ -f "$dir/go.mod" ]; then
echo "Pruefe $dir..."
cd $dir
# Go mod tidy und update
go get -u ./... 2>/dev/null || true
go mod tidy 2>/dev/null || true
# Pruefe ob go.mod/go.sum geaendert wurden
if git diff --quiet go.mod go.sum 2>/dev/null; then
echo " -> Keine Aenderungen in $dir"
else
echo " -> Updates angewendet in $dir"
FIXES_APPLIED=$((FIXES_APPLIED + 1))
fi
cd ..
fi
done
echo "Go Auto-Fix abgeschlossen: $FIXES_APPLIED Module aktualisiert"
echo "GO_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env
when:
event: cron
# ========================================
# Commit & Push Auto-Fixes
# ========================================
commit-security-fixes:
image: alpine/git:latest
commands:
- |
echo "=== Commit Security Fixes ==="
# Git konfigurieren
git config --global user.email "security-bot@breakpilot.de"
git config --global user.name "Security Bot"
git config --global --add safe.directory /woodpecker/src
# Pruefe ob es Aenderungen gibt
if git diff --quiet && git diff --cached --quiet; then
echo "Keine Security-Fixes zum Committen"
exit 0
fi
# Zeige was geaendert wurde
echo "Geaenderte Dateien:"
git status --short
# Stage alle relevanten Dateien
git add -A \
*/package-lock.json \
*/requirements.txt \
*/go.mod \
*/go.sum \
2>/dev/null || true
# Commit erstellen
TIMESTAMP=$(date +%Y-%m-%d)
git commit -m "fix(security): auto-fix vulnerable dependencies [$TIMESTAMP]
Automatische Sicherheitsupdates durch CI/CD Pipeline:
- npm audit fix fuer Node.js Projekte
- pip-audit --fix fuer Python Projekte
- go get -u fuer Go Module
Co-Authored-By: Security Bot <security-bot@breakpilot.de>" || echo "Nichts zu committen"
# Push zum Repository
git push origin HEAD:main || echo "Push fehlgeschlagen - manueller Review erforderlich"
echo "Security-Fixes committed und gepusht"
when:
event: cron
status: success
# ========================================
# Report to Dashboard
# ========================================
update-security-dashboard:
image: curlimages/curl:latest
commands:
- |
curl -X POST "http://backend:8000/api/security/scan-results" \
-H "Content-Type: application/json" \
-d "{
\"scan_type\": \"daily\",
\"timestamp\": \"$(date -Iseconds)\",
\"tools\": [\"semgrep\", \"bandit\", \"gosec\", \"gitleaks\", \"trivy\"]
}" || true
when:
status: [success, failure]
event: cron

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
# BreakPilot Consent Management System - Projektplan
## Executive Summary
Dieses Dokument beschreibt den Plan zur Entwicklung eines vollständigen Consent Management Systems (CMS) für BreakPilot. Das System wird komplett neu entwickelt und ersetzt das bestehende Policy Vault System, das Bugs enthält und nicht optimal funktioniert.
---
## Technologie-Entscheidung: Warum welche Sprache?
### Backend-Optionen im Vergleich
| Kriterium | Rust | Go | Python (FastAPI) | TypeScript (NestJS) |
|-----------|------|-----|------------------|---------------------|
| **Performance** | Exzellent | Sehr gut | Gut | Gut |
| **Memory Safety** | Garantiert | GC | GC | GC |
| **Entwicklungsgeschwindigkeit** | Langsam | Mittel | Schnell | Schnell |
| **Lernkurve** | Steil | Flach | Flach | Mittel |
| **Ecosystem für Web** | Wachsend | Sehr gut | Exzellent | Exzellent |
| **Integration mit BreakPilot** | Neu | Neu | Bereits vorhanden | Möglich |
| **Team-Erfahrung** | ? | ? | Vorhanden | Möglich |
### Empfehlung: **Python (FastAPI)** oder **Go**
#### Option A: Python mit FastAPI (Empfohlen für schnelle Integration)
**Vorteile:**
- Bereits im BreakPilot-Projekt verwendet
- Schnelle Entwicklung
- Exzellente Dokumentation (automatisch generiert)
- Einfache Integration mit bestehendem Code
- Type Hints für bessere Code-Qualität
- Async/Await Support
**Nachteile:**
- Langsamer als Rust/Go bei hoher Last
- GIL-Einschränkungen bei CPU-intensiven Tasks
#### Option B: Go (Empfohlen für Microservice-Architektur)
**Vorteile:**
- Extrem schnell und effizient
- Exzellent für Microservices
- Einfache Deployment (Single Binary)
- Gute Concurrency
- Statische Typisierung
**Nachteile:**
- Neuer Tech-Stack im Projekt
- Getrennte Codebasis von BreakPilot
#### Option C: Rust (Für maximale Performance & Sicherheit)
**Vorteile:**
- Höchste Performance
- Memory Safety ohne GC
- Exzellente Sicherheit
- WebAssembly-Support
**Nachteile:**
- Sehr steile Lernkurve
- Längere Entwicklungszeit (2-3x)
- Kleineres Web-Ecosystem
- Komplexere Fehlerbehandlung
### Finale Empfehlung
**Für BreakPilot empfehle ich: Go (Golang)**
Begründung:
1. **Unabhängiger Microservice** - Das CMS sollte als eigenständiger Service laufen
2. **Performance** - Consent-Checks müssen schnell sein (bei jedem API-Call)
3. **Einfaches Deployment** - Single Binary, ideal für Container
4. **Gute Balance** - Schneller als Python, einfacher als Rust
5. **Zukunftssicher** - Moderne Sprache mit wachsendem Ecosystem
---
## Systemarchitektur
```
┌─────────────────────────────────────────────────────────────────────────┐
│ BreakPilot Ecosystem │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BreakPilot │ │ Consent Admin │ │ BreakPilot │ │
│ │ Studio (Web) │ │ Dashboard │ │ Mobile Apps │ │
│ │ (Python/HTML) │ │ (Vue.js/React) │ │ (iOS/Android) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────────────────┼──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ API Gateway / Proxy │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BreakPilot API │ │ Consent Service │ │ Auth Service │ │
│ │ (Python/FastAPI)│ │ (Go) │ │ (Go) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ (Shared Database) │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Projektphasen
### Phase 1: Grundlagen & Datenbank (Woche 1-2)
**Ziel:** Datenbank-Schema und Basis-Services
#### 1.1 Datenbank-Design
- [ ] Users-Tabelle (Integration mit BreakPilot Auth)
- [ ] Legal Documents (AGB, Datenschutz, Community Guidelines, etc.)
- [ ] Document Versions (Versionierung mit Freigabe-Workflow)
- [ ] User Consents (Welcher User hat wann was zugestimmt)
- [ ] Cookie Categories (Notwendig, Funktional, Marketing, Analytics)
- [ ] Cookie Consents (Granulare Cookie-Zustimmungen)
- [ ] Audit Log (DSGVO-konforme Protokollierung)
#### 1.2 Go Backend Setup
- [ ] Projekt-Struktur mit Clean Architecture
- [ ] Database Layer (sqlx oder GORM)
- [ ] Migration System
- [ ] Config Management
- [ ] Logging & Error Handling
### Phase 2: Core Consent Service (Woche 3-4)
**Ziel:** Kern-Funktionalität für Consent-Management
#### 2.1 Document Management API
- [ ] CRUD für Legal Documents
- [ ] Versionierung mit Diff-Tracking
- [ ] Draft/Published/Archived Status
- [ ] Mehrsprachigkeit (DE, EN, etc.)
#### 2.2 Consent Tracking API
- [ ] User Consent erstellen/abrufen
- [ ] Consent History pro User
- [ ] Bulk-Consent für mehrere Dokumente
- [ ] Consent Withdrawal (Widerruf)
#### 2.3 Cookie Consent API
- [ ] Cookie-Kategorien verwalten
- [ ] Granulare Cookie-Einstellungen
- [ ] Consent-Banner Konfiguration
### Phase 3: Admin Dashboard (Woche 5-6)
**Ziel:** Web-Interface für Administratoren
#### 3.1 Admin Frontend (Vue.js oder React)
- [ ] Login/Auth (Integration mit BreakPilot)
- [ ] Dashboard mit Statistiken
- [ ] Document Editor (Rich Text)
- [ ] Version Management UI
- [ ] User Consent Übersicht
- [ ] Cookie Management UI
#### 3.2 Freigabe-Workflow
- [ ] Draft → Review → Approved → Published
- [ ] Benachrichtigungen bei neuen Versionen
- [ ] Rollback-Funktion
### Phase 4: BreakPilot Integration (Woche 7-8)
**Ziel:** Integration in BreakPilot Studio
#### 4.1 User-facing Features
- [ ] "Legal" Button in Einstellungen
- [ ] Consent-Historie anzeigen
- [ ] Cookie-Präferenzen ändern
- [ ] Datenauskunft anfordern (DSGVO Art. 15)
#### 4.2 Cookie Banner
- [ ] Cookie-Consent-Modal beim ersten Besuch
- [ ] Granulare Auswahl der Kategorien
- [ ] "Alle akzeptieren" / "Nur notwendige"
- [ ] Persistente Speicherung
#### 4.3 Consent-Check Middleware
- [ ] Automatische Prüfung bei API-Calls
- [ ] Blocking bei fehlender Zustimmung
- [ ] Marketing-Opt-out respektieren
### Phase 5: Data Subject Rights (Woche 9-10)
**Ziel:** DSGVO-Compliance Features
#### 5.1 Datenauskunft (Art. 15 DSGVO)
- [ ] API für "Welche Daten haben wir?"
- [ ] Export als JSON/PDF
- [ ] Automatisierte Bereitstellung
#### 5.2 Datenlöschung (Art. 17 DSGVO)
- [ ] "Recht auf Vergessenwerden"
- [ ] Anonymisierung statt Löschung (wo nötig)
- [ ] Audit Trail für Löschungen
#### 5.3 Datenportabilität (Art. 20 DSGVO)
- [ ] Export in maschinenlesbarem Format
- [ ] Download-Funktion im Frontend
### Phase 6: Testing & Security (Woche 11-12)
**Ziel:** Absicherung und Qualität
#### 6.1 Testing
- [ ] Unit Tests (>80% Coverage)
- [ ] Integration Tests
- [ ] E2E Tests für kritische Flows
- [ ] Performance Tests
#### 6.2 Security
- [ ] Security Audit
- [ ] Penetration Testing
- [ ] Rate Limiting
- [ ] Input Validation
- [ ] SQL Injection Prevention
- [ ] XSS Protection
---
## Datenbank-Schema (Entwurf)
```sql
-- Benutzer (Integration mit BreakPilot)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) UNIQUE, -- BreakPilot User ID
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Rechtliche Dokumente
CREATE TABLE legal_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL, -- 'terms', 'privacy', 'cookies', 'community'
name VARCHAR(255) NOT NULL,
description TEXT,
is_mandatory BOOLEAN DEFAULT true,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Dokumentversionen
CREATE TABLE document_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE,
version VARCHAR(20) NOT NULL, -- Semver: 1.0.0, 1.1.0, etc.
language VARCHAR(5) DEFAULT 'de', -- ISO 639-1
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL, -- HTML oder Markdown
summary TEXT, -- Kurze Zusammenfassung der Änderungen
status VARCHAR(20) DEFAULT 'draft', -- draft, review, approved, published, archived
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
approved_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(document_id, version, language)
);
-- Benutzer-Zustimmungen
CREATE TABLE user_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
document_version_id UUID REFERENCES document_versions(id),
consented BOOLEAN NOT NULL,
ip_address INET,
user_agent TEXT,
consented_at TIMESTAMPTZ DEFAULT NOW(),
withdrawn_at TIMESTAMPTZ,
UNIQUE(user_id, document_version_id)
);
-- Cookie-Kategorien
CREATE TABLE cookie_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL, -- 'necessary', 'functional', 'analytics', 'marketing'
display_name_de VARCHAR(255) NOT NULL,
display_name_en VARCHAR(255),
description_de TEXT,
description_en TEXT,
is_mandatory BOOLEAN DEFAULT false,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Cookie-Zustimmungen
CREATE TABLE cookie_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
category_id UUID REFERENCES cookie_categories(id),
consented BOOLEAN NOT NULL,
consented_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, category_id)
);
-- Audit Log (DSGVO-konform)
CREATE TABLE consent_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
action VARCHAR(50) NOT NULL, -- 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete'
entity_type VARCHAR(50), -- 'document', 'cookie_category'
entity_id UUID,
details JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indizes für Performance
CREATE INDEX idx_user_consents_user ON user_consents(user_id);
CREATE INDEX idx_user_consents_version ON user_consents(document_version_id);
CREATE INDEX idx_cookie_consents_user ON cookie_consents(user_id);
CREATE INDEX idx_audit_log_user ON consent_audit_log(user_id);
CREATE INDEX idx_audit_log_created ON consent_audit_log(created_at);
```
---
## API-Endpoints (Entwurf)
### Public API (für BreakPilot Frontend)
```
# Dokumente abrufen
GET /api/v1/documents # Alle aktiven Dokumente
GET /api/v1/documents/:type # Dokument nach Typ (terms, privacy)
GET /api/v1/documents/:type/latest # Neueste publizierte Version
# Consent Management
POST /api/v1/consent # Zustimmung erteilen
GET /api/v1/consent/my # Meine Zustimmungen
GET /api/v1/consent/check/:documentType # Prüfen ob zugestimmt
DELETE /api/v1/consent/:id # Zustimmung widerrufen
# Cookie Consent
GET /api/v1/cookies/categories # Cookie-Kategorien
POST /api/v1/cookies/consent # Cookie-Präferenzen setzen
GET /api/v1/cookies/consent/my # Meine Cookie-Einstellungen
# Data Subject Rights (DSGVO)
GET /api/v1/privacy/my-data # Alle meine Daten abrufen
POST /api/v1/privacy/export # Datenexport anfordern
POST /api/v1/privacy/delete # Löschung anfordern
```
### Admin API (für Admin Dashboard)
```
# Document Management
GET /api/v1/admin/documents # Alle Dokumente (mit Drafts)
POST /api/v1/admin/documents # Neues Dokument
PUT /api/v1/admin/documents/:id # Dokument bearbeiten
DELETE /api/v1/admin/documents/:id # Dokument löschen
# Version Management
GET /api/v1/admin/versions/:docId # Alle Versionen eines Dokuments
POST /api/v1/admin/versions # Neue Version erstellen
PUT /api/v1/admin/versions/:id # Version bearbeiten
POST /api/v1/admin/versions/:id/publish # Version veröffentlichen
POST /api/v1/admin/versions/:id/archive # Version archivieren
# Cookie Categories
GET /api/v1/admin/cookies/categories # Alle Kategorien
POST /api/v1/admin/cookies/categories # Neue Kategorie
PUT /api/v1/admin/cookies/categories/:id
DELETE /api/v1/admin/cookies/categories/:id
# Statistics & Reports
GET /api/v1/admin/stats/consents # Consent-Statistiken
GET /api/v1/admin/stats/cookies # Cookie-Statistiken
GET /api/v1/admin/audit-log # Audit Log (mit Filter)
```
---
## Consent-Check Middleware (Konzept)
```go
// middleware/consent_check.go
func ConsentCheckMiddleware(requiredConsent string) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString("user_id")
// Prüfe ob User zugestimmt hat
hasConsent, err := consentService.CheckConsent(userID, requiredConsent)
if err != nil {
c.AbortWithStatusJSON(500, gin.H{"error": "Consent check failed"})
return
}
if !hasConsent {
c.AbortWithStatusJSON(403, gin.H{
"error": "consent_required",
"document_type": requiredConsent,
"message": "Sie müssen den Nutzungsbedingungen zustimmen",
})
return
}
c.Next()
}
}
// Verwendung in BreakPilot
router.POST("/api/worksheets",
authMiddleware,
ConsentCheckMiddleware("terms"),
worksheetHandler.Create,
)
```
---
## Cookie-Banner Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Erster Besuch │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. User öffnet BreakPilot │
│ │ │
│ ▼ │
│ 2. Check: Hat User Cookie-Consent gegeben? │
│ │ │
│ ┌─────────┴─────────┐ │
│ │ Nein │ Ja │
│ ▼ ▼ │
│ 3. Zeige Cookie Lade gespeicherte │
│ Banner Präferenzen │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Cookie Consent Banner │ │
│ ├─────────────────────────────────────────┤ │
│ │ Wir verwenden Cookies, um Ihnen die │ │
│ │ beste Erfahrung zu bieten. │ │
│ │ │ │
│ │ ☑ Notwendig (immer aktiv) │ │
│ │ ☐ Funktional │ │
│ │ ☐ Analytics │ │
│ │ ☐ Marketing │ │
│ │ │ │
│ │ [Alle akzeptieren] [Auswahl speichern] │ │
│ │ [Nur notwendige] [Mehr erfahren] │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Legal-Bereich im BreakPilot Frontend (Mockup)
```
┌─────────────────────────────────────────────────────────────┐
│ Einstellungen > Legal │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Meine Zustimmungen │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ✓ Allgemeine Geschäftsbedingungen │ │
│ │ Version 2.1 · Zugestimmt am 15.11.2024 │ │
│ │ [Ansehen] [Widerrufen] │ │
│ │ │ │
│ │ ✓ Datenschutzerklärung │ │
│ │ Version 3.0 · Zugestimmt am 15.11.2024 │ │
│ │ [Ansehen] [Widerrufen] │ │
│ │ │ │
│ │ ✓ Community Guidelines │ │
│ │ Version 1.2 · Zugestimmt am 15.11.2024 │ │
│ │ [Ansehen] [Widerrufen] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Cookie-Einstellungen │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ☑ Notwendige Cookies (erforderlich) │ │
│ │ ☑ Funktionale Cookies │ │
│ │ ☐ Analytics Cookies │ │
│ │ ☐ Marketing Cookies │ │
│ │ │ │
│ │ [Einstellungen speichern] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Meine Daten (DSGVO) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ [Meine Daten exportieren] │ │
│ │ Erhalten Sie eine Kopie aller Ihrer gespeicherten │ │
│ │ Daten als JSON-Datei. │ │
│ │ │ │
│ │ [Account löschen] │ │
│ │ Alle Ihre Daten werden unwiderruflich gelöscht. │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Nächste Schritte
### Sofort (diese Woche)
1. **Entscheidung:** Go oder Python für Backend?
2. **Projekt-Setup:** Repository anlegen
3. **Datenbank:** Schema finalisieren und migrieren
### Kurzfristig (nächste 2 Wochen)
1. Core API implementieren
2. Basis-Integration in BreakPilot
### Mittelfristig (nächste 4-6 Wochen)
1. Admin Dashboard
2. Cookie Banner
3. DSGVO-Features
---
## Offene Fragen
1. **Sprache:** Go oder Python für das Backend?
2. **Admin Dashboard:** Eigenes Frontend oder in BreakPilot integriert?
3. **Hosting:** Gleicher Server wie BreakPilot oder separater Service?
4. **Auth:** Shared Authentication mit BreakPilot oder eigenes System?
5. **Datenbank:** Shared PostgreSQL oder eigene Instanz?
---
## Ressourcen-Schätzung
| Phase | Aufwand (Tage) | Beschreibung |
|-------|---------------|--------------|
| Phase 1 | 5-7 | Datenbank & Setup |
| Phase 2 | 8-10 | Core Consent Service |
| Phase 3 | 10-12 | Admin Dashboard |
| Phase 4 | 8-10 | BreakPilot Integration |
| Phase 5 | 5-7 | DSGVO Features |
| Phase 6 | 5-7 | Testing & Security |
| **Gesamt** | **41-53** | ~8-10 Wochen |
---
*Dokument erstellt am: 12. Dezember 2024*
*Version: 1.0*

View File

@@ -0,0 +1,473 @@
# BreakPilot Content Service - Setup & Deployment Guide
## 🎯 Übersicht
Der BreakPilot Content Service ist eine vollständige Educational Content Management Plattform mit:
-**Content Service API** (FastAPI) - Educational Content Management
-**MinIO S3 Storage** - File Storage für Videos, PDFs, Bilder
-**H5P Service** - Interactive Educational Content (Quizzes, etc.)
-**Matrix Feed Integration** - Content Publishing zu Matrix Spaces
-**PostgreSQL** - Content Metadata Storage
-**Creative Commons Licensing** - CC-BY, CC-BY-SA, etc.
-**Rating & Download Tracking** - Analytics & Impact Scoring
## 🚀 Quick Start
### 1. Alle Services starten
```bash
# Haupt-Services + Content Services starten
docker-compose \
-f docker-compose.yml \
-f docker-compose.content.yml \
up -d
# Logs verfolgen
docker-compose -f docker-compose.yml -f docker-compose.content.yml logs -f
```
### 2. Verfügbare Services
| Service | URL | Beschreibung |
|---------|-----|--------------|
| Content Service API | http://localhost:8002 | REST API für Content Management |
| MinIO Console | http://localhost:9001 | Storage Dashboard (User: minioadmin, Pass: minioadmin123) |
| H5P Service | http://localhost:8003 | Interactive Content Editor |
| Content DB | localhost:5433 | PostgreSQL Database |
### 3. API Dokumentation
Content Service API Docs:
```
http://localhost:8002/docs
```
## 📦 Installation (Development)
### Content Service (Backend)
```bash
cd backend/content_service
# Virtual Environment erstellen
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Dependencies installieren
pip install -r requirements.txt
# Environment Variables
cp .env.example .env
# Database Migrations
alembic upgrade head
# Service starten
uvicorn main:app --reload --port 8002
```
### H5P Service
```bash
cd h5p-service
# Dependencies installieren
npm install
# Service starten
npm start
```
### Creator Dashboard (Frontend)
```bash
cd frontend/creator-studio
# Dependencies installieren
npm install
# Development Server
npm run dev
```
## 🔧 Konfiguration
### Environment Variables
Erstelle `.env` im Projekt-Root:
```env
# Content Service
CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@localhost:5433/breakpilot_content
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin123
MINIO_BUCKET=breakpilot-content
# Matrix Integration
MATRIX_HOMESERVER=http://localhost:8008
MATRIX_ACCESS_TOKEN=your-matrix-token-here
MATRIX_BOT_USER=@breakpilot-bot:localhost
MATRIX_FEED_ROOM=!breakpilot-feed:localhost
# OAuth2 (consent-service)
CONSENT_SERVICE_URL=http://localhost:8081
JWT_SECRET=your-jwt-secret-here
# H5P Service
H5P_BASE_URL=http://localhost:8003
H5P_STORAGE_PATH=/app/h5p-content
```
## 📝 Content Service API Endpoints
### Content Management
```bash
# Create Content
POST /api/v1/content
{
"title": "5-Minuten Yoga für Grundschule",
"description": "Bewegungspause mit einfachen Yoga-Übungen",
"content_type": "video",
"category": "movement",
"license": "CC-BY-SA-4.0",
"age_min": 6,
"age_max": 10,
"tags": ["yoga", "bewegung", "pause"]
}
# Upload File
POST /api/v1/upload
Content-Type: multipart/form-data
file: <video-file>
# Add Files to Content
POST /api/v1/content/{content_id}/files
{
"file_urls": ["http://minio:9000/breakpilot-content/..."]
}
# Publish Content (→ Matrix Feed)
POST /api/v1/content/{content_id}/publish
# List Content (with filters)
GET /api/v1/content?category=movement&age_min=6&age_max=10
# Get Content Details
GET /api/v1/content/{content_id}
# Rate Content
POST /api/v1/content/{content_id}/rate
{
"stars": 5,
"comment": "Sehr hilfreich für meine Klasse!"
}
```
### H5P Interactive Content
```bash
# Get H5P Editor
GET http://localhost:8003/h5p/editor/new
# Save H5P Content
POST http://localhost:8003/h5p/editor
{
"library": "H5P.InteractiveVideo 1.22",
"params": { ... }
}
# Play H5P Content
GET http://localhost:8003/h5p/play/{contentId}
# Export as .h5p File
GET http://localhost:8003/h5p/export/{contentId}
```
## 🎨 Creator Workflow
### 1. Content erstellen
```javascript
// Creator Dashboard
const content = await createContent({
title: "Mathe-Quiz: Einmaleins",
description: "Interaktives Quiz zum Üben des Einmaleins",
content_type: "h5p",
category: "math",
license: "CC-BY-SA-4.0",
age_min: 7,
age_max: 9
});
```
### 2. Files hochladen
```javascript
// Upload Video/PDF/Images
const file = document.querySelector('#fileInput').files[0];
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/v1/upload', {
method: 'POST',
body: formData
});
const { file_url } = await response.json();
```
### 3. Publish to Matrix Feed
```javascript
// Publish → Matrix Spaces
await publishContent(content.id);
// → Content erscheint in #movement, #math, etc.
```
## 📊 Matrix Feed Integration
### Matrix Spaces Struktur
```
#breakpilot (Root Space)
├── #feed (Chronologischer Content Feed)
├── #bewegung (Kategorie: Movement)
├── #mathe (Kategorie: Math)
├── #steam (Kategorie: STEAM)
└── #sprache (Kategorie: Language)
```
### Content Message Format
Wenn Content published wird, erscheint in Matrix:
```
📹 5-Minuten Yoga für Grundschule
Bewegungspause mit einfachen Yoga-Übungen für den Unterricht
📝 Von: Max Mustermann
🏃 Kategorie: movement
👥 Alter: 6-10 Jahre
⚖️ Lizenz: CC-BY-SA-4.0
🏷️ Tags: yoga, bewegung, pause
[📥 Inhalt ansehen/herunterladen]
```
## 🔐 Creative Commons Lizenzen
Verfügbare Lizenzen:
- `CC-BY-4.0` - Attribution (Namensnennung)
- `CC-BY-SA-4.0` - Attribution + ShareAlike (empfohlen)
- `CC-BY-NC-4.0` - Attribution + NonCommercial
- `CC-BY-NC-SA-4.0` - Attribution + NonCommercial + ShareAlike
- `CC0-1.0` - Public Domain
### Lizenz-Workflow
```python
# Bei Content-Erstellung: Creator wählt Lizenz
content.license = "CC-BY-SA-4.0"
# System validiert:
Nur erlaubte Lizenzen
Lizenz-Badge wird angezeigt
Lizenz-Link zu Creative Commons
```
## 📈 Analytics & Impact Scoring
### Download Tracking
```python
# Automatisch getrackt bei Download
POST /api/v1/content/{content_id}/download
# → Zähler erhöht
# → Download-Event gespeichert
# → Für Impact-Score verwendet
```
### Creator Statistics
```bash
# Get Creator Stats
GET /api/v1/stats/creator/{creator_id}
{
"total_contents": 12,
"total_downloads": 347,
"total_views": 1203,
"avg_rating": 4.7,
"impact_score": 892.5,
"content_breakdown": {
"movement": 5,
"math": 4,
"steam": 3
}
}
```
## 🧪 Testing
### API Tests
```bash
# Pytest
cd backend/content_service
pytest tests/
# Mit Coverage
pytest --cov=. --cov-report=html
```
### Integration Tests
```bash
# Test Content Upload Flow
curl -X POST http://localhost:8002/api/v1/content \
-H "Content-Type: application/json" \
-d '{
"title": "Test Content",
"content_type": "pdf",
"category": "math",
"license": "CC-BY-SA-4.0"
}'
```
## 🐳 Docker Commands
```bash
# Build einzelnen Service
docker-compose -f docker-compose.content.yml build content-service
# Nur Content Services starten
docker-compose -f docker-compose.content.yml up -d
# Logs einzelner Service
docker-compose logs -f content-service
# Service neu starten
docker-compose restart content-service
# Alle stoppen
docker-compose -f docker-compose.yml -f docker-compose.content.yml down
# Mit Volumes löschen (Achtung: Datenverlust!)
docker-compose -f docker-compose.yml -f docker-compose.content.yml down -v
```
## 🗄️ Database Migrations
```bash
cd backend/content_service
# Neue Migration erstellen
alembic revision --autogenerate -m "Add new field"
# Migration anwenden
alembic upgrade head
# Zurückrollen
alembic downgrade -1
```
## 📱 Frontend Development
### Creator Studio
```bash
cd frontend/creator-studio
# Install dependencies
npm install
# Development
npm run dev # → http://localhost:3000
# Build
npm run build
# Preview Production Build
npm run preview
```
## 🔒 DSGVO Compliance
### Datenminimierung
- ✅ Nur notwendige Metadaten gespeichert
- ✅ Keine Schülerdaten
- ✅ IP-Adressen anonymisiert nach 7 Tagen
- ✅ User kann Content/Account löschen
### Datenexport
```bash
# User Data Export
GET /api/v1/user/export
→ JSON mit allen Daten des Users
```
## 🚨 Troubleshooting
### MinIO Connection Failed
```bash
# Check MinIO status
docker-compose logs minio
# Test connection
curl http://localhost:9000/minio/health/live
```
### Content Service Database Connection
```bash
# Check PostgreSQL
docker-compose logs content-db
# Connect manually
docker exec -it breakpilot-pwa-content-db psql -U breakpilot -d breakpilot_content
```
### H5P Service Not Starting
```bash
# Check logs
docker-compose logs h5p-service
# Rebuild
docker-compose build h5p-service
docker-compose up -d h5p-service
```
## 📚 Weitere Dokumentation
- [Architekturempfehlung](./backend/docs/Architekturempfehlung%20für%20Breakpilot%20%20Offene,%20modulare%20Bildungsplattform%20im%20DACH-Raum.pdf)
- [Content Service API](./backend/content_service/README.md)
- [H5P Integration](./h5p-service/README.md)
- [Matrix Feed Setup](./docs/matrix-feed-setup.md)
## 🎉 Next Steps
1. ✅ Services starten (siehe Quick Start)
2. ✅ Creator Account erstellen
3. ✅ Ersten Content hochladen
4. ✅ H5P Interactive Content erstellen
5. ✅ Content publishen → Matrix Feed
6. ✅ Teacher Discovery UI testen
7. 🔜 OAuth2 SSO mit consent-service integrieren
8. 🔜 Production Deployment vorbereiten
## 💡 Support
Bei Fragen oder Problemen:
- GitHub Issues: https://github.com/breakpilot/breakpilot-pwa/issues
- Matrix Chat: #breakpilot-dev:matrix.org
- Email: dev@breakpilot.app

View File

@@ -0,0 +1,427 @@
# 🎓 BreakPilot Content Service - Implementierungs-Zusammenfassung
## ✅ Vollständig implementierte Sprints
### **Sprint 1-2: Content Service Foundation** ✅
**Backend (FastAPI):**
- ✅ Complete Database Schema (PostgreSQL)
- `Content` Model mit allen Metadaten
- `Rating` Model für Teacher Reviews
- `Tag` System für Content Organization
- `Download` Tracking für Impact Scoring
- ✅ Pydantic Schemas für API Validation
- ✅ Full CRUD API für Content Management
- ✅ Upload API für Files (Video, PDF, Images, Audio)
- ✅ Search & Filter Endpoints
- ✅ Analytics & Statistics Endpoints
**Storage:**
- ✅ MinIO S3-kompatible Object Storage
- ✅ Automatic Bucket Creation
- ✅ Public Read Policy für Content
- ✅ File Upload Integration
- ✅ Presigned URLs für private Files
**Files Created:**
```
backend/content_service/
├── models.py # Database Models
├── schemas.py # Pydantic Schemas
├── database.py # DB Configuration
├── main.py # FastAPI Application
├── storage.py # MinIO Integration
├── requirements.txt # Python Dependencies
└── Dockerfile # Container Definition
```
---
### **Sprint 3-4: Matrix Feed Integration** ✅
**Matrix Client:**
- ✅ Matrix SDK Integration (matrix-nio)
- ✅ Content Publishing to Matrix Spaces
- ✅ Formatted Messages (Plain Text + HTML)
- ✅ Category-based Room Routing
- ✅ Rich Metadata for Content
- ✅ Reactions & Threading Support
**Matrix Spaces Struktur:**
```
#breakpilot:server.de (Root Space)
├── #feed (Chronologischer Content Feed)
├── #bewegung (Movement Category)
├── #mathe (Math Category)
├── #steam (STEAM Category)
└── #sprache (Language Category)
```
**Files Created:**
```
backend/content_service/
└── matrix_client.py # Matrix Integration
```
**Features:**
- ✅ Auto-publish on Content.status = PUBLISHED
- ✅ Rich HTML Formatting mit Thumbnails
- ✅ CC License Badges in Messages
- ✅ Direct Links zu Content
- ✅ Category-specific Posting
---
### **Sprint 5-6: Rating & Download Tracking** ✅
**Rating System:**
- ✅ 5-Star Rating System
- ✅ Text Comments
- ✅ Average Rating Calculation
- ✅ Rating Count Tracking
- ✅ One Rating per User (Update möglich)
**Download Tracking:**
- ✅ Event-based Download Logging
- ✅ User-specific Tracking
- ✅ IP Anonymization (nach 7 Tagen)
- ✅ Download Counter
- ✅ Impact Score Foundation
**Analytics:**
- ✅ Platform-wide Statistics
- ✅ Creator Statistics
- ✅ Content Breakdown by Category
- ✅ Downloads, Views, Ratings
---
### **Sprint 7-8: H5P Interactive Content** ✅
**H5P Service (Node.js):**
- ✅ Self-hosted H5P Server
- ✅ H5P Editor Integration
- ✅ H5P Player
- ✅ File-based Content Storage
- ✅ Library Management
- ✅ Export as .h5p Files
- ✅ Import .h5p Files
**Supported H5P Content Types:**
- ✅ Interactive Video
- ✅ Course Presentation
- ✅ Quiz (Multiple Choice)
- ✅ Drag & Drop
- ✅ Timeline
- ✅ Memory Game
- ✅ Fill in the Blanks
- ✅ 50+ weitere Content Types
**Files Created:**
```
h5p-service/
├── server.js # H5P Express Server
├── package.json # Node Dependencies
└── Dockerfile # Container Definition
```
**Integration:**
- ✅ Content Service → H5P Service API
- ✅ H5P Content ID in Content Model
- ✅ Automatic Publishing to Matrix
---
### **Sprint 7-8: Creative Commons Licensing** ✅
**Lizenz-System:**
- ✅ CC-BY-4.0
- ✅ CC-BY-SA-4.0 (Recommended)
- ✅ CC-BY-NC-4.0
- ✅ CC-BY-NC-SA-4.0
- ✅ CC0-1.0 (Public Domain)
**Features:**
- ✅ License Validation bei Upload
- ✅ License Selector in Creator Studio
- ✅ License Badges in UI
- ✅ Direct Links zu Creative Commons
- ✅ Matrix Messages mit License Info
---
### **Sprint 7-8: DSGVO Compliance** ✅
**Privacy by Design:**
- ✅ Datenminimierung (nur notwendige Daten)
- ✅ EU Server Hosting
- ✅ IP Anonymization
- ✅ User Data Export API
- ✅ Account Deletion
- ✅ No Schülerdaten
**Transparency:**
- ✅ Clear License Information
- ✅ Open Source Code
- ✅ Transparent Analytics
---
## 🐳 Docker Infrastructure
**docker-compose.content.yml:**
```yaml
Services:
- minio (Object Storage)
- content-db (PostgreSQL)
- content-service (FastAPI)
- h5p-service (Node.js H5P)
Volumes:
- minio_data
- content_db_data
- h5p_content
Networks:
- breakpilot-pwa-network (external)
```
---
## 📊 Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────┐
│ BREAKPILOT CONTENT PLATFORM │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Creator │───▶│ Content │───▶│ Matrix │ │
│ │ Studio │ │ Service │ │ Feed │ │
│ │ (Vue.js) │ │ (FastAPI) │ │ (Synapse) │ │
│ └──────────────┘ └──────┬───────┘ └───────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ┌──────▼─────┐ ┌─────▼─────┐ │
│ │ MinIO │ │ H5P │ │
│ │ Storage │ │ Service │ │
│ └────────────┘ └───────────┘ │
│ │ │ │
│ ┌──────▼─────────────────▼─────┐ │
│ │ PostgreSQL Database │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌───────────┐ │
│ │ Teacher │────────────────────────▶│ Content │ │
│ │ Discovery │ Search & Download │ Player │ │
│ │ UI │ │ │ │
│ └──────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 🚀 Deployment
### Quick Start
```bash
# 1. Startup Script ausführbar machen
chmod +x scripts/start-content-services.sh
# 2. Alle Services starten
./scripts/start-content-services.sh
# ODER manuell:
docker-compose \
-f docker-compose.yml \
-f docker-compose.content.yml \
up -d
```
### URLs nach Start
| Service | URL | Credentials |
|---------|-----|-------------|
| Content Service API | http://localhost:8002/docs | - |
| MinIO Console | http://localhost:9001 | minioadmin / minioadmin123 |
| H5P Editor | http://localhost:8003/h5p/editor/new | - |
| Content Database | localhost:5433 | breakpilot / breakpilot123 |
---
## 📝 Content Creation Workflow
### 1. Creator erstellt Content
```javascript
// POST /api/v1/content
{
"title": "5-Minuten Yoga",
"description": "Bewegungspause für Grundschüler",
"content_type": "video",
"category": "movement",
"license": "CC-BY-SA-4.0",
"age_min": 6,
"age_max": 10,
"tags": ["yoga", "bewegung"]
}
```
### 2. Upload Media Files
```javascript
// POST /api/v1/upload
FormData {
file: <video-file.mp4>
}
Returns: { file_url: "http://minio:9000/..." }
```
### 3. Attach Files to Content
```javascript
// POST /api/v1/content/{id}/files
{
"file_urls": ["http://minio:9000/..."]
}
```
### 4. Publish to Matrix
```javascript
// POST /api/v1/content/{id}/publish
Status: PUBLISHED
Matrix Message in #movement Space
Discoverable by Teachers
```
---
## 🎨 Frontend Components (Creator Studio)
### Struktur (Vorbereitet)
```
frontend/creator-studio/
├── src/
│ ├── components/
│ │ ├── ContentUpload.vue
│ │ ├── ContentList.vue
│ │ ├── ContentEditor.vue
│ │ ├── H5PEditor.vue
│ │ └── Analytics.vue
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── CreateContent.vue
│ │ └── MyContent.vue
│ ├── api/
│ │ └── content.js
│ └── router/
│ └── index.js
├── package.json
└── vite.config.js
```
**Status:** Framework vorbereitet, vollständige UI-Implementation ausstehend (Sprint 1-2 Frontend)
---
## ⏭️ Nächste Schritte (Optional/Future)
### **Ausstehend:**
1. **OAuth2 SSO Integration** (Sprint 3-4)
- consent-service → Matrix SSO
- JWT Validation in Content Service
- User Roles & Permissions
2. **Teacher Discovery UI** (Sprint 5-6)
- Vue.js Frontend komplett
- Search & Filter UI
- Content Preview & Download
- Rating Interface
3. **Production Deployment**
- Environment Configuration
- SSL/TLS Certificates
- Backup Strategy
- Monitoring (Prometheus/Grafana)
---
## 📈 Impact Scoring (Fundament gelegt)
**Vorbereitet für zukünftige Implementierung:**
```python
# Impact Score Calculation (Beispiel)
impact_score = (
downloads * 10 +
rating_count * 5 +
avg_rating * 20 +
matrix_engagement * 2
)
```
**Bereits getrackt:**
- ✅ Downloads
- ✅ Views
- ✅ Ratings (Stars + Comments)
- ✅ Matrix Event IDs
---
## 🎯 Erreichte Features (Zusammenfassung)
| Feature | Status | Sprint |
|---------|--------|--------|
| Content CRUD API | ✅ | 1-2 |
| File Upload (MinIO) | ✅ | 1-2 |
| PostgreSQL Schema | ✅ | 1-2 |
| Matrix Feed Publishing | ✅ | 3-4 |
| Rating System | ✅ | 5-6 |
| Download Tracking | ✅ | 5-6 |
| H5P Integration | ✅ | 7-8 |
| CC Licensing | ✅ | 7-8 |
| DSGVO Compliance | ✅ | 7-8 |
| Docker Setup | ✅ | 7-8 |
| Deployment Guide | ✅ | 7-8 |
| Creator Studio (Backend) | ✅ | 1-2 |
| Creator Studio (Frontend) | 🔜 | Pending |
| Teacher Discovery UI | 🔜 | Pending |
| OAuth2 SSO | 🔜 | Pending |
---
## 📚 Dokumentation
-**CONTENT_SERVICE_SETUP.md** - Vollständiger Setup Guide
-**IMPLEMENTATION_SUMMARY.md** - Diese Datei
-**API Dokumentation** - Auto-generiert via FastAPI (/docs)
-**Architekturempfehlung PDF** - Strategische Planung
---
## 🎉 Fazit
**Implementiert:** 8+ Wochen Entwicklung in Sprints 1-8
**Kernfunktionen:**
- ✅ Vollständiger Content Service (Backend)
- ✅ MinIO S3 Storage
- ✅ H5P Interactive Content
- ✅ Matrix Feed Integration
- ✅ Creative Commons Licensing
- ✅ Rating & Analytics
- ✅ DSGVO Compliance
- ✅ Docker Deployment Ready
**Ready to Use:** Alle Backend-Services produktionsbereit
**Next:** Frontend UI vervollständigen & Production Deploy
---
**🚀 Die BreakPilot Content Platform ist LIVE!**

View File

@@ -0,0 +1,95 @@
# Mac Mini Headless Setup - Vollständig Automatisch
## Verbindungsdaten
- **IP (LAN):** 192.168.178.100
- **IP (WiFi):** 192.168.178.163 (nicht mehr aktiv)
- **User:** benjaminadmin
- **SSH:** `ssh benjaminadmin@192.168.178.100`
## Nach Neustart - Alles startet automatisch!
| Service | Auto-Start | Port |
|---------|------------|------|
| ✅ SSH | Ja | 22 |
| ✅ Docker Desktop | Ja | - |
| ✅ Docker Container | Ja (nach ~2 Min) | 8000, 8081, etc. |
| ✅ Ollama Server | Ja | 11434 |
| ✅ Unity Hub | Ja | - |
| ✅ VS Code | Ja | - |
**Keine Aktion nötig nach Neustart!** Einfach 2-3 Minuten warten.
## Status prüfen
```bash
./scripts/mac-mini/status.sh
```
## Services & Ports
| Service | Port | URL |
|---------|------|-----|
| Backend API | 8000 | http://192.168.178.100:8000/admin |
| Consent Service | 8081 | - |
| PostgreSQL | 5432 | - |
| Valkey/Redis | 6379 | - |
| MinIO | 9000/9001 | http://192.168.178.100:9001 |
| Mailpit | 8025 | http://192.168.178.100:8025 |
| Ollama | 11434 | http://192.168.178.100:11434/api/tags |
## LLM Modelle
- **Qwen 2.5 14B** (14.8 Milliarden Parameter)
## Scripts (auf MacBook)
```bash
./scripts/mac-mini/status.sh # Status prüfen
./scripts/mac-mini/sync.sh # Code synchronisieren
./scripts/mac-mini/docker.sh # Docker-Befehle
./scripts/mac-mini/backup.sh # Backup erstellen
```
## Docker-Befehle
```bash
./scripts/mac-mini/docker.sh ps # Container anzeigen
./scripts/mac-mini/docker.sh logs backend # Logs
./scripts/mac-mini/docker.sh restart # Neustart
./scripts/mac-mini/docker.sh build # Image bauen
```
## LaunchAgents (Auto-Start)
Pfad auf Mac Mini: `~/Library/LaunchAgents/`
| Agent | Funktion |
|-------|----------|
| `com.docker.desktop.plist` | Docker Desktop |
| `com.breakpilot.docker-containers.plist` | Container Auto-Start |
| `com.ollama.serve.plist` | Ollama Server |
| `com.unity.hub.plist` | Unity Hub |
| `com.microsoft.vscode.plist` | VS Code |
## Projekt-Pfade
- **MacBook:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/`
- **Mac Mini:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/`
## Troubleshooting
### Docker Onboarding erscheint wieder
Docker-Einstellungen sind gesichert in `~/docker-settings-backup/`
```bash
# Wiederherstellen:
cp -r ~/docker-settings-backup/* ~/Library/Group\ Containers/group.com.docker/
```
### Container starten nicht automatisch
Log prüfen:
```bash
ssh benjaminadmin@192.168.178.163 "cat /tmp/docker-autostart.log"
```
Manuell starten:
```bash
./scripts/mac-mini/docker.sh up
```
### SSH nicht erreichbar
- Prüfe ob Mac Mini an ist (Ping: `ping 192.168.178.163`)
- Warte 1-2 Minuten nach Boot
- Prüfe Netzwerkverbindung

80
admin-v2/Makefile Normal file
View File

@@ -0,0 +1,80 @@
# BreakPilot PWA - Makefile fuer lokale CI-Simulation
#
# Verwendung:
# make ci - Alle Tests lokal ausfuehren
# make test-go - Nur Go-Tests
# make test-python - Nur Python-Tests
# make logs-agent - Woodpecker Agent Logs
# make logs-backend - Backend Logs (ci-result)
.PHONY: ci test-go test-python test-node logs-agent logs-backend clean help
# Verzeichnis fuer Test-Ergebnisse
CI_RESULTS_DIR := .ci-results
help:
@echo "BreakPilot CI - Verfuegbare Befehle:"
@echo ""
@echo " make ci - Alle Tests lokal ausfuehren"
@echo " make test-go - Go Service Tests"
@echo " make test-python - Python Service Tests"
@echo " make test-node - Node.js Service Tests"
@echo " make logs-agent - Woodpecker Agent Logs anzeigen"
@echo " make logs-backend - Backend Logs (ci-result) anzeigen"
@echo " make clean - Test-Ergebnisse loeschen"
ci: test-go test-python test-node
@echo "========================================="
@echo "Local CI complete. Results in $(CI_RESULTS_DIR)/"
@echo "========================================="
@ls -la $(CI_RESULTS_DIR)/
test-go: $(CI_RESULTS_DIR)
@echo "=== Go Tests ==="
@if [ -d "consent-service" ]; then \
cd consent-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-consent.json 2>&1 || true; \
echo "consent-service: done"; \
fi
@if [ -d "billing-service" ]; then \
cd billing-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-billing.json 2>&1 || true; \
echo "billing-service: done"; \
fi
@if [ -d "school-service" ]; then \
cd school-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-school.json 2>&1 || true; \
echo "school-service: done"; \
fi
test-python: $(CI_RESULTS_DIR)
@echo "=== Python Tests ==="
@if [ -d "backend" ]; then \
cd backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \
echo "backend: done"; \
fi
@if [ -d "voice-service" ]; then \
cd voice-service && python -m pytest tests/ -v --tb=short 2>&1 || true; \
echo "voice-service: done"; \
fi
@if [ -d "klausur-service/backend" ]; then \
cd klausur-service/backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \
echo "klausur-service: done"; \
fi
test-node: $(CI_RESULTS_DIR)
@echo "=== Node.js Tests ==="
@if [ -d "h5p-service" ]; then \
cd h5p-service && npm test 2>&1 || true; \
echo "h5p-service: done"; \
fi
$(CI_RESULTS_DIR):
@mkdir -p $(CI_RESULTS_DIR)
logs-agent:
docker logs breakpilot-pwa-woodpecker-agent --tail=200
logs-backend:
docker compose logs backend --tail=200 | grep -E "(ci-result|error|ERROR)"
clean:
rm -rf $(CI_RESULTS_DIR)
@echo "Test-Ergebnisse geloescht"

View File

@@ -0,0 +1,794 @@
# Policy Vault - Projekt-Dokumentation
## Projektübersicht
**Policy Vault** ist eine vollständige Web-Anwendung zur Verwaltung von Datenschutzrichtlinien, Cookie-Einwilligungen und Nutzerzustimmungen für verschiedene Projekte und Plattformen. Das System ermöglicht es Administratoren, Datenschutzdokumente zu erstellen, zu verwalten und zu versionieren, sowie Nutzereinwilligungen zu verfolgen und Cookie-Präferenzen zu speichern.
## Zweck und Anwendungsbereich
Das Policy Vault System dient als zentrale Plattform für:
- **Verwaltung von Datenschutzrichtlinien** (Privacy Policies, Terms of Service, etc.)
- **Cookie-Consent-Management** mit Kategorisierung und Vendor-Verwaltung
- **Versionskontrolle** für Richtliniendokumente
- **Multi-Projekt-Verwaltung** mit rollenbasiertem Zugriff
- **Nutzereinwilligungs-Tracking** über verschiedene Plattformen hinweg
- **Mehrsprachige Unterstützung** für globale Anwendungen
---
## Technologie-Stack
### Backend
- **Framework**: NestJS (Node.js/TypeScript)
- **Datenbank**: PostgreSQL
- **ORM**: Drizzle ORM
- **Authentifizierung**: JWT (JSON Web Tokens) mit Access/Refresh Token
- **API-Dokumentation**: Swagger/OpenAPI
- **Validierung**: class-validator, class-transformer
- **Security**:
- Encryption-based authentication
- Rate limiting (Throttler)
- Role-based access control (RBAC)
- bcrypt für Password-Hashing
- **Logging**: Winston mit Daily Rotate File
- **Job Scheduling**: NestJS Schedule
- **E-Mail**: Nodemailer
- **OTP-Generierung**: otp-generator
### Frontend
- **Framework**: Angular 18
- **UI**:
- TailwindCSS
- Custom SCSS
- **Rich Text Editor**: CKEditor 5
- Alignment, Block Quote, Code Block
- Font styling, Image support
- List und Table support
- **State Management**: RxJS
- **Security**: DOMPurify für HTML-Sanitization
- **Multi-Select**: ng-multiselect-dropdown
- **Process Manager**: PM2
---
## Hauptfunktionen und Features
### 1. Administratoren-Verwaltung
- **Super Admin und Admin Rollen**
- Super Admin (Role 1): Vollzugriff auf alle Funktionen
- Admin (Role 2): Eingeschränkter Zugriff auf zugewiesene Projekte
- **Authentifizierung**
- Login mit E-Mail und Passwort
- JWT-basierte Sessions (Access + Refresh Token)
- OTP-basierte Passwort-Wiederherstellung
- Account-Lock-Mechanismus bei mehrfachen Fehlversuchen
- **Benutzerverwaltung**
- Admin-Erstellung durch Super Admin
- Projekt-Zuweisungen für Admins
- Rollen-Modifikation (Promote/Demote)
- Soft-Delete (isDeleted Flag)
### 2. Projekt-Management
- **Projektverwaltung**
- Erstellung und Verwaltung von Projekten
- Projekt-spezifische Konfiguration (Theme-Farben, Icons, Logos)
- Mehrsprachige Unterstützung (Language Configuration)
- Projekt-Keys für sichere API-Zugriffe
- Soft-Delete und Blocking von Projekten
- **Projekt-Zugriffskontrolle**
- Zuweisung von Admins zu spezifischen Projekten
- Project-Admin-Beziehungen
### 3. Policy Document Management
- **Dokumentenverwaltung**
- Erstellung von Datenschutzdokumenten (Privacy Policies, ToS, etc.)
- Projekt-spezifische Dokumente
- Beschreibung und Metadaten
- **Versionierung**
- Multiple Versionen pro Dokument
- Version-Metadaten mit Inhalt
- Publish/Draft-Status
- Versionsnummern-Tracking
### 4. Cookie-Consent-Management
- **Cookie-Kategorien**
- Kategorien-Metadaten (z.B. Notwendig, Marketing, Analytics)
- Plattform-spezifische Kategorien (Web, Mobile, etc.)
- Versionierung der Kategorien
- Pflicht- und optionale Kategorien
- Mehrsprachige Kategorie-Beschreibungen
- **Vendor-Management**
- Verwaltung von Drittanbieter-Services
- Vendor-Metadaten und -Beschreibungen
- Zuordnung zu Kategorien
- Sub-Services für Vendors
- Mehrsprachige Vendor-Informationen
- **Globale Cookie-Einstellungen**
- Projekt-weite Cookie-Texte und -Beschreibungen
- Mehrsprachige globale Inhalte
- Datei-Upload-Unterstützung
### 5. User Consent Tracking
- **Policy Document Consent**
- Tracking von Nutzereinwilligungen für Richtlinien-Versionen
- Username-basiertes Tracking
- Status (Akzeptiert/Abgelehnt)
- Timestamp-Tracking
- **Cookie Consent**
- Granulare Cookie-Einwilligungen pro Kategorie
- Vendor-spezifische Einwilligungen
- Versions-Tracking
- Username und Projekt-basiert
- **Verschlüsselte API-Zugriffe**
- Token-basierte Authentifizierung für Mobile/Web
- Encryption-based authentication für externe Zugriffe
### 6. Mehrsprachige Unterstützung
- **Language Management**
- Dynamische Sprachen-Konfiguration pro Projekt
- Mehrsprachige Inhalte für:
- Kategorien-Beschreibungen
- Vendor-Informationen
- Globale Cookie-Texte
- Sub-Service-Beschreibungen
---
## API-Struktur und Endpoints
### Admin-Endpoints (`/admins`)
```
POST /admins/create-admin - Admin erstellen (Super Admin only)
POST /admins/create-super-admin - Super Admin erstellen (Super Admin only)
POST /admins/create-root-user-super-admin - Root Super Admin erstellen (Secret-based)
POST /admins/login - Admin Login
GET /admins/get-access-token - Neuen Access Token abrufen
POST /admins/generate-otp - OTP für Passwort-Reset generieren
POST /admins/validate-otp - OTP validieren
POST /admins/change-password - Passwort ändern (mit OTP)
PUT /admins/update-password - Passwort aktualisieren (eingeloggt)
PUT /admins/forgot-password - Passwort vergessen
PUT /admins/make-super-admin - Admin zu Super Admin befördern
PUT /admins/remove-super-admin - Super Admin zu Admin zurückstufen
PUT /admins/make-project-admin - Projekt-Zugriff gewähren
DELETE /admins/remove-project-admin - Projekt-Zugriff entfernen
GET /admins/findAll?role= - Alle Admins abrufen (gefiltert nach Rolle)
GET /admins/findAll-super-admins - Alle Super Admins abrufen
GET /admins/findOne?id= - Einzelnen Admin abrufen
PUT /admins/update - Admin-Details aktualisieren
DELETE /admins/delete-admin?id= - Admin löschen (Soft-Delete)
```
### Project-Endpoints (`/project`)
```
POST /project/create - Projekt erstellen (Super Admin only)
PUT /project/v2/updateProjectKeys - Projekt-Keys aktualisieren
GET /project/findAll - Alle Projekte abrufen (mit Pagination)
GET /project/findAllByUser - Projekte eines bestimmten Users
GET /project/findOne?id= - Einzelnes Projekt abrufen
PUT /project/update - Projekt aktualisieren
DELETE /project/delete?id= - Projekt löschen
```
### Policy Document-Endpoints (`/policydocument`)
```
POST /policydocument/create - Policy Document erstellen
GET /policydocument/findAll - Alle Policy Documents abrufen
GET /policydocument/findOne?id= - Einzelnes Policy Document
GET /policydocument/findPolicyDocs?projectId= - Documents für ein Projekt
PUT /policydocument/update - Policy Document aktualisieren
DELETE /policydocument/delete?id= - Policy Document löschen
```
### Version-Endpoints (`/version`)
```
POST /version/create - Version erstellen
GET /version/findAll - Alle Versionen abrufen
GET /version/findOne?id= - Einzelne Version abrufen
GET /version/findVersions?policyDocId= - Versionen für ein Policy Document
PUT /version/update - Version aktualisieren
DELETE /version/delete?id= - Version löschen
```
### User Consent-Endpoints (`/consent`)
```
POST /consent/v2/create - User Consent erstellen (Encrypted)
GET /consent/v2/GetConsent - Consent abrufen (Encrypted)
GET /consent/v2/GetConsentFileContent - Consent mit Dateiinhalt (Encrypted)
GET /consent/v2/latestAcceptedConsent - Letzte akzeptierte Consent
DELETE /consent/v2/delete - Consent löschen (Encrypted)
```
### Cookie Consent-Endpoints (`/cookieconsent`)
```
POST /cookieconsent/v2/create - Cookie Consent erstellen (Encrypted)
GET /cookieconsent/v2/get - Cookie Kategorien abrufen (Encrypted)
GET /cookieconsent/v2/getFileContent - Cookie Daten mit Dateiinhalt (Encrypted)
DELETE /cookieconsent/v2/delete - Cookie Consent löschen (Encrypted)
```
### Cookie-Endpoints (`/cookies`)
```
POST /cookies/createCategory - Cookie-Kategorie erstellen
POST /cookies/createVendor - Vendor erstellen
POST /cookies/createGlobalCookie - Globale Cookie-Einstellung erstellen
GET /cookies/getCategories?projectId= - Kategorien für Projekt abrufen
GET /cookies/getVendors?projectId= - Vendors für Projekt abrufen
GET /cookies/getGlobalCookie?projectId= - Globale Cookie-Settings
PUT /cookies/updateCategory - Kategorie aktualisieren
PUT /cookies/updateVendor - Vendor aktualisieren
PUT /cookies/updateGlobalCookie - Globale Settings aktualisieren
DELETE /cookies/deleteCategory?id= - Kategorie löschen
DELETE /cookies/deleteVendor?id= - Vendor löschen
DELETE /cookies/deleteGlobalCookie?id= - Globale Settings löschen
```
### Health Check-Endpoint (`/db-health-check`)
```
GET /db-health-check - Datenbank-Status prüfen
```
---
## Datenmodelle
### Admin
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
employeeCode: string (nullable)
firstName: string (max 60)
lastName: string (max 50)
officialMail: string (unique, max 100)
role: number (1 = Super Admin, 2 = Admin)
passwordHash: string
salt: string (nullable)
accessToken: text (nullable)
refreshToken: text (nullable)
accLockCount: number (default 0)
accLockTime: number (default 0)
isBlocked: boolean (default false)
isDeleted: boolean (default false)
otp: string (nullable)
}
```
### Project
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
name: string (unique)
description: string
imageURL: text (nullable)
iconURL: text (nullable)
isBlocked: boolean (default false)
isDeleted: boolean (default false)
themeColor: string
textColor: string
languages: json (nullable) // Array von Sprach-Codes
}
```
### Policy Document
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
name: string
description: string (nullable)
projectId: number (FK -> project.id, CASCADE)
}
```
### Version (Policy Document Meta & Version Meta)
```typescript
// Policy Document Meta
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
policyDocumentId: number (FK)
version: string
isPublish: boolean
}
// Version Meta (Sprachspezifischer Inhalt)
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
policyDocMetaId: number (FK)
language: string
content: text
file: text (nullable)
}
```
### User Consent
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
username: string
status: boolean
projectId: number (FK -> project.id, CASCADE)
versionMetaId: number (FK -> versionMeta.id, CASCADE)
}
```
### Cookie Consent
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
username: string
categoryId: number[] (Array)
vendors: number[] (Array)
projectId: number (FK -> project.id, CASCADE)
version: string
}
```
### Categories Metadata
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
projectId: number (FK -> project.id, CASCADE)
platform: string
version: string
isPublish: boolean (default false)
metaName: string
isMandatory: boolean (default false)
}
```
### Categories Language Data
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
categoryMetaId: number (FK -> categoriesMetadata.id, CASCADE)
language: string
title: string
description: text
}
```
### Vendor Meta
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
categoryId: number (FK -> categoriesMetadata.id, CASCADE)
vendorName: string
}
```
### Vendor Language
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
vendorMetaId: number (FK -> vendorMeta.id, CASCADE)
language: string
description: text
}
```
### Sub Service
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
vendorMetaId: number (FK -> vendorMeta.id, CASCADE)
serviceName: string
}
```
### Global Cookie Metadata
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
projectId: number (FK -> project.id, CASCADE)
version: string
isPublish: boolean (default false)
}
```
### Global Cookie Language Data
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
globalCookieMetaId: number (FK -> globalCookieMetadata.id, CASCADE)
language: string
title: string
description: text
file: text (nullable)
}
```
### Project Keys
```typescript
{
id: number (PK)
createdAt: timestamp
updatedAt: timestamp
projectId: number (FK -> project.id, CASCADE)
publicKey: text
privateKey: text
}
```
### Admin Projects (Junction Table)
```typescript
{
id: number (PK)
adminId: number (FK -> admin.id, CASCADE)
projectId: number (FK -> project.id, CASCADE)
}
```
---
## Architektur-Übersicht
### Backend-Architektur
```
┌─────────────────────────────────────────────────────────────┐
│ NestJS Backend │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Guards │ │ Middlewares │ │ Interceptors │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ - AuthGuard │ │ - Token │ │ - Serialize │ │
│ │ - RolesGuard │ │ - Decrypt │ │ - Logging │ │
│ │ - Throttler │ │ - Headers │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ API Modules │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ - Admins (Authentication, Authorization) │ │
│ │ - Projects (Multi-tenant Management) │ │
│ │ - Policy Documents (Document Management) │ │
│ │ - Versions (Versioning System) │ │
│ │ - User Consent (Consent Tracking) │ │
│ │ - Cookies (Cookie Categories & Vendors) │ │
│ │ - Cookie Consent (Cookie Consent Tracking) │ │
│ │ - DB Health Check (System Monitoring) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Drizzle ORM Layer │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ - Schema Definitions │ │
│ │ - Relations │ │
│ │ - Database Connection Pool │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
└──────────────────────────┼────────────────────────────────────┘
┌─────────────────┐
│ PostgreSQL │
│ Database │
└─────────────────┘
```
### Frontend-Architektur
```
┌─────────────────────────────────────────────────────────────┐
│ Angular Frontend │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Guards │ │ Interceptors │ │ Services │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ - AuthGuard │ │ - HTTP │ │ - Auth │ │
│ │ │ │ - Error │ │ - REST API │ │
│ │ │ │ │ │ - Session │ │
│ │ │ │ │ │ - Security │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Feature Modules │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Auth Module │ │ │
│ │ │ - Login Component │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Project Dashboard │ │ │
│ │ │ - Project List │ │ │
│ │ │ - Project Creation │ │ │
│ │ │ - Project Settings │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Individual Project Dashboard │ │ │
│ │ │ - Agreements (Policy Documents) │ │ │
│ │ │ - Cookie Consent Management │ │ │
│ │ │ - FAQ Management │ │ │
│ │ │ - Licenses Management │ │ │
│ │ │ - User Management │ │ │
│ │ │ - Project Settings │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Shared Components │ │ │
│ │ │ - Settings │ │ │
│ │ │ - Common UI Elements │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│ HTTPS/REST API
┌─────────────────┐
│ NestJS Backend │
└─────────────────┘
```
### Datenbankbeziehungen
```
┌──────────┐ ┌─────────────────┐ ┌─────────────┐
│ Admin │◄───────►│ AdminProjects │◄───────►│ Project │
└──────────┘ └─────────────────┘ └─────────────┘
│ 1:N
┌────────────────────────────────────┤
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Policy Document │ │ Categories Metadata │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 1:N │ 1:N
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Policy Document Meta │ │ Categories Language Data │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 1:N │ 1:N
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Version Meta │ │ Vendor Meta │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 1:N │ 1:N
▼ ├──────────┐
┌──────────────────────┐ ▼ ▼
│ User Consent │ ┌─────────────────┐ ┌────────────┐
└──────────────────────┘ │ Vendor Language │ │Sub Service │
└─────────────────┘ └────────────┘
┌──────────────────────┐
│ Cookie Consent │◄─── Project
└──────────────────────┘
┌─────────────────────────┐
│ Global Cookie Metadata │◄─── Project
└─────────────────────────┘
│ 1:N
┌─────────────────────────────┐
│ Global Cookie Language Data │
└─────────────────────────────────┘
┌──────────────────┐
│ Project Keys │◄─── Project
└──────────────────┘
```
### Sicherheitsarchitektur
#### Authentifizierung & Autorisierung
1. **JWT-basierte Authentifizierung**
- Access Token (kurzlebig)
- Refresh Token (langlebig)
- Token-Refresh-Mechanismus
2. **Rollenbasierte Zugriffskontrolle (RBAC)**
- Super Admin (Role 1): Vollzugriff
- Admin (Role 2): Projektbezogener Zugriff
- Guard-basierte Absicherung auf Controller-Ebene
3. **Encryption-based Authentication**
- Für externe/mobile Zugriffe
- Token-basierte Verschlüsselung
- User + Project ID Validierung
#### Security Features
- **Rate Limiting**: Throttler mit konfigurierbaren Limits
- **Password Security**: bcrypt Hashing mit Salt
- **Account Lock**: Nach mehrfachen Fehlversuchen
- **OTP-basierte Passwort-Wiederherstellung**
- **Input Validation**: class-validator auf allen DTOs
- **HTML Sanitization**: DOMPurify im Frontend
- **CORS Configuration**: Custom Headers Middleware
- **Soft Delete**: Keine permanente Löschung von Daten
---
## Deployment und Konfiguration
### Backend Environment Variables
```env
DATABASE_URL=postgresql://username:password@host:port/database
NODE_ENV=development|test|production|local|demo
PORT=3000
JWT_SECRET=your_jwt_secret
JWT_REFRESH_SECRET=your_refresh_secret
ROOT_SECRET=your_root_secret
ENCRYPTION_KEY=your_encryption_key
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your_email
SMTP_PASSWORD=your_password
```
### Frontend Environment
```typescript
{
production: false,
BASE_URL: "https://api.example.com/api/",
TITLE: "Policy Vault - Environment"
}
```
### Datenbank-Setup
```bash
# Migrationen ausführen
npm run migration:up
# Migrationen zurückrollen
npm run migration:down
# Schema generieren
npx drizzle-kit push
```
---
## API-Sicherheit
### Token-basierte Authentifizierung
- Alle geschützten Endpoints erfordern einen gültigen JWT-Token im Authorization-Header
- Format: `Authorization: Bearer <access_token>`
### Encryption-based Endpoints
Für mobile/externe Zugriffe (Consent Tracking):
- Header: `secret` oder `mobiletoken`
- Format: Verschlüsselter String mit `userId_projectId`
- Automatische Validierung durch DecryptMiddleware
### Rate Limiting
- Standard: 10 Requests pro Minute
- OTP/Login: 3 Requests pro Minute
- Konfigurierbar über ThrottlerModule
---
## Besondere Features
### 1. Versionierung
- Komplettes Versions-Management für Policy Documents
- Mehrsprachige Versionen mit separaten Inhalten
- Publish/Draft Status
- Historische Versionsverfolgung
### 2. Mehrsprachigkeit
- Zentrale Sprach-Konfiguration pro Projekt
- Separate Language-Data Tabellen für alle Inhaltstypen
- Support für unbegrenzte Sprachen
### 3. Cookie-Consent-System
- Granulare Kontrolle über Cookie-Kategorien
- Vendor-Management mit Sub-Services
- Plattform-spezifische Kategorien (Web, Mobile, etc.)
- Versions-Tracking für Compliance
### 4. Rich Content Editing
- CKEditor 5 Integration
- Support für komplexe Formatierungen
- Bild-Upload und -Verwaltung
- Code-Block-Unterstützung
### 5. Logging & Monitoring
- Winston-basiertes Logging
- Daily Rotate Files
- Structured Logging
- Fehler-Tracking
- Datenbank-Health-Checks
### 6. Soft Delete Pattern
- Keine permanente Datenlöschung
- `isDeleted` Flags auf allen Haupt-Entitäten
- Möglichkeit zur Wiederherstellung
- Audit Trail Erhaltung
---
## Entwicklung
### Backend starten
```bash
# Development
npm run start:dev
# Local (mit Watch)
npm run start:local
# Production
npm run start:prod
```
### Frontend starten
```bash
# Development Server
npm run start
# oder
ng serve
# Build
npm run build
# Mit PM2
npm run start:pm2
```
### Tests
```bash
# Backend Tests
npm run test
npm run test:e2e
npm run test:cov
# Frontend Tests
npm run test
```
---
## Zusammenfassung
Policy Vault ist eine umfassende Enterprise-Lösung für die Verwaltung von Datenschutzrichtlinien und Cookie-Einwilligungen. Das System bietet:
- **Multi-Tenant-Architektur** mit Projekt-basierter Trennung
- **Robuste Authentifizierung** mit JWT und rollenbasierter Zugriffskontrolle
- **Vollständiges Versions-Management** für Compliance-Tracking
- **Granulare Cookie-Consent-Verwaltung** mit Vendor-Support
- **Mehrsprachige Unterstützung** für globale Anwendungen
- **Moderne Tech-Stack** mit NestJS, Angular und PostgreSQL
- **Enterprise-Grade Security** mit Encryption, Rate Limiting und Audit Trails
- **Skalierbare Architektur** mit klarer Trennung von Concerns
Das System eignet sich ideal für Unternehmen, die:
- Multiple Projekte/Produkte mit unterschiedlichen Datenschutzrichtlinien verwalten
- GDPR/DSGVO-Compliance sicherstellen müssen
- Granulare Cookie-Einwilligungen tracken wollen
- Mehrsprachige Anwendungen betreiben
- Eine zentrale Policy-Management-Plattform benötigen

1204
admin-v2/SBOM.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,530 @@
# Source-Policy System - Implementierungsplan
## Zusammenfassung
Whitelist-basiertes Datenquellen-Management fuer das edu-search-service unter `/compliance/source-policy`. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail.
**Kernprinzipien:**
- Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG)
- Training mit externen Daten: **VERBOTEN**
- Alle Aenderungen protokolliert (Audit-Trail)
- PII-Blocklist mit Hard-Block
---
## 1. Architektur
```
┌─────────────────────────────────────────────────────────────────┐
│ admin-v2 (Next.js) │
│ /app/(admin)/compliance/source-policy/ │
│ ├── page.tsx (Dashboard + Tabs) │
│ └── components/ │
│ ├── SourcesTab.tsx (Whitelist-Verwaltung) │
│ ├── OperationsMatrixTab.tsx (Lookup/RAG/Training/Export) │
│ ├── PIIRulesTab.tsx (PII-Blocklist) │
│ └── AuditTab.tsx (Aenderungshistorie + Export) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ edu-search-service (Go) │
│ NEW: internal/policy/ │
│ ├── models.go (Datenstrukturen) │
│ ├── store.go (PostgreSQL CRUD) │
│ ├── enforcer.go (Policy-Enforcement) │
│ ├── pii_detector.go (PII-Erkennung) │
│ └── audit.go (Audit-Logging) │
│ │
│ MODIFIED: │
│ ├── crawler/crawler.go (Whitelist-Check vor Fetch) │
│ ├── pipeline/pipeline.go (PII-Filter nach Extract) │
│ └── api/handlers/policy_handlers.go (Admin-API) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ NEW TABLES: │
│ - source_policies (versionierte Policies) │
│ - allowed_sources (Whitelist pro Bundesland) │
│ - operation_permissions (Lookup/RAG/Training/Export Matrix) │
│ - pii_rules (Regex/Keyword Blocklist) │
│ - policy_audit_log (unveraenderlich) │
│ - blocked_content_log (blockierte URLs fuer Audit) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. Datenmodell
### 2.1 PostgreSQL Schema
```sql
-- Policies (versioniert)
CREATE TABLE source_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version INTEGER NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
bundesland VARCHAR(2), -- NULL = Bundesebene/KMK
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
approved_by UUID,
approved_at TIMESTAMP
);
-- Whitelist
CREATE TABLE allowed_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID REFERENCES source_policies(id),
domain VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
license VARCHAR(50) NOT NULL, -- DL-DE-BY-2.0, CC-BY, §5 UrhG
legal_basis VARCHAR(100),
citation_template TEXT,
trust_boost DECIMAL(3,2) DEFAULT 0.50,
is_active BOOLEAN DEFAULT true
);
-- Operations Matrix
CREATE TABLE operation_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID REFERENCES allowed_sources(id),
operation VARCHAR(50) NOT NULL, -- lookup, rag, training, export
is_allowed BOOLEAN NOT NULL,
requires_citation BOOLEAN DEFAULT false,
notes TEXT
);
-- PII Blocklist
CREATE TABLE pii_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
rule_type VARCHAR(50) NOT NULL, -- regex, keyword
pattern TEXT NOT NULL,
severity VARCHAR(20) DEFAULT 'block', -- block, warn, redact
is_active BOOLEAN DEFAULT true
);
-- Audit Log (immutable)
CREATE TABLE policy_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID,
old_value JSONB,
new_value JSONB,
user_email VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
-- Blocked Content Log
CREATE TABLE blocked_content_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url VARCHAR(2048) NOT NULL,
domain VARCHAR(255) NOT NULL,
block_reason VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 2.2 Initial-Daten
Datei: `edu-search-service/policies/bundeslaender.yaml`
```yaml
federal:
name: "KMK & Bundesebene"
sources:
- domain: "kmk.org"
name: "Kultusministerkonferenz"
license: "§5 UrhG"
legal_basis: "Amtliche Werke (§5 UrhG)"
citation_template: "Quelle: KMK, {title}, {date}"
- domain: "bildungsserver.de"
name: "Deutscher Bildungsserver"
license: "DL-DE-BY-2.0"
NI:
name: "Niedersachsen"
sources:
- domain: "nibis.de"
name: "NiBiS Bildungsserver"
license: "DL-DE-BY-2.0"
- domain: "mk.niedersachsen.de"
name: "Kultusministerium Niedersachsen"
license: "§5 UrhG"
- domain: "cuvo.nibis.de"
name: "Kerncurricula Niedersachsen"
license: "DL-DE-BY-2.0"
BY:
name: "Bayern"
sources:
- domain: "km.bayern.de"
name: "Bayerisches Kultusministerium"
license: "§5 UrhG"
- domain: "isb.bayern.de"
name: "ISB Bayern"
license: "DL-DE-BY-2.0"
- domain: "lehrplanplus.bayern.de"
name: "LehrplanPLUS"
license: "DL-DE-BY-2.0"
# Default Operations Matrix
default_operations:
lookup:
allowed: true
requires_citation: true
rag:
allowed: true
requires_citation: true
training:
allowed: false # VERBOTEN
export:
allowed: true
requires_citation: true
# Default PII Rules
pii_rules:
- name: "Email Addresses"
type: "regex"
pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
severity: "block"
- name: "German Phone Numbers"
type: "regex"
pattern: "(?:\\+49|0)[\\s.-]?\\d{2,4}[\\s.-]?\\d{3,}[\\s.-]?\\d{2,}"
severity: "block"
- name: "IBAN"
type: "regex"
pattern: "DE\\d{2}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{2}"
severity: "block"
```
---
## 3. Backend Implementation
### 3.1 Neue Dateien
| Datei | Beschreibung |
|-------|--------------|
| `internal/policy/models.go` | Go Structs (SourcePolicy, AllowedSource, PIIRule, etc.) |
| `internal/policy/store.go` | PostgreSQL CRUD mit pgx |
| `internal/policy/enforcer.go` | `CheckSource()`, `CheckOperation()`, `DetectPII()` |
| `internal/policy/audit.go` | `LogChange()`, `LogBlocked()` |
| `internal/policy/pii_detector.go` | Regex-basierte PII-Erkennung |
| `internal/api/handlers/policy_handlers.go` | Admin-Endpoints |
| `migrations/005_source_policies.sql` | DB-Schema |
| `policies/bundeslaender.yaml` | Initial-Daten |
### 3.2 API Endpoints
```
# Policies
GET /v1/admin/policies
POST /v1/admin/policies
PUT /v1/admin/policies/:id
# Sources (Whitelist)
GET /v1/admin/sources
POST /v1/admin/sources
PUT /v1/admin/sources/:id
DELETE /v1/admin/sources/:id
# Operations Matrix
GET /v1/admin/operations-matrix
PUT /v1/admin/operations/:id
# PII Rules
GET /v1/admin/pii-rules
POST /v1/admin/pii-rules
PUT /v1/admin/pii-rules/:id
DELETE /v1/admin/pii-rules/:id
POST /v1/admin/pii-rules/test # Test gegen Sample-Text
# Audit
GET /v1/admin/policy-audit?from=&to=
GET /v1/admin/blocked-content?from=&to=
GET /v1/admin/compliance-report # PDF/JSON Export
# Live-Check
POST /v1/admin/check-compliance
Body: { "url": "...", "operation": "lookup" }
```
### 3.3 Crawler-Integration
In `crawler/crawler.go`:
```go
func (c *Crawler) FetchWithPolicy(ctx context.Context, url string) (*FetchResult, error) {
// 1. Whitelist-Check
source, err := c.enforcer.CheckSource(ctx, url)
if err != nil || source == nil {
c.enforcer.LogBlocked(ctx, url, "not_whitelisted")
return nil, ErrNotWhitelisted
}
// ... existing fetch ...
// 2. PII-Check nach Fetch
piiMatches := c.enforcer.DetectPII(content)
if hasSeverity(piiMatches, "block") {
c.enforcer.LogBlocked(ctx, url, "pii_detected")
return nil, ErrPIIDetected
}
return result, nil
}
```
---
## 4. Frontend Implementation
### 4.1 Navigation Update
In `lib/navigation.ts` unter `compliance` Kategorie hinzufuegen:
```typescript
{
id: 'source-policy',
name: 'Quellen-Policy',
href: '/compliance/source-policy',
description: 'Datenquellen & Compliance',
purpose: 'Whitelist zugelassener Datenquellen mit Operations-Matrix und PII-Blocklist.',
audience: ['DSB', 'Compliance Officer', 'Auditor'],
gdprArticles: ['Art. 5 (Rechtmaessigkeit)', 'Art. 6 (Rechtsgrundlage)'],
}
```
### 4.2 Seiten-Struktur
```
/app/(admin)/compliance/source-policy/
├── page.tsx # Haupt-Dashboard mit Tabs
└── components/
├── SourcesTab.tsx # Whitelist-Tabelle mit CRUD
├── OperationsMatrixTab.tsx # 4x4 Matrix
├── PIIRulesTab.tsx # PII-Regeln mit Test-Funktion
└── AuditTab.tsx # Aenderungshistorie + Export
```
### 4.3 UI-Layout
**Stats Cards (oben):**
- Aktive Policies
- Zugelassene Quellen
- Blockiert (heute)
- Compliance Score
**Tabs:**
1. **Dashboard** - Uebersicht mit Quick-Stats
2. **Quellen** - Whitelist-Tabelle (Domain, Name, Lizenz, Status)
3. **Operations** - Matrix mit Lookup/RAG/Training/Export
4. **PII-Regeln** - Blocklist mit Test-Funktion
5. **Audit** - Aenderungshistorie mit PDF/JSON-Export
**Pattern (aus audit-report/page.tsx):**
- Tab-Navigation: `bg-purple-600 text-white` fuer aktiv
- Status-Badges: `bg-green-100 text-green-700` fuer aktiv
- Tabellen: `hover:bg-slate-50`
- Info-Boxen: `bg-blue-50 border-blue-200`
---
## 5. Betroffene Dateien
### Neue Dateien erstellen:
**Backend (edu-search-service):**
```
internal/policy/models.go
internal/policy/store.go
internal/policy/enforcer.go
internal/policy/audit.go
internal/policy/pii_detector.go
internal/api/handlers/policy_handlers.go
migrations/005_source_policies.sql
policies/bundeslaender.yaml
```
**Frontend (admin-v2):**
```
app/(admin)/compliance/source-policy/page.tsx
app/(admin)/compliance/source-policy/components/SourcesTab.tsx
app/(admin)/compliance/source-policy/components/OperationsMatrixTab.tsx
app/(admin)/compliance/source-policy/components/PIIRulesTab.tsx
app/(admin)/compliance/source-policy/components/AuditTab.tsx
```
### Bestehende Dateien aendern:
```
edu-search-service/cmd/server/main.go # Policy-Endpoints registrieren
edu-search-service/internal/crawler/crawler.go # Policy-Check hinzufuegen
edu-search-service/internal/pipeline/pipeline.go # PII-Filter
edu-search-service/internal/database/database.go # Migrations
admin-v2/lib/navigation.ts # source-policy Modul
```
---
## 6. Implementierungs-Reihenfolge
### Phase 1: Datenbank & Models
1. Migration `005_source_policies.sql` erstellen
2. Go Models in `internal/policy/models.go`
3. Store-Layer in `internal/policy/store.go`
4. YAML-Loader fuer Initial-Daten
### Phase 2: Policy Enforcer
1. `internal/policy/enforcer.go` - CheckSource, CheckOperation
2. `internal/policy/pii_detector.go` - Regex-basierte Erkennung
3. `internal/policy/audit.go` - Logging
4. Integration in Crawler
### Phase 3: Admin API
1. `internal/api/handlers/policy_handlers.go`
2. Routen in main.go registrieren
3. API testen
### Phase 4: Frontend
1. Hauptseite mit PagePurpose
2. SourcesTab mit Whitelist-CRUD
3. OperationsMatrixTab
4. PIIRulesTab mit Test-Funktion
5. AuditTab mit Export
### Phase 5: Testing & Deployment
1. Unit Tests fuer Enforcer
2. Integration Tests fuer API
3. E2E Test fuer Frontend
4. Deployment auf Mac Mini
---
## 7. Verifikation
### Nach Backend (Phase 1-3):
```bash
# Migration ausfuehren
ssh macmini "cd /path/to/edu-search-service && go run ./cmd/migrate"
# API testen
curl -X GET http://macmini:8088/v1/admin/policies
curl -X POST http://macmini:8088/v1/admin/check-compliance \
-d '{"url":"https://nibis.de/test","operation":"lookup"}'
```
### Nach Frontend (Phase 4):
```bash
# Build & Deploy
rsync -avz admin-v2/ macmini:/path/to/admin-v2/
ssh macmini "docker compose build admin-v2 && docker compose up -d admin-v2"
# Testen
open https://macmini:3002/compliance/source-policy
```
### Auditor-Checkliste:
- [ ] Alle Quellen in Whitelist dokumentiert
- [ ] Operations-Matrix zeigt Training = VERBOTEN
- [ ] PII-Regeln aktiv und testbar
- [ ] Audit-Log zeigt alle Aenderungen
- [ ] Blocked-Content-Log zeigt blockierte URLs
- [ ] PDF/JSON-Export funktioniert
---
## 8. KMK-Spezifika (§5 UrhG)
**Rechtsgrundlage:**
- KMK-Beschluesse, Vereinbarungen, EPA sind amtliche Werke nach §5 UrhG
- Frei nutzbar, Attribution erforderlich
**Zitierformat:**
```
Quelle: KMK, [Titel des Beschlusses], [Datum]
Beispiel: Quelle: KMK, Bildungsstandards im Fach Deutsch, 2003
```
**Zugelassene Dokumenttypen:**
- Beschluesse (Resolutions)
- Vereinbarungen (Agreements)
- EPA (Einheitliche Pruefungsanforderungen)
- Empfehlungen (Recommendations)
**In Operations-Matrix:**
| Operation | Erlaubt | Hinweis |
|-----------|---------|---------|
| Lookup | Ja | Quelle anzeigen |
| RAG | Ja | Zitation im Output |
| Training | **NEIN** | VERBOTEN |
| Export | Ja | Attribution |
---
## 9. Lizenzen
| Lizenz | Name | Attribution |
|--------|------|-------------|
| DL-DE-BY-2.0 | Datenlizenz Deutschland | Ja |
| CC-BY | Creative Commons Attribution | Ja |
| CC-BY-SA | CC Attribution-ShareAlike | Ja + ShareAlike |
| CC0 | Public Domain | Nein |
| §5 UrhG | Amtliche Werke | Ja (Quelle) |
---
## 10. Aktueller Stand
**Phase 1: Datenbank & Models - ABGESCHLOSSEN**
- [x] Codebase-Exploration edu-search-service
- [x] Codebase-Exploration admin-v2
- [x] Plan dokumentiert
- [x] Migration 005_source_policies.sql erstellen
- [x] Go Models implementieren (internal/policy/models.go)
- [x] Store-Layer implementieren (internal/policy/store.go)
- [x] Policy Enforcer implementieren (internal/policy/enforcer.go)
- [x] PII Detector implementieren (internal/policy/pii_detector.go)
- [x] Audit Logging implementieren (internal/policy/audit.go)
- [x] YAML Loader implementieren (internal/policy/loader.go)
- [x] Initial-Daten YAML erstellen (policies/bundeslaender.yaml)
- [x] Unit Tests schreiben (internal/policy/policy_test.go)
- [x] README aktualisieren
**Phase 2: Admin API - AUSSTEHEND**
- [ ] API Handlers implementieren (policy_handlers.go)
- [ ] main.go aktualisieren
- [ ] API testen
**Phase 3: Integration - AUSSTEHEND**
- [ ] Crawler-Integration
- [ ] Pipeline-Integration
**Phase 4: Frontend - AUSSTEHEND**
- [ ] Frontend page.tsx erstellen
- [ ] SourcesTab Component
- [ ] OperationsMatrixTab Component
- [ ] PIIRulesTab Component
- [ ] AuditTab Component
- [ ] Navigation aktualisieren
**Erstellte Dateien:**
```
edu-search-service/
├── migrations/
│ └── 005_source_policies.sql # DB Schema (6 Tabellen)
├── internal/policy/
│ ├── models.go # Datenstrukturen & Enums
│ ├── store.go # PostgreSQL CRUD
│ ├── enforcer.go # Policy-Enforcement
│ ├── pii_detector.go # PII-Erkennung
│ ├── audit.go # Audit-Logging
│ ├── loader.go # YAML-Loader
│ └── policy_test.go # Unit Tests
└── policies/
└── bundeslaender.yaml # Initial-Daten (8 Bundeslaender)
```

View File

@@ -0,0 +1,703 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Course,
CourseCategory,
Enrollment,
EnrollmentStatus,
AcademyStatistics,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
import { fetchSDKAcademyList } from '@/lib/sdk/academy/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) {
const categoryInfo = COURSE_CATEGORY_INFO[course.category]
return (
<Link href={`/sdk/academy/${course.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
border-gray-200 hover:border-purple-300
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
</div>
{/* Course Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{course.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{course.description}
</p>
{/* Course Meta */}
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{course.lessons.length} Lektionen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{course.durationMinutes} Min.
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{enrollmentCount} Teilnehmer
</span>
</div>
</div>
{/* Right Side - Roles */}
<div className="text-right ml-4 text-gray-500">
<div className="text-sm font-medium">
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
</div>
<div className="text-xs mt-0.5">
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Details
</span>
</div>
</div>
</div>
</Link>
)
}
function EnrollmentCard({ enrollment, courseName }: { enrollment: Enrollment; courseName: string }) {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div className={`
bg-white rounded-xl border-2 p-6
${overdue ? 'border-red-300' :
enrollment.status === 'completed' ? 'border-green-200' :
enrollment.status === 'in_progress' ? 'border-yellow-200' :
'border-gray-200'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Status Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{overdue && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ueberfaellig
</span>
)}
</div>
{/* User Info */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{enrollment.userName}
</h3>
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
{/* Progress Bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">Fortschritt</span>
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
enrollment.progress === 100 ? 'bg-green-500' :
overdue ? 'bg-red-500' :
'bg-purple-500'
}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
</div>
{/* Right Side - Deadline */}
<div className={`text-right ml-4 ${
overdue ? 'text-red-600' :
daysUntil <= 7 ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{enrollment.status === 'completed'
? 'Abgeschlossen'
: overdue
? `${Math.abs(daysUntil)} Tage ueberfaellig`
: `${daysUntil} Tage verbleibend`
}
</div>
<div className="text-xs mt-0.5">
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
</div>
{enrollment.completedAt && (
<div className="text-sm text-green-600">
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedCategory,
selectedStatus,
onCategoryChange,
onStatusChange,
onClear
}: {
selectedCategory: CourseCategory | 'all'
selectedStatus: EnrollmentStatus | 'all'
onCategoryChange: (category: CourseCategory | 'all') => void
onStatusChange: (status: EnrollmentStatus | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Enrollment Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AcademyPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [courses, setCourses] = useState<Course[]>([])
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<EnrollmentStatus | 'all'>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const data = await fetchSDKAcademyList()
setCourses(data.courses)
setEnrollments(data.enrollments)
setStatistics(data.statistics)
} catch (error) {
console.error('Failed to load Academy data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
courses: courses.length,
enrollments: enrollments.filter(e => e.status !== 'completed').length,
certificates: enrollments.filter(e => e.certificateId).length,
overdue: enrollments.filter(e => isEnrollmentOverdue(e)).length
}
}, [courses, enrollments])
// Filtered courses
const filteredCourses = useMemo(() => {
let filtered = [...courses]
if (selectedCategory !== 'all') {
filtered = filtered.filter(c => c.category === selectedCategory)
}
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [courses, selectedCategory])
// Filtered enrollments
const filteredEnrollments = useMemo(() => {
let filtered = [...enrollments]
if (selectedStatus !== 'all') {
filtered = filtered.filter(e => e.status === selectedStatus)
}
// Sort: overdue first, then by deadline
return filtered.sort((a, b) => {
const aOverdue = isEnrollmentOverdue(a) ? -1 : 0
const bOverdue = isEnrollmentOverdue(b) ? -1 : 0
if (aOverdue !== bOverdue) return aOverdue - bOverdue
return getDaysUntilDeadline(a.deadline) - getDaysUntilDeadline(b.deadline)
})
}, [enrollments, selectedStatus])
// Enrollment counts per course
const enrollmentCountByCourseId = useMemo(() => {
const counts: Record<string, number> = {}
enrollments.forEach(e => {
counts[e.courseId] = (counts[e.courseId] || 0) + 1
})
return counts
}, [enrollments])
// Course name lookup
const courseNameById = useMemo(() => {
const map: Record<string, string> = {}
courses.forEach(c => { map[c.id] = c.title })
return map
}, [courses])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'courses', label: 'Kurse', count: tabCounts.courses, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'enrollments', label: 'Einschreibungen', count: tabCounts.enrollments, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'certificates', label: 'Zertifikate', count: tabCounts.certificates, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['academy']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="academy"
title={stepInfo?.title || 'Compliance Academy'}
description={stepInfo?.description || 'E-Learning Plattform fuer Compliance-Schulungen'}
explanation={stepInfo?.explanation}
tips={stepInfo?.tips}
>
<Link
href="/sdk/academy/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Kurs erstellen
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : activeTab === 'certificates' ? (
/* Certificates Tab Placeholder */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Zertifikate</h3>
<p className="mt-2 text-gray-500">
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert.
Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein.
</p>
{tabCounts.certificates > 0 && (
<p className="mt-2 text-sm text-purple-600 font-medium">
{tabCounts.certificates} Zertifikat(e) vorhanden
</p>
)}
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Kurse gesamt"
value={statistics.totalCourses}
color="gray"
/>
<StatCard
label="Aktive Teilnehmer"
value={statistics.byStatus.in_progress + statistics.byStatus.not_started}
color="blue"
/>
<StatCard
label="Abschlussrate"
value={`${statistics.completionRate}%`}
color="green"
/>
<StatCard
label="Ueberfaellig"
value={statistics.overdueCount}
color={statistics.overdueCount > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert */}
{tabCounts.overdue > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: {tabCounts.overdue} ueberfaellige Schulung(en)
</h4>
<p className="text-sm text-red-600">
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
</p>
</div>
<button
onClick={() => {
setActiveTab('enrollments')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
<p className="text-sm text-blue-600 mt-1">
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
sollten mindestens jaehrlich aufgefrischt werden.
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
onCategoryChange={setSelectedCategory}
onStatusChange={setSelectedStatus}
onClear={clearFilters}
/>
{/* Courses Tab */}
{(activeTab === 'overview' || activeTab === 'courses') && (
<div className="space-y-4">
{activeTab === 'courses' && (
<h2 className="text-lg font-semibold text-gray-900">Kurse ({filteredCourses.length})</h2>
)}
{filteredCourses.map(course => (
<CourseCard
key={course.id}
course={course}
enrollmentCount={enrollmentCountByCourseId[course.id] || 0}
/>
))}
</div>
)}
{/* Enrollments Tab */}
{activeTab === 'enrollments' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Einschreibungen ({filteredEnrollments.length})</h2>
{filteredEnrollments.map(enrollment => (
<EnrollmentCard
key={enrollment.id}
enrollment={enrollment}
courseName={courseNameById[enrollment.courseId] || 'Unbekannter Kurs'}
/>
))}
</div>
)}
{/* Empty States */}
{activeTab === 'courses' && filteredCourses.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Kurse vorhanden.'
}
</p>
{selectedCategory !== 'all' ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/academy/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Kurs erstellen
</Link>
)}
</div>
)}
{activeTab === 'enrollments' && filteredEnrollments.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedStatus !== 'all'
? 'Passen Sie die Filter an.'
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
}
</p>
{selectedStatus !== 'all' && (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,706 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Incident,
IncidentSeverity,
IncidentStatus,
IncidentCategory,
IncidentStatistics,
INCIDENT_SEVERITY_INFO,
INCIDENT_STATUS_INFO,
INCIDENT_CATEGORY_INFO,
getHoursUntil72hDeadline,
is72hDeadlineExpired
} from '@/lib/sdk/incidents/types'
import { fetchSDKIncidentList, createMockIncidents, createMockStatistics } from '@/lib/sdk/incidents/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses: Record<string, string> = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600',
orange: 'border-orange-200 text-orange-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
{icon}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedSeverity,
selectedStatus,
selectedCategory,
onSeverityChange,
onStatusChange,
onCategoryChange,
onClear
}: {
selectedSeverity: IncidentSeverity | 'all'
selectedStatus: IncidentStatus | 'all'
selectedCategory: IncidentCategory | 'all'
onSeverityChange: (severity: IncidentSeverity | 'all') => void
onStatusChange: (status: IncidentStatus | 'all') => void
onCategoryChange: (category: IncidentCategory | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Severity Filter */}
<select
value={selectedSeverity}
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Schweregrade</option>
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
<option key={severity} value={severity}>{info.label}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
/**
* 72h-Countdown-Anzeige mit visueller Farbkodierung
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
*/
function CountdownTimer({ incident }: { incident: Incident }) {
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
const expired = is72hDeadlineExpired(incident.detectedAt)
// Nicht relevant fuer abgeschlossene Vorfaelle
if (incident.status === 'closed') return null
// Bereits gemeldet
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Gemeldet
</span>
)
}
// Keine Meldepflicht festgestellt
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
Keine Meldepflicht
</span>
)
}
// Abgelaufen
if (expired) {
const overdueHours = Math.abs(hoursRemaining)
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{overdueHours.toFixed(0)}h ueberfaellig
</span>
)
}
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
let colorClass: string
if (hoursRemaining > 48) {
colorClass = 'bg-green-100 text-green-700'
} else if (hoursRemaining > 24) {
colorClass = 'bg-yellow-100 text-yellow-700'
} else if (hoursRemaining > 12) {
colorClass = 'bg-orange-100 text-orange-700'
} else {
colorClass = 'bg-red-100 text-red-700'
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{hoursRemaining.toFixed(0)}h verbleibend
</span>
)
}
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
}
function IncidentCard({ incident }: { incident: Incident }) {
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
const expired = is72hDeadlineExpired(incident.detectedAt)
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
const severityBorderColors: Record<IncidentSeverity, string> = {
critical: 'border-red-300 hover:border-red-400',
high: 'border-orange-300 hover:border-orange-400',
medium: 'border-yellow-300 hover:border-yellow-400',
low: 'border-green-200 hover:border-green-300'
}
const borderColor = incident.status === 'closed'
? 'border-green-200 hover:border-green-300'
: expired && !isNotified
? 'border-red-400 hover:border-red-500'
: severityBorderColors[incident.severity]
const measuresCount = incident.measures.length
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
return (
<Link href={`/sdk/incidents/${incident.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${borderColor}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{incident.referenceNumber}
</span>
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{incident.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{incident.description}
</p>
{/* 72h Countdown - prominent */}
<div className="mt-3">
<CountdownTimer incident={incident} />
</div>
</div>
{/* Right Side - Key Numbers */}
<div className="text-right ml-4 flex-shrink-0">
<div className="text-sm text-gray-500">
Betroffene
</div>
<div className="text-xl font-bold text-gray-900">
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
</div>
<div className="text-xs text-gray-400 mt-1">
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{completedMeasures}/{measuresCount} Massnahmen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{incident.timeline.length} Eintraege
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
{incident.assignedTo
? `Zugewiesen: ${incident.assignedTo}`
: 'Nicht zugewiesen'
}
</span>
{incident.status !== 'closed' ? (
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
) : (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
</Link>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function IncidentsPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [incidents, setIncidents] = useState<Incident[]>([])
const [statistics, setStatistics] = useState<IncidentStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedSeverity, setSelectedSeverity] = useState<IncidentSeverity | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<IncidentStatus | 'all'>('all')
const [selectedCategory, setSelectedCategory] = useState<IncidentCategory | 'all'>('all')
// Load data
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { incidents: loadedIncidents, statistics: loadedStats } = await fetchSDKIncidentList()
setIncidents(loadedIncidents)
setStatistics(loadedStats)
} catch (error) {
console.error('Fehler beim Laden der Incident-Daten:', error)
// Fallback auf Mock-Daten
setIncidents(createMockIncidents())
setStatistics(createMockStatistics())
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
active: incidents.filter(i =>
i.status === 'detected' || i.status === 'assessment' ||
i.status === 'containment' || i.status === 'remediation'
).length,
notification: incidents.filter(i =>
i.status === 'notification_required' || i.status === 'notification_sent' ||
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
).length,
closed: incidents.filter(i => i.status === 'closed').length,
deadlineExpired: incidents.filter(i => {
if (i.status === 'closed') return false
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
if (i.riskAssessment && !i.riskAssessment.notificationRequired) return false
return is72hDeadlineExpired(i.detectedAt)
}).length,
deadlineApproaching: incidents.filter(i => {
if (i.status === 'closed') return false
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
const hours = getHoursUntil72hDeadline(i.detectedAt)
return hours > 0 && hours <= 24
}).length
}
}, [incidents])
// Filter incidents based on active tab and filters
const filteredIncidents = useMemo(() => {
let filtered = [...incidents]
// Tab-based filtering
if (activeTab === 'active') {
filtered = filtered.filter(i =>
i.status === 'detected' || i.status === 'assessment' ||
i.status === 'containment' || i.status === 'remediation'
)
} else if (activeTab === 'notification') {
filtered = filtered.filter(i =>
i.status === 'notification_required' || i.status === 'notification_sent' ||
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
)
} else if (activeTab === 'closed') {
filtered = filtered.filter(i => i.status === 'closed')
}
// Severity filter
if (selectedSeverity !== 'all') {
filtered = filtered.filter(i => i.severity === selectedSeverity)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(i => i.status === selectedStatus)
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(i => i.category === selectedCategory)
}
// Sort: most urgent first (overdue > deadline approaching > severity > detected time)
const severityOrder: Record<IncidentSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3 }
return filtered.sort((a, b) => {
// Closed always at the end
if (a.status === 'closed' !== (b.status === 'closed')) return a.status === 'closed' ? 1 : -1
// Overdue first
const aExpired = is72hDeadlineExpired(a.detectedAt)
const bExpired = is72hDeadlineExpired(b.detectedAt)
if (aExpired !== bExpired) return aExpired ? -1 : 1
// Then by severity
if (severityOrder[a.severity] !== severityOrder[b.severity]) {
return severityOrder[a.severity] - severityOrder[b.severity]
}
// Then by deadline urgency
return getHoursUntil72hDeadline(a.detectedAt) - getHoursUntil72hDeadline(b.detectedAt)
})
}, [incidents, activeTab, selectedSeverity, selectedStatus, selectedCategory])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'active', label: 'Aktiv', count: tabCounts.active, countColor: 'bg-orange-100 text-orange-600' },
{ id: 'notification', label: 'Meldepflichtig', count: tabCounts.notification, countColor: 'bg-red-100 text-red-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['incidents']
const clearFilters = () => {
setSelectedSeverity('all')
setSelectedStatus('all')
setSelectedCategory('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="incidents"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<Link
href="/sdk/incidents/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorfall melden
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Incident-Management-Einstellungen, Eskalationswege und Meldevorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt Vorfaelle"
value={statistics.totalIncidents}
color="gray"
/>
<StatCard
label="Offene Vorfaelle"
value={statistics.openIncidents}
color="orange"
/>
<StatCard
label="Meldungen ausstehend"
value={statistics.notificationsPending}
color={statistics.notificationsPending > 0 ? 'red' : 'green'}
/>
<StatCard
label="Durchschn. Reaktionszeit"
value={`${statistics.averageResponseTimeHours}h`}
color="purple"
/>
</div>
)}
{/* Critical Alert: 72h deadline approaching or expired */}
{(tabCounts.deadlineExpired > 0 || tabCounts.deadlineApproaching > 0) && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
{tabCounts.deadlineExpired > 0
? `Achtung: ${tabCounts.deadlineExpired} ueberfaellige Meldung(en) - 72-Stunden-Frist ueberschritten!`
: `Warnung: ${tabCounts.deadlineApproaching} Meldung(en) mit ablaufender 72-Stunden-Frist`
}
</h4>
<p className="text-sm text-red-600">
{tabCounts.deadlineExpired > 0
? 'Die gesetzliche Meldefrist nach Art. 33 DSGVO ist abgelaufen. Handeln Sie umgehend, um Bussgelder zu vermeiden. Verspaetete Meldungen muessen begruendet werden.'
: 'Die 72-Stunden-Meldefrist nach Art. 33 DSGVO laeuft in Kuerze ab. Fuehren Sie eine Risikobewertung durch und entscheiden Sie ueber die Meldepflicht.'
}
</p>
</div>
<button
onClick={() => {
setActiveTab('active')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">Art. 33/34 DSGVO - 72-Stunden-Meldepflicht</h4>
<p className="text-sm text-blue-600 mt-1">
Nach Art. 33 DSGVO muessen Datenschutzverletzungen innerhalb von 72 Stunden
an die zustaendige Aufsichtsbehoerde gemeldet werden, sofern ein Risiko fuer
die Rechte und Freiheiten der betroffenen Personen besteht. Bei hohem Risiko
muessen gemaess Art. 34 DSGVO auch die betroffenen Personen benachrichtigt werden.
Alle Vorfaelle sind unabhaengig von der Meldepflicht zu dokumentieren (Art. 33 Abs. 5).
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedSeverity={selectedSeverity}
selectedStatus={selectedStatus}
selectedCategory={selectedCategory}
onSeverityChange={setSelectedSeverity}
onStatusChange={setSelectedStatus}
onCategoryChange={setSelectedCategory}
onClear={clearFilters}
/>
{/* Incidents List */}
<div className="space-y-4">
{filteredIncidents.map(incident => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
{/* Empty State */}
{filteredIncidents.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Vorfaelle gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Vorfaelle erfasst worden.'
}
</p>
{(selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all') ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/incidents/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Vorfall erfassen
</Link>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,669 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
WhistleblowerReport,
WhistleblowerStatistics,
ReportCategory,
ReportStatus,
ReportPriority,
REPORT_CATEGORY_INFO,
REPORT_STATUS_INFO,
isAcknowledgmentOverdue,
isFeedbackOverdue,
getDaysUntilAcknowledgment,
getDaysUntilFeedback
} from '@/lib/sdk/whistleblower/types'
import { fetchSDKWhistleblowerList } from '@/lib/sdk/whistleblower/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'new_reports' | 'investigation' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedCategory,
selectedStatus,
selectedPriority,
onCategoryChange,
onStatusChange,
onPriorityChange,
onClear
}: {
selectedCategory: ReportCategory | 'all'
selectedStatus: ReportStatus | 'all'
selectedPriority: ReportPriority | 'all'
onCategoryChange: (category: ReportCategory | 'all') => void
onStatusChange: (status: ReportStatus | 'all') => void
onPriorityChange: (priority: ReportPriority | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as ReportCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(REPORT_CATEGORY_INFO).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as ReportStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(REPORT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Priority Filter */}
<select
value={selectedPriority}
onChange={(e) => onPriorityChange(e.target.value as ReportPriority | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Prioritaeten</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
function ReportCard({ report }: { report: WhistleblowerReport }) {
const categoryInfo = REPORT_CATEGORY_INFO[report.category]
const statusInfo = REPORT_STATUS_INFO[report.status]
const isClosed = report.status === 'closed' || report.status === 'rejected'
const ackOverdue = isAcknowledgmentOverdue(report)
const fbOverdue = isFeedbackOverdue(report)
const daysAck = getDaysUntilAcknowledgment(report)
const daysFb = getDaysUntilFeedback(report)
const completedMeasures = report.measures.filter(m => m.status === 'completed').length
const totalMeasures = report.measures.length
const priorityLabels: Record<ReportPriority, string> = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
critical: 'Kritisch'
}
return (
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${ackOverdue || fbOverdue ? 'border-red-300 hover:border-red-400' :
report.priority === 'critical' ? 'border-orange-300 hover:border-orange-400' :
isClosed ? 'border-green-200 hover:border-green-300' :
'border-gray-200 hover:border-purple-300'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{report.referenceNumber}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{report.isAnonymous && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Anonym
</span>
)}
{report.priority === 'critical' && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Kritisch
</span>
)}
{report.priority === 'high' && (
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full">
Hoch
</span>
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{report.title}
</h3>
{/* Description Preview */}
{report.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{report.description}
</p>
)}
{/* Deadline Info */}
{!isClosed && (
<div className="flex items-center gap-4 mt-3 text-xs">
{report.status === 'new' && (
<span className={`flex items-center gap-1 ${ackOverdue ? 'text-red-600 font-medium' : daysAck <= 2 ? 'text-orange-600' : 'text-gray-500'}`}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{ackOverdue
? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig`
: `Bestaetigung in ${daysAck} Tagen`
}
</span>
)}
<span className={`flex items-center gap-1 ${fbOverdue ? 'text-red-600 font-medium' : daysFb <= 14 ? 'text-orange-600' : 'text-gray-500'}`}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{fbOverdue
? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig`
: `Rueckmeldung in ${daysFb} Tagen`
}
</span>
</div>
)}
</div>
{/* Right Side - Date & Priority */}
<div className={`text-right ml-4 ${
ackOverdue || fbOverdue ? 'text-red-600' :
report.priority === 'critical' ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{isClosed
? statusInfo.label
: ackOverdue
? 'Ueberfaellig'
: priorityLabels[report.priority]
}
</div>
<div className="text-xs mt-0.5">
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-500">
{report.assignedTo
? `Zugewiesen: ${report.assignedTo}`
: 'Nicht zugewiesen'
}
</div>
{report.attachments.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''}
</span>
)}
{totalMeasures > 0 && (
<span className={`flex items-center gap-1 text-xs ${completedMeasures === totalMeasures ? 'text-green-600' : 'text-yellow-600'}`}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{completedMeasures}/{totalMeasures} Massnahmen
</span>
)}
{report.messages.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isClosed && (
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
)}
{isClosed && (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function WhistleblowerPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [reports, setReports] = useState<WhistleblowerReport[]>([])
const [statistics, setStatistics] = useState<WhistleblowerStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<ReportCategory | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<ReportStatus | 'all'>('all')
const [selectedPriority, setSelectedPriority] = useState<ReportPriority | 'all'>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { reports: wbReports, statistics: wbStats } = await fetchSDKWhistleblowerList()
setReports(wbReports)
setStatistics(wbStats)
} catch (error) {
console.error('Failed to load Whistleblower data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Locally computed overdue counts (always fresh)
const overdueCounts = useMemo(() => {
const overdueAck = reports.filter(r => isAcknowledgmentOverdue(r)).length
const overdueFb = reports.filter(r => isFeedbackOverdue(r)).length
return { overdueAck, overdueFb }
}, [reports])
// Calculate tab counts
const tabCounts = useMemo(() => {
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
return {
new_reports: reports.filter(r => r.status === 'new').length,
investigation: reports.filter(r => investigationStatuses.includes(r.status)).length,
closed: reports.filter(r => closedStatuses.includes(r.status)).length
}
}, [reports])
// Filter reports based on active tab and filters
const filteredReports = useMemo(() => {
let filtered = [...reports]
// Tab-based filtering
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
if (activeTab === 'new_reports') {
filtered = filtered.filter(r => r.status === 'new')
} else if (activeTab === 'investigation') {
filtered = filtered.filter(r => investigationStatuses.includes(r.status))
} else if (activeTab === 'closed') {
filtered = filtered.filter(r => closedStatuses.includes(r.status))
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(r => r.category === selectedCategory)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(r => r.status === selectedStatus)
}
// Priority filter
if (selectedPriority !== 'all') {
filtered = filtered.filter(r => r.priority === selectedPriority)
}
// Sort: overdue first, then by priority, then by date
return filtered.sort((a, b) => {
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
const getUrgency = (r: WhistleblowerReport) => {
if (closedStatuses.includes(r.status)) return 1000
const ackOd = isAcknowledgmentOverdue(r)
const fbOd = isFeedbackOverdue(r)
if (ackOd || fbOd) return -100
const priorityScore = { critical: 0, high: 1, normal: 2, low: 3 }
return priorityScore[r.priority] ?? 2
}
const urgencyDiff = getUrgency(a) - getUrgency(b)
if (urgencyDiff !== 0) return urgencyDiff
return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
})
}, [reports, activeTab, selectedCategory, selectedStatus, selectedPriority])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'new_reports', label: 'Neue Meldungen', count: tabCounts.new_reports, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'investigation', label: 'In Untersuchung', count: tabCounts.investigation, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['whistleblower']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
setSelectedPriority('all')
}
return (
<div className="space-y-6">
{/* Step Header - NO "create report" button (reports come from the public form) */}
<StepHeader
stepId="whistleblower"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
/>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung
und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt Meldungen"
value={statistics.totalReports}
color="gray"
/>
<StatCard
label="Neue Meldungen"
value={statistics.newReports}
color="blue"
/>
<StatCard
label="In Untersuchung"
value={statistics.underReview}
color="yellow"
/>
<StatCard
label="Ueberfaellige Bestaetigung"
value={overdueCounts.overdueAck}
color={overdueCounts.overdueAck > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */}
{(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: Gesetzliche Fristen ueberschritten
</h4>
<p className="text-sm text-red-600 mt-0.5">
{overdueCounts.overdueAck > 0 && (
<span>{overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1). </span>
)}
{overdueCounts.overdueFb > 0 && (
<span>{overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2). </span>
)}
Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden.
</p>
</div>
<button
onClick={() => {
if (overdueCounts.overdueAck > 0) {
setActiveTab('new_reports')
} else {
setActiveTab('investigation')
}
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box about HinSchG Deadlines (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">HinSchG-Fristen</h4>
<p className="text-sm text-blue-600 mt-1">
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
selectedPriority={selectedPriority}
onCategoryChange={setSelectedCategory}
onStatusChange={setSelectedStatus}
onPriorityChange={setSelectedPriority}
onClear={clearFilters}
/>
{/* Report List */}
<div className="space-y-4">
{filteredReports.map(report => (
<ReportCard key={report.id} report={report} />
))}
</div>
{/* Empty State */}
{filteredReports.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Meldungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
? 'Passen Sie die Filter an oder setzen Sie sie zurueck.'
: 'Es sind noch keine Meldungen im Hinweisgebersystem vorhanden. Meldungen werden ueber das oeffentliche Meldeformular eingereicht.'
}
</p>
{(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
/**
* Academy API Proxy - Catch-all route
* Proxies all /api/sdk/v1/academy/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/academy`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF certificates)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Academy API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,137 @@
/**
* Incidents/Breach Management API Proxy - Catch-all route
* Proxies all /api/sdk/v1/incidents/* requests to ai-compliance-sdk backend
* Supports PDF generation for authority notification forms
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/incidents`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (PDF authority forms, exports)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Incidents API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,136 @@
/**
* Vendor Compliance API Proxy - Catch-all route
* Proxies all /api/sdk/v1/vendors/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/vendors`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF exports)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Vendor Compliance API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,147 @@
/**
* Whistleblower API Proxy - Catch-all route
* Proxies all /api/sdk/v1/whistleblower/* requests to ai-compliance-sdk backend
* Supports multipart/form-data for file uploads
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/whistleblower`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {}
const contentType = request.headers.get('content-type')
// Forward auth headers
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(60000), // 60s for file uploads
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (contentType?.includes('multipart/form-data')) {
// Forward multipart form data (file uploads)
const formData = await request.formData()
fetchOptions.body = formData
// Don't set Content-Type - let fetch set it with boundary
} else if (contentType?.includes('application/json')) {
headers['Content-Type'] = 'application/json'
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
} else {
headers['Content-Type'] = 'application/json'
}
} else {
headers['Content-Type'] = 'application/json'
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF exports, file downloads)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream') ||
responseContentType?.includes('image/')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Whistleblower API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -781,6 +781,87 @@ export const STEP_EXPLANATIONS = {
},
],
},
'incidents': {
title: 'Incident Management',
description: 'Erfassen, bewerten und melden Sie Datenschutzverletzungen nach Art. 33/34 DSGVO',
explanation: 'Das Incident Management ermoeglicht die strukturierte Erfassung und Bearbeitung von Datenschutzverletzungen. Es umfasst die Ersterfassung des Vorfalls, eine automatische Risikobewertung zur Bestimmung der Meldepflicht, einen 72-Stunden-Countdown fuer die Meldung an die Aufsichtsbehoerde, die Generierung des Meldeformulars sowie die Dokumentation aller Sofort- und Langfristmassnahmen.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Art. 33 DSGVO: Die Aufsichtsbehoerde muss innerhalb von 72 Stunden nach Bekanntwerden einer meldepflichtigen Datenpanne informiert werden.',
},
{
icon: 'info' as const,
title: 'Risikobewertung',
description: 'Nicht jede Datenpanne ist meldepflichtig. Die Risikobewertung hilft automatisch zu bestimmen, ob eine Meldung an die Aufsichtsbehoerde oder Betroffene erforderlich ist.',
},
{
icon: 'lightbulb' as const,
title: 'Massnahmen dokumentieren',
description: 'Dokumentieren Sie sowohl Sofortmassnahmen (Eindaemmung) als auch langfristige Massnahmen (Praevention). Dies ist fuer Audits essentiell.',
},
{
icon: 'success' as const,
title: 'Lessons Learned',
description: 'Schliessen Sie jeden Vorfall mit einer Ursachenanalyse und Lessons Learned ab, um kuenftige Vorfaelle zu vermeiden.',
},
],
},
'whistleblower': {
title: 'Hinweisgebersystem',
description: 'Anonymes Meldesystem gemaess Hinweisgeberschutzgesetz (HinSchG)',
explanation: 'Das Hinweisgebersystem ermoeglicht anonyme Meldungen von Missstaenden gemaess dem Hinweisgeberschutzgesetz (HinSchG). Unternehmen ab 50 Mitarbeitern sind gesetzlich verpflichtet, einen internen Meldekanal bereitzustellen. Das System bietet ein oeffentliches Meldeformular (ohne Login), einen anonymen Rueckkanal ueber Zugangscodes, Fallmanagement fuer die Ombudsperson und revisionssichere Dokumentation.',
tips: [
{
icon: 'warning' as const,
title: 'Gesetzliche Pflicht',
description: 'Ab 50 Mitarbeitern ist ein interner Meldekanal Pflicht (§ 12 HinSchG). Bussgeld bei Verstoessen: bis zu 50.000 EUR.',
},
{
icon: 'info' as const,
title: '7-Tage-Frist',
description: 'Eingangsbestaetigung muss innerhalb von 7 Tagen erfolgen. Rueckmeldung an den Hinweisgeber innerhalb von 3 Monaten.',
},
{
icon: 'lightbulb' as const,
title: 'Anonymitaet schuetzen',
description: 'Die Identitaet des Hinweisgebers darf nur mit dessen Einwilligung offengelegt werden. Das System unterstuetzt vollstaendig anonyme Meldungen.',
},
{
icon: 'success' as const,
title: 'Massnahmen-Tracking',
description: 'Dokumentieren Sie alle ergriffenen Massnahmen. Dies dient als Nachweis fuer die Aufsichtsbehoerde.',
},
],
},
'academy': {
title: 'Compliance Academy',
description: 'Schulen Sie Ihre Mitarbeiter in Datenschutz, IT-Sicherheit und KI-Kompetenz',
explanation: 'Die Compliance Academy bietet eine integrierte Schulungsplattform fuer Mitarbeiter-Compliance-Trainings. Sie umfasst vorgefertigte Kurse zu DSGVO-Grundlagen, IT-Sicherheit, AI Literacy und Hinweisgeberschutz. Mitarbeiter absolvieren Lektionen mit Videos und Texten, beantworten Quiz-Fragen und erhalten nach erfolgreichem Abschluss ein Zertifikat. Administratoren koennen den Fortschritt aller Mitarbeiter nachverfolgen und Erinnerungen fuer jaehrliche Auffrischungen einrichten.',
tips: [
{
icon: 'warning' as const,
title: 'DSGVO-Schulungspflicht',
description: 'Art. 39 Abs. 1 lit. b DSGVO: Der DSB muss die Sensibilisierung und Schulung der Mitarbeiter sicherstellen. Nachweisbare Schulungen sind Pflicht.',
},
{
icon: 'info' as const,
title: 'Jaehrliche Auffrischung',
description: 'Compliance-Schulungen sollten mindestens jaehrlich wiederholt werden. Das System erinnert automatisch an faellige Auffrischungen.',
},
{
icon: 'lightbulb' as const,
title: 'Zertifikate als Nachweis',
description: 'Jeder abgeschlossene Kurs generiert ein PDF-Zertifikat. Dies dient als Audit-Nachweis fuer die Schulungspflicht.',
},
{
icon: 'success' as const,
title: 'Quiz-Pflicht',
description: 'Nach jeder Lektion muss ein Quiz bestanden werden. So wird sichergestellt, dass die Inhalte verstanden wurden.',
},
],
},
}
export default StepHeader

125
admin-v2/deploy-and-ingest.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/bin/bash
# ============================================================
# RAG DACH Vollabdeckung — Deploy & Ingest Script
# Laeuft auf dem Mac Mini im Hintergrund (nohup)
# ============================================================
set -e
LOG_FILE="/Users/benjaminadmin/Projekte/breakpilot-pwa/ingest-$(date +%Y%m%d-%H%M%S).log"
PROJ="/Users/benjaminadmin/Projekte/breakpilot-pwa"
DOCKER="/usr/local/bin/docker"
COMPOSE="$DOCKER compose -f $PROJ/docker-compose.yml"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "============================================================"
echo "RAG DACH Deploy & Ingest — Start: $(date)"
echo "Logfile: $LOG_FILE"
echo "============================================================"
# Phase 1: Check prerequisites
echo ""
echo "[1/6] Pruefe Docker-Services..."
$COMPOSE ps qdrant embedding-service klausur-service 2>/dev/null || true
# Phase 2: Restart klausur-service to pick up new code
echo ""
echo "[2/6] Rebuilding klausur-service..."
cd "$PROJ"
$COMPOSE build --no-cache klausur-service
echo "Build fertig."
echo ""
echo "[3/6] Restarting klausur-service..."
$COMPOSE up -d klausur-service
echo "Warte 15 Sekunden auf Service-Start..."
sleep 15
# Check if klausur-service is healthy
echo "Pruefe klausur-service Health..."
for i in 1 2 3 4 5; do
if curl -sf http://127.0.0.1:8086/health > /dev/null 2>&1; then
echo "klausur-service ist bereit."
break
fi
echo "Warte auf klausur-service... ($i/5)"
sleep 10
done
# Phase 3: Run ingestion for new DACH laws only (not all — that would re-ingest existing ones)
echo ""
echo "[4/6] Starte Ingestion der neuen DACH-Gesetze (P1 zuerst)..."
# P1 — Deutschland
echo ""
echo "--- Deutschland P1 ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
DE_DDG DE_BGB_AGB DE_EGBGB DE_UWG DE_HGB_RET DE_AO_RET DE_TKG 2>&1 || echo "DE P1 hatte Fehler (non-fatal)"
# P1 — Oesterreich
echo ""
echo "--- Oesterreich P1 ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
AT_ECG AT_TKG AT_KSCHG AT_FAGG AT_UGB_RET AT_BAO_RET AT_MEDIENG 2>&1 || echo "AT P1 hatte Fehler (non-fatal)"
# P1 — Schweiz
echo ""
echo "--- Schweiz P1 ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
CH_DSV CH_OR_AGB CH_UWG CH_FMG 2>&1 || echo "CH P1 hatte Fehler (non-fatal)"
# 3 fehlgeschlagene Quellen nachholen
echo ""
echo "--- 3 fehlgeschlagene Quellen ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
LU_DPA_LAW DK_DATABESKYTTELSESLOVEN EDPB_GUIDELINES_1_2022 2>&1 || echo "Fix-3 hatte Fehler (non-fatal)"
echo ""
echo "[5/6] Starte Ingestion P2 + P3..."
# P2 — Deutschland
echo ""
echo "--- Deutschland P2 ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
DE_PANGV DE_DLINFOV DE_BETRVG 2>&1 || echo "DE P2 hatte Fehler (non-fatal)"
# P2 — Oesterreich
echo ""
echo "--- Oesterreich P2 ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
AT_ABGB_AGB AT_UWG 2>&1 || echo "AT P2 hatte Fehler (non-fatal)"
# P2 — Schweiz
echo ""
echo "--- Schweiz P2 ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
CH_GEBUV CH_ZERTES 2>&1 || echo "CH P2 hatte Fehler (non-fatal)"
# P3
echo ""
echo "--- P3 (DE + CH) ---"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
DE_GESCHGEHG DE_BSIG DE_USTG_RET CH_ZGB_PERS 2>&1 || echo "P3 hatte Fehler (non-fatal)"
# Phase 4: Rebuild admin-v2 frontend
echo ""
echo "[6/6] Rebuilding admin-v2 Frontend..."
$COMPOSE build --no-cache admin-v2
$COMPOSE up -d admin-v2
echo "admin-v2 neu gestartet."
# Phase 5: Status check
echo ""
echo "============================================================"
echo "FINAL STATUS CHECK"
echo "============================================================"
echo ""
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --status 2>&1 || echo "Status-Check fehlgeschlagen"
echo ""
echo "============================================================"
echo "Fertig: $(date)"
echo "Logfile: $LOG_FILE"
echo "============================================================"

View File

@@ -0,0 +1,135 @@
# BreakPilot Content Service Stack
# Usage: docker-compose -f docker-compose.yml -f docker-compose.content.yml up -d
services:
# MinIO Object Storage (S3-compatible)
minio:
image: minio/minio:latest
container_name: breakpilot-pwa-minio
ports:
- "9000:9000" # API
- "9001:9001" # Console
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
networks:
- breakpilot-pwa-network
restart: unless-stopped
# Content Service Database (separate from main DB)
content-db:
image: postgres:16-alpine
container_name: breakpilot-pwa-content-db
ports:
- "5433:5432"
environment:
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot123
POSTGRES_DB: breakpilot_content
volumes:
- content_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_content"]
interval: 5s
timeout: 5s
retries: 5
networks:
- breakpilot-pwa-network
restart: unless-stopped
# Content Service API
content-service:
build:
context: ./backend/content_service
dockerfile: Dockerfile
container_name: breakpilot-pwa-content-service
ports:
- "8002:8002"
environment:
- CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@content-db:5432/breakpilot_content
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin123
- MINIO_SECURE=false
- MINIO_BUCKET=breakpilot-content
- CONSENT_SERVICE_URL=http://consent-service:8081
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-http://synapse:8008}
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN:-}
depends_on:
content-db:
condition: service_healthy
minio:
condition: service_healthy
networks:
- breakpilot-pwa-network
restart: unless-stopped
# H5P Interactive Content Service
h5p-service:
build:
context: ./h5p-service
dockerfile: Dockerfile
container_name: breakpilot-pwa-h5p
ports:
- "8003:8080"
environment:
- H5P_STORAGE_PATH=/h5p-content
- CONTENT_SERVICE_URL=http://content-service:8002
volumes:
- h5p_content:/h5p-content
networks:
- breakpilot-pwa-network
restart: unless-stopped
# AI Content Generator Service
ai-content-generator:
build:
context: ./ai-content-generator
dockerfile: Dockerfile
container_name: breakpilot-pwa-ai-generator
ports:
- "8004:8004"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- YOUTUBE_API_KEY=${YOUTUBE_API_KEY:-}
- H5P_SERVICE_URL=http://h5p-service:8080
- CONTENT_SERVICE_URL=http://content-service:8002
- SERVICE_HOST=0.0.0.0
- SERVICE_PORT=8004
- MAX_UPLOAD_SIZE=10485760
- MAX_CONCURRENT_JOBS=5
- JOB_TIMEOUT=300
volumes:
- ai_generator_temp:/app/temp
- ai_generator_uploads:/app/uploads
depends_on:
- h5p-service
- content-service
networks:
- breakpilot-pwa-network
restart: unless-stopped
volumes:
minio_data:
driver: local
content_db_data:
driver: local
h5p_content:
driver: local
ai_generator_temp:
driver: local
ai_generator_uploads:
driver: local
networks:
breakpilot-pwa-network:
external: true

View File

@@ -0,0 +1,28 @@
# Development-specific overrides
# Use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
# Mount source code for hot-reload
- ./backend:/app
# Don't override the venv
- /app/venv
environment:
- DEBUG=true
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
consent-service:
# For development, you might want to use the local binary instead
# Uncomment below to mount source and rebuild on changes
# volumes:
# - ./consent-service:/app
environment:
- GIN_MODE=debug
postgres:
ports:
- "5432:5432" # Expose for local tools

View File

@@ -0,0 +1,108 @@
# ============================================
# BreakPilot PWA - Development Overrides
# ============================================
# This file is AUTOMATICALLY loaded with: docker compose up
# No need to specify -f flag for development!
#
# For staging: docker compose -f docker-compose.yml -f docker-compose.staging.yml up
# ============================================
services:
# ==========================================
# Python Backend (FastAPI)
# ==========================================
backend:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
# Mount source code for hot-reload
- ./backend:/app
# Don't override the venv
- /app/venv
environment:
- DEBUG=true
- ENVIRONMENT=development
- LOG_LEVEL=debug
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# ==========================================
# Go Consent Service
# ==========================================
consent-service:
environment:
- GIN_MODE=debug
- ENVIRONMENT=development
- LOG_LEVEL=debug
# ==========================================
# Go School Service
# ==========================================
school-service:
environment:
- GIN_MODE=debug
- ENVIRONMENT=development
# ==========================================
# Go Billing Service
# ==========================================
billing-service:
environment:
- GIN_MODE=debug
- ENVIRONMENT=development
# ==========================================
# Klausur Service (Python + React)
# ==========================================
klausur-service:
environment:
- DEBUG=true
- ENVIRONMENT=development
# ==========================================
# Website (Next.js)
# ==========================================
website:
environment:
- NODE_ENV=development
# ==========================================
# PostgreSQL
# ==========================================
postgres:
ports:
- "5432:5432" # Expose for local DB tools
environment:
- POSTGRES_DB=${POSTGRES_DB:-breakpilot_dev}
# ==========================================
# MinIO (Object Storage)
# ==========================================
minio:
ports:
- "9000:9000"
- "9001:9001" # Console
# ==========================================
# Qdrant (Vector DB)
# ==========================================
qdrant:
ports:
- "6333:6333"
- "6334:6334"
# ==========================================
# Mailpit (Email Testing)
# ==========================================
mailpit:
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP
# ==========================================
# DSMS Gateway
# ==========================================
dsms-gateway:
environment:
- DEBUG=true
- ENVIRONMENT=development

View File

@@ -0,0 +1,133 @@
# ============================================
# BreakPilot PWA - Staging Overrides
# ============================================
# Usage: docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
#
# Or use the helper script:
# ./scripts/start.sh staging
# ============================================
services:
# ==========================================
# Python Backend (FastAPI)
# ==========================================
backend:
environment:
- DEBUG=false
- ENVIRONMENT=staging
- LOG_LEVEL=info
restart: unless-stopped
# No hot-reload in staging
command: uvicorn main:app --host 0.0.0.0 --port 8000
# ==========================================
# Go Consent Service
# ==========================================
consent-service:
environment:
- GIN_MODE=release
- ENVIRONMENT=staging
- LOG_LEVEL=info
restart: unless-stopped
# ==========================================
# Go School Service
# ==========================================
school-service:
environment:
- GIN_MODE=release
- ENVIRONMENT=staging
restart: unless-stopped
# ==========================================
# Go Billing Service
# ==========================================
billing-service:
environment:
- GIN_MODE=release
- ENVIRONMENT=staging
restart: unless-stopped
# ==========================================
# Klausur Service (Python + React)
# ==========================================
klausur-service:
environment:
- DEBUG=false
- ENVIRONMENT=staging
restart: unless-stopped
# ==========================================
# Website (Next.js)
# ==========================================
website:
environment:
- NODE_ENV=production
restart: unless-stopped
# ==========================================
# PostgreSQL (Separate Database for Staging)
# ==========================================
postgres:
ports:
- "5433:5432" # Different port for staging!
environment:
- POSTGRES_DB=${POSTGRES_DB:-breakpilot_staging}
volumes:
- breakpilot_staging_postgres:/var/lib/postgresql/data
# ==========================================
# MinIO (Object Storage - Different Ports)
# ==========================================
minio:
ports:
- "9002:9000"
- "9003:9001"
volumes:
- breakpilot_staging_minio:/data
# ==========================================
# Qdrant (Vector DB - Different Ports)
# ==========================================
qdrant:
ports:
- "6335:6333"
- "6336:6334"
volumes:
- breakpilot_staging_qdrant:/qdrant/storage
# ==========================================
# Mailpit (Still using Mailpit for Safety)
# ==========================================
mailpit:
ports:
- "8026:8025" # Different Web UI port
- "1026:1025" # Different SMTP port
# ==========================================
# DSMS Gateway
# ==========================================
dsms-gateway:
environment:
- DEBUG=false
- ENVIRONMENT=staging
restart: unless-stopped
# ==========================================
# Enable Backup Service in Staging
# ==========================================
backup:
profiles: [] # Remove profile restriction = always start
environment:
- PGDATABASE=breakpilot_staging
# ==========================================
# Separate Volumes for Staging
# ==========================================
volumes:
breakpilot_staging_postgres:
name: breakpilot_staging_postgres
breakpilot_staging_minio:
name: breakpilot_staging_minio
breakpilot_staging_qdrant:
name: breakpilot_staging_qdrant

View File

@@ -0,0 +1,153 @@
# BreakPilot PWA - Test-Infrastruktur
#
# Vollstaendige Integration-Test Umgebung fuer CI/CD Pipeline.
# Startet alle Services isoliert fuer Integration-Tests.
#
# Verwendung:
# docker compose -f docker-compose.test.yml up -d
# docker compose -f docker-compose.test.yml down -v
#
# Verbindungen:
# PostgreSQL: localhost:55432 (breakpilot_test/breakpilot/breakpilot)
# Valkey/Redis: localhost:56379
# Consent Service: localhost:58081
# Backend: localhost:58000
# Mailpit Web: localhost:58025
# Mailpit SMTP: localhost:51025
version: "3.9"
services:
# ========================================
# Datenbank-Services
# ========================================
postgres-test:
image: postgres:16-alpine
container_name: breakpilot-postgres-test
environment:
POSTGRES_DB: breakpilot_test
POSTGRES_USER: breakpilot
POSTGRES_PASSWORD: breakpilot_test
ports:
- "55432:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_test"]
interval: 5s
timeout: 5s
retries: 5
networks:
- breakpilot-test-network
restart: unless-stopped
valkey-test:
image: valkey/valkey:7-alpine
container_name: breakpilot-valkey-test
ports:
- "56379:6379"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- breakpilot-test-network
restart: unless-stopped
# ========================================
# Application Services
# ========================================
# Consent Service (Go)
consent-service-test:
build:
context: ./consent-service
dockerfile: Dockerfile
container_name: breakpilot-consent-service-test
ports:
- "58081:8081"
depends_on:
postgres-test:
condition: service_healthy
valkey-test:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8081/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
environment:
- DATABASE_URL=postgres://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test
- VALKEY_URL=redis://valkey-test:6379
- REDIS_URL=redis://valkey-test:6379
- JWT_SECRET=test-jwt-secret-for-integration-tests
- ENVIRONMENT=test
- LOG_LEVEL=debug
networks:
- breakpilot-test-network
restart: unless-stopped
# Backend (Python FastAPI)
backend-test:
build:
context: ./backend
dockerfile: Dockerfile
container_name: breakpilot-backend-test
ports:
- "58000:8000"
depends_on:
postgres-test:
condition: service_healthy
valkey-test:
condition: service_healthy
consent-service-test:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 45s
environment:
- DATABASE_URL=postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test
- CONSENT_SERVICE_URL=http://consent-service-test:8081
- VALKEY_URL=redis://valkey-test:6379
- REDIS_URL=redis://valkey-test:6379
- JWT_SECRET=test-jwt-secret-for-integration-tests
- ENVIRONMENT=test
- SMTP_HOST=mailpit-test
- SMTP_PORT=1025
- SKIP_INTEGRATION_TESTS=false
networks:
- breakpilot-test-network
restart: unless-stopped
# ========================================
# Development/Testing Tools
# ========================================
# Mailpit (E-Mail Testing)
mailpit-test:
image: axllent/mailpit:latest
container_name: breakpilot-mailpit-test
ports:
- "58025:8025" # Web UI
- "51025:1025" # SMTP
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/api/v1/info"]
interval: 10s
timeout: 5s
retries: 5
networks:
- breakpilot-test-network
restart: unless-stopped
networks:
breakpilot-test-network:
driver: bridge
volumes:
postgres_test_data:

View File

@@ -0,0 +1,98 @@
# HashiCorp Vault Configuration for BreakPilot
#
# Usage:
# Development mode (unsealed, no auth required):
# docker-compose -f docker-compose.vault.yml up -d vault
#
# Production mode:
# docker-compose -f docker-compose.vault.yml --profile production up -d
#
# After starting Vault in dev mode:
# export VAULT_ADDR=http://localhost:8200
# export VAULT_TOKEN=breakpilot-dev-token
#
# License: HashiCorp Vault is BSL 1.1 (open source for non-commercial use)
# Vault clients (hvac) are Apache-2.0
services:
# HashiCorp Vault - Secrets Management
vault:
image: hashicorp/vault:1.15
container_name: breakpilot-pwa-vault
ports:
- "8200:8200"
environment:
# Development mode settings
VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_DEV_TOKEN:-breakpilot-dev-token}
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
VAULT_ADDR: "http://127.0.0.1:8200"
VAULT_API_ADDR: "http://0.0.0.0:8200"
cap_add:
- IPC_LOCK # Required for mlock
volumes:
- vault_data:/vault/data
- vault_logs:/vault/logs
- ./vault/config:/vault/config:ro
- ./vault/policies:/vault/policies:ro
command: server -dev -dev-root-token-id=${VAULT_DEV_TOKEN:-breakpilot-dev-token}
healthcheck:
test: ["CMD", "vault", "status"]
interval: 10s
timeout: 5s
retries: 3
networks:
- breakpilot-pwa-network
restart: unless-stopped
# Vault Agent for automatic secret injection (production)
vault-agent:
image: hashicorp/vault:1.15
container_name: breakpilot-pwa-vault-agent
profiles:
- production
depends_on:
vault:
condition: service_healthy
environment:
VAULT_ADDR: "http://vault:8200"
volumes:
- ./vault/agent-config.hcl:/vault/config/agent-config.hcl:ro
- vault_agent_secrets:/vault/secrets
command: agent -config=/vault/config/agent-config.hcl
networks:
- breakpilot-pwa-network
restart: unless-stopped
# Vault initializer - Seeds secrets in development
vault-init:
image: hashicorp/vault:1.15
container_name: breakpilot-pwa-vault-init
depends_on:
vault:
condition: service_healthy
environment:
VAULT_ADDR: "http://vault:8200"
VAULT_TOKEN: ${VAULT_DEV_TOKEN:-breakpilot-dev-token}
volumes:
- ./vault/init-secrets.sh:/vault/init-secrets.sh:ro
entrypoint: ["/bin/sh", "-c"]
command:
- |
sleep 5
chmod +x /vault/init-secrets.sh
/vault/init-secrets.sh
echo "Vault initialized with development secrets"
networks:
- breakpilot-pwa-network
volumes:
vault_data:
name: breakpilot_vault_data
vault_logs:
name: breakpilot_vault_logs
vault_agent_secrets:
name: breakpilot_vault_agent_secrets
networks:
breakpilot-pwa-network:
external: true

1832
admin-v2/docker-compose.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,576 @@
/**
* Academy API Client
*
* API client for the Compliance E-Learning Academy module
* Connects to the ai-compliance-sdk backend via Next.js proxy
*/
import {
Course,
CourseCategory,
CourseCreateRequest,
CourseUpdateRequest,
Enrollment,
EnrollmentStatus,
EnrollmentListResponse,
EnrollUserRequest,
UpdateProgressRequest,
Certificate,
AcademyStatistics,
SubmitQuizRequest,
SubmitQuizResponse,
isEnrollmentOverdue
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const ACADEMY_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = localStorage.getItem('bp_user_id')
if (userId) {
headers['X-User-ID'] = userId
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// COURSE CRUD
// =============================================================================
/**
* Alle Kurse abrufen
*/
export async function fetchCourses(): Promise<Course[]> {
return fetchWithTimeout<Course[]>(
`${ACADEMY_API_BASE}/api/v1/academy/courses`
)
}
/**
* Einzelnen Kurs abrufen
*/
export async function fetchCourse(id: string): Promise<Course> {
return fetchWithTimeout<Course>(
`${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`
)
}
/**
* Neuen Kurs erstellen
*/
export async function createCourse(request: CourseCreateRequest): Promise<Course> {
return fetchWithTimeout<Course>(
`${ACADEMY_API_BASE}/api/v1/academy/courses`,
{
method: 'POST',
body: JSON.stringify(request)
}
)
}
/**
* Kurs aktualisieren
*/
export async function updateCourse(id: string, update: CourseUpdateRequest): Promise<Course> {
return fetchWithTimeout<Course>(
`${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Kurs loeschen
*/
export async function deleteCourse(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`,
{
method: 'DELETE'
}
)
}
// =============================================================================
// ENROLLMENTS
// =============================================================================
/**
* Einschreibungen abrufen (optional gefiltert nach Kurs-ID)
*/
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
const params = new URLSearchParams()
if (courseId) {
params.set('courseId', courseId)
}
const queryString = params.toString()
const url = `${ACADEMY_API_BASE}/api/v1/academy/enrollments${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<Enrollment[]>(url)
}
/**
* Benutzer in einen Kurs einschreiben
*/
export async function enrollUser(request: EnrollUserRequest): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments`,
{
method: 'POST',
body: JSON.stringify(request)
}
)
}
/**
* Fortschritt einer Einschreibung aktualisieren
*/
export async function updateProgress(enrollmentId: string, update: UpdateProgressRequest): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/progress`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Einschreibung als abgeschlossen markieren
*/
export async function completeEnrollment(enrollmentId: string): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/complete`,
{
method: 'POST'
}
)
}
// =============================================================================
// CERTIFICATES
// =============================================================================
/**
* Zertifikat abrufen
*/
export async function fetchCertificate(id: string): Promise<Certificate> {
return fetchWithTimeout<Certificate>(
`${ACADEMY_API_BASE}/api/v1/academy/certificates/${id}`
)
}
/**
* Zertifikat generieren nach erfolgreichem Kursabschluss
*/
export async function generateCertificate(enrollmentId: string): Promise<Certificate> {
return fetchWithTimeout<Certificate>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/certificate`,
{
method: 'POST'
}
)
}
// =============================================================================
// QUIZ
// =============================================================================
/**
* Quiz-Antworten einreichen und auswerten
*/
export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest): Promise<SubmitQuizResponse> {
return fetchWithTimeout<SubmitQuizResponse>(
`${ACADEMY_API_BASE}/api/v1/academy/lessons/${lessonId}/quiz`,
{
method: 'POST',
body: JSON.stringify(answers)
}
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Academy-Statistiken abrufen
*/
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
return fetchWithTimeout<AcademyStatistics>(
`${ACADEMY_API_BASE}/api/v1/academy/statistics`
)
}
// =============================================================================
// SDK PROXY FUNCTION (wraps fetchCourses + fetchAcademyStatistics)
// =============================================================================
/**
* Kurse und Statistiken laden - mit Fallback auf Mock-Daten
*/
export async function fetchSDKAcademyList(): Promise<{
courses: Course[]
enrollments: Enrollment[]
statistics: AcademyStatistics
}> {
try {
const [courses, enrollments, statistics] = await Promise.all([
fetchCourses(),
fetchEnrollments(),
fetchAcademyStatistics()
])
return { courses, enrollments, statistics }
} catch (error) {
console.error('Failed to load Academy data from backend, using mock data:', error)
// Fallback to mock data
const courses = createMockCourses()
const enrollments = createMockEnrollments()
const statistics = createMockStatistics(courses, enrollments)
return { courses, enrollments, statistics }
}
}
// =============================================================================
// MOCK DATA (Fallback / Demo)
// =============================================================================
/**
* Demo-Kurse mit deutschen Titeln erstellen
*/
export function createMockCourses(): Course[] {
const now = new Date()
return [
{
id: 'course-001',
title: 'DSGVO-Grundlagen fuer Mitarbeiter',
description: 'Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Dieses Pflichttraining vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten im Arbeitsalltag.',
category: 'dsgvo_basics',
durationMinutes: 90,
requiredForRoles: ['all'],
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
lessons: [
{
id: 'lesson-001-01',
courseId: 'course-001',
order: 1,
title: 'Was ist die DSGVO?',
type: 'text',
contentMarkdown: '# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der Europaeischen Union...',
durationMinutes: 15
},
{
id: 'lesson-001-02',
courseId: 'course-001',
order: 2,
title: 'Die 7 Grundsaetze der DSGVO',
type: 'video',
contentMarkdown: 'Videoerklaerung der Grundsaetze: Rechtmaessigkeit, Zweckbindung, Datenminimierung, Richtigkeit, Speicherbegrenzung, Integritaet und Vertraulichkeit, Rechenschaftspflicht.',
durationMinutes: 20,
videoUrl: '/videos/dsgvo-grundsaetze.mp4'
},
{
id: 'lesson-001-03',
courseId: 'course-001',
order: 3,
title: 'Betroffenenrechte (Art. 15-21)',
type: 'text',
contentMarkdown: '# Betroffenenrechte\n\nUebersicht der Betroffenenrechte: Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenuebertragbarkeit, Widerspruch.',
durationMinutes: 20
},
{
id: 'lesson-001-04',
courseId: 'course-001',
order: 4,
title: 'Personenbezogene Daten im Arbeitsalltag',
type: 'video',
contentMarkdown: 'Praxisbeispiele fuer den korrekten Umgang mit personenbezogenen Daten am Arbeitsplatz.',
durationMinutes: 15,
videoUrl: '/videos/dsgvo-praxis.mp4'
},
{
id: 'lesson-001-05',
courseId: 'course-001',
order: 5,
title: 'Wissenstest: DSGVO-Grundlagen',
type: 'quiz',
contentMarkdown: 'Testen Sie Ihr Wissen zu den DSGVO-Grundlagen.',
durationMinutes: 20
}
]
},
{
id: 'course-002',
title: 'IT-Sicherheit & Cybersecurity Awareness',
description: 'Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern, Social Engineering und sicherer Kommunikation.',
category: 'it_security',
durationMinutes: 60,
requiredForRoles: ['all'],
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
lessons: [
{
id: 'lesson-002-01',
courseId: 'course-002',
order: 1,
title: 'Phishing erkennen und vermeiden',
type: 'video',
contentMarkdown: 'Wie erkennt man Phishing-E-Mails und was tut man im Verdachtsfall?',
durationMinutes: 15,
videoUrl: '/videos/phishing-awareness.mp4'
},
{
id: 'lesson-002-02',
courseId: 'course-002',
order: 2,
title: 'Sichere Passwoerter und MFA',
type: 'text',
contentMarkdown: '# Sichere Passwoerter\n\nRichtlinien fuer starke Passwoerter, Passwort-Manager und Multi-Faktor-Authentifizierung.',
durationMinutes: 15
},
{
id: 'lesson-002-03',
courseId: 'course-002',
order: 3,
title: 'Social Engineering und Manipulation',
type: 'text',
contentMarkdown: '# Social Engineering\n\nMethoden von Angreifern zur Manipulation von Mitarbeitern und Schutzmassnahmen.',
durationMinutes: 15
},
{
id: 'lesson-002-04',
courseId: 'course-002',
order: 4,
title: 'Wissenstest: IT-Sicherheit',
type: 'quiz',
contentMarkdown: 'Testen Sie Ihr Wissen zur IT-Sicherheit.',
durationMinutes: 15
}
]
},
{
id: 'course-003',
title: 'AI Literacy - Sicherer Umgang mit KI',
description: 'Grundlagen kuenstlicher Intelligenz, EU AI Act, verantwortungsvoller Einsatz von KI-Werkzeugen und Risiken bei der Nutzung von Large Language Models (LLMs) im Unternehmen.',
category: 'ai_literacy',
durationMinutes: 75,
requiredForRoles: ['admin', 'data_protection_officer'],
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
lessons: [
{
id: 'lesson-003-01',
courseId: 'course-003',
order: 1,
title: 'Was ist Kuenstliche Intelligenz?',
type: 'text',
contentMarkdown: '# Was ist KI?\n\nGrundlagen von Machine Learning, Deep Learning und Large Language Models in verstaendlicher Sprache.',
durationMinutes: 15
},
{
id: 'lesson-003-02',
courseId: 'course-003',
order: 2,
title: 'Der EU AI Act - Was bedeutet er fuer uns?',
type: 'video',
contentMarkdown: 'Ueberblick ueber den EU AI Act, Risikoklassen und Anforderungen fuer Unternehmen.',
durationMinutes: 20,
videoUrl: '/videos/eu-ai-act.mp4'
},
{
id: 'lesson-003-03',
courseId: 'course-003',
order: 3,
title: 'KI-Werkzeuge sicher nutzen',
type: 'text',
contentMarkdown: '# KI-Werkzeuge sicher nutzen\n\nRichtlinien fuer den Einsatz von ChatGPT, Copilot & Co.',
durationMinutes: 20
},
{
id: 'lesson-003-04',
courseId: 'course-003',
order: 4,
title: 'Wissenstest: AI Literacy',
type: 'quiz',
contentMarkdown: 'Testen Sie Ihr Wissen zum sicheren Umgang mit KI.',
durationMinutes: 20
}
]
}
]
}
/**
* Demo-Einschreibungen erstellen
*/
export function createMockEnrollments(): Enrollment[] {
const now = new Date()
return [
{
id: 'enr-001',
courseId: 'course-001',
userId: 'user-001',
userName: 'Maria Fischer',
userEmail: 'maria.fischer@example.de',
status: 'in_progress',
progress: 40,
startedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() + 20 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-002',
courseId: 'course-002',
userId: 'user-002',
userName: 'Stefan Mueller',
userEmail: 'stefan.mueller@example.de',
status: 'completed',
progress: 100,
startedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString(),
certificateId: 'cert-001',
deadline: new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-003',
courseId: 'course-001',
userId: 'user-003',
userName: 'Laura Schneider',
userEmail: 'laura.schneider@example.de',
status: 'not_started',
progress: 0,
startedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() + 28 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-004',
courseId: 'course-003',
userId: 'user-004',
userName: 'Thomas Wagner',
userEmail: 'thomas.wagner@example.de',
status: 'expired',
progress: 25,
startedAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-005',
courseId: 'course-002',
userId: 'user-005',
userName: 'Julia Becker',
userEmail: 'julia.becker@example.de',
status: 'in_progress',
progress: 50,
startedAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
}
]
}
/**
* Demo-Statistiken aus Kursen und Einschreibungen berechnen
*/
export function createMockStatistics(courses?: Course[], enrollments?: Enrollment[]): AcademyStatistics {
const c = courses || createMockCourses()
const e = enrollments || createMockEnrollments()
const completedCount = e.filter(en => en.status === 'completed').length
const completionRate = e.length > 0 ? Math.round((completedCount / e.length) * 100) : 0
const overdueCount = e.filter(en => isEnrollmentOverdue(en)).length
return {
totalCourses: c.length,
totalEnrollments: e.length,
completionRate,
overdueCount,
byCategory: {
dsgvo_basics: c.filter(co => co.category === 'dsgvo_basics').length,
it_security: c.filter(co => co.category === 'it_security').length,
ai_literacy: c.filter(co => co.category === 'ai_literacy').length,
whistleblower_protection: c.filter(co => co.category === 'whistleblower_protection').length,
custom: c.filter(co => co.category === 'custom').length,
},
byStatus: {
not_started: e.filter(en => en.status === 'not_started').length,
in_progress: e.filter(en => en.status === 'in_progress').length,
completed: e.filter(en => en.status === 'completed').length,
expired: e.filter(en => en.status === 'expired').length,
}
}
}

View File

@@ -0,0 +1,6 @@
/**
* Academy Module Exports
*/
export * from './types'
export * from './api'

View File

@@ -0,0 +1,285 @@
/**
* Academy (E-Learning / Compliance Academy) Types
*
* TypeScript definitions for the E-Learning Academy module
* Provides course management, enrollment tracking, and certificate generation
* for DSGVO, IT-Security, AI Literacy, and Whistleblower compliance training
*/
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================
export type CourseCategory =
| 'dsgvo_basics' // DSGVO-Grundlagen
| 'it_security' // IT-Sicherheit
| 'ai_literacy' // AI Literacy
| 'whistleblower_protection' // Hinweisgeberschutz
| 'custom' // Benutzerdefiniert
export type EnrollmentStatus =
| 'not_started' // Nicht gestartet
| 'in_progress' // In Bearbeitung
| 'completed' // Abgeschlossen
| 'expired' // Abgelaufen
export type LessonType = 'video' | 'text' | 'quiz'
// =============================================================================
// COURSE CATEGORY METADATA
// =============================================================================
export interface CourseCategoryInfo {
label: string
description: string
icon: string
color: string
bgColor: string
}
export const COURSE_CATEGORY_INFO: Record<CourseCategory, CourseCategoryInfo> = {
dsgvo_basics: {
label: 'DSGVO-Grundlagen',
description: 'Grundlagenwissen zur Datenschutz-Grundverordnung fuer alle Mitarbeiter',
icon: 'Shield',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
it_security: {
label: 'IT-Sicherheit',
description: 'Cybersecurity Awareness und sichere IT-Nutzung im Arbeitsalltag',
icon: 'Lock',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
ai_literacy: {
label: 'AI Literacy',
description: 'Sicherer und verantwortungsvoller Umgang mit kuenstlicher Intelligenz',
icon: 'Brain',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
whistleblower_protection: {
label: 'Hinweisgeberschutz',
description: 'Hinweisgeberschutzgesetz (HinSchG) und interne Meldestellen',
icon: 'Megaphone',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
custom: {
label: 'Benutzerdefiniert',
description: 'Individuell erstellte Schulungsinhalte und unternehmensspezifische Kurse',
icon: 'Pencil',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
}
}
// =============================================================================
// ENROLLMENT STATUS METADATA
// =============================================================================
export const ENROLLMENT_STATUS_INFO: Record<EnrollmentStatus, { label: string; color: string; bgColor: string; borderColor: string }> = {
not_started: {
label: 'Nicht gestartet',
color: 'text-gray-700',
bgColor: 'bg-gray-100',
borderColor: 'border-gray-200'
},
in_progress: {
label: 'In Bearbeitung',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100',
borderColor: 'border-yellow-200'
},
completed: {
label: 'Abgeschlossen',
color: 'text-green-700',
bgColor: 'bg-green-100',
borderColor: 'border-green-200'
},
expired: {
label: 'Abgelaufen',
color: 'text-red-700',
bgColor: 'bg-red-100',
borderColor: 'border-red-200'
}
}
// =============================================================================
// MAIN INTERFACES
// =============================================================================
export interface Course {
id: string
title: string
description: string
category: CourseCategory
lessons: Lesson[]
durationMinutes: number
requiredForRoles: string[]
createdAt: string
updatedAt: string
}
export interface Lesson {
id: string
courseId: string
title: string
type: LessonType
contentMarkdown: string
videoUrl?: string
order: number
durationMinutes: number
}
export interface QuizQuestion {
id: string
lessonId: string
question: string
options: string[]
correctOptionIndex: number
explanation: string
}
export interface Enrollment {
id: string
courseId: string
userId: string
userName: string
userEmail: string
status: EnrollmentStatus
progress: number // 0-100
startedAt: string
completedAt?: string
certificateId?: string
deadline: string
}
export interface Certificate {
id: string
enrollmentId: string
courseId: string
userId: string
userName: string
courseName: string
issuedAt: string
validUntil: string
pdfUrl: string
}
// =============================================================================
// STATISTICS
// =============================================================================
export interface AcademyStatistics {
totalCourses: number
totalEnrollments: number
completionRate: number // 0-100
overdueCount: number
byCategory: Record<CourseCategory, number>
byStatus: Record<EnrollmentStatus, number>
}
// =============================================================================
// API TYPES (REQUEST / RESPONSE)
// =============================================================================
export interface CourseListResponse {
courses: Course[]
total: number
page: number
pageSize: number
}
export interface EnrollmentListResponse {
enrollments: Enrollment[]
total: number
page: number
pageSize: number
}
export interface CourseCreateRequest {
title: string
description: string
category: CourseCategory
durationMinutes: number
requiredForRoles?: string[]
}
export interface CourseUpdateRequest {
title?: string
description?: string
category?: CourseCategory
durationMinutes?: number
requiredForRoles?: string[]
}
export interface EnrollUserRequest {
courseId: string
userId: string
userName: string
userEmail: string
deadline: string
}
export interface UpdateProgressRequest {
progress: number
lessonId?: string
}
export interface SubmitQuizRequest {
answers: number[] // Index der ausgewaehlten Antwort pro Frage
}
export interface SubmitQuizResponse {
score: number
passed: boolean
correctAnswers: number
totalQuestions: number
results: { questionId: string; correct: boolean; explanation: string }[]
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Berechnet die Abschlussrate fuer eine Liste von Einschreibungen in Prozent (0-100)
*/
export function getCompletionPercentage(enrollments: Enrollment[]): number {
if (enrollments.length === 0) return 0
const completed = enrollments.filter(e => e.status === 'completed').length
return Math.round((completed / enrollments.length) * 100)
}
/**
* Prueft ob eine Einschreibung ueberfaellig ist (Deadline ueberschritten und nicht abgeschlossen)
*/
export function isEnrollmentOverdue(enrollment: Enrollment): boolean {
if (enrollment.status === 'completed' || enrollment.status === 'expired') {
return false
}
const deadlineDate = new Date(enrollment.deadline)
const now = new Date()
return deadlineDate.getTime() < now.getTime()
}
/**
* Berechnet die verbleibenden Tage bis zur Deadline
* Negative Werte bedeuten ueberfaellig
*/
export function getDaysUntilDeadline(deadline: string): number {
const deadlineDate = new Date(deadline)
const now = new Date()
const diff = deadlineDate.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
export function getCategoryInfo(category: CourseCategory): CourseCategoryInfo {
return COURSE_CATEGORY_INFO[category]
}
export function getStatusInfo(status: EnrollmentStatus) {
return ENROLLMENT_STATUS_INFO[status]
}

View File

@@ -0,0 +1,845 @@
/**
* Incident/Breach Management API Client
*
* API client for DSGVO Art. 33/34 Incident & Data Breach Management
* Connects via Next.js proxy to the ai-compliance-sdk backend
*/
import {
Incident,
IncidentListResponse,
IncidentFilters,
IncidentCreateRequest,
IncidentUpdateRequest,
IncidentStatistics,
IncidentMeasure,
TimelineEntry,
RiskAssessmentRequest,
RiskAssessment,
AuthorityNotification,
DataSubjectNotification,
IncidentSeverity,
IncidentStatus,
IncidentCategory,
calculateRiskLevel,
isNotificationRequired,
get72hDeadline
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const INCIDENTS_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = localStorage.getItem('bp_user_id')
if (userId) {
headers['X-User-ID'] = userId
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// INCIDENT LIST & CRUD
// =============================================================================
/**
* Alle Vorfaelle abrufen mit optionalen Filtern
*/
export async function fetchIncidents(filters?: IncidentFilters): Promise<IncidentListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.status) {
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
statuses.forEach(s => params.append('status', s))
}
if (filters.severity) {
const severities = Array.isArray(filters.severity) ? filters.severity : [filters.severity]
severities.forEach(s => params.append('severity', s))
}
if (filters.category) {
const categories = Array.isArray(filters.category) ? filters.category : [filters.category]
categories.forEach(c => params.append('category', c))
}
if (filters.assignedTo) params.set('assignedTo', filters.assignedTo)
if (filters.overdue !== undefined) params.set('overdue', String(filters.overdue))
if (filters.search) params.set('search', filters.search)
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom)
if (filters.dateTo) params.set('dateTo', filters.dateTo)
}
const queryString = params.toString()
const url = `${INCIDENTS_API_BASE}/api/v1/incidents${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<IncidentListResponse>(url)
}
/**
* Einzelnen Vorfall per ID abrufen
*/
export async function fetchIncident(id: string): Promise<Incident> {
return fetchWithTimeout<Incident>(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`)
}
/**
* Neuen Vorfall erstellen
*/
export async function createIncident(request: IncidentCreateRequest): Promise<Incident> {
return fetchWithTimeout<Incident>(`${INCIDENTS_API_BASE}/api/v1/incidents`, {
method: 'POST',
body: JSON.stringify(request)
})
}
/**
* Vorfall aktualisieren
*/
export async function updateIncident(id: string, update: IncidentUpdateRequest): Promise<Incident> {
return fetchWithTimeout<Incident>(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`, {
method: 'PUT',
body: JSON.stringify(update)
})
}
/**
* Vorfall loeschen (Soft Delete)
*/
export async function deleteIncident(id: string): Promise<void> {
await fetchWithTimeout<void>(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`, {
method: 'DELETE'
})
}
// =============================================================================
// RISK ASSESSMENT
// =============================================================================
/**
* Risikobewertung fuer einen Vorfall durchfuehren (Art. 33 DSGVO)
*/
export async function submitRiskAssessment(
incidentId: string,
assessment: RiskAssessmentRequest
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/risk-assessment`,
{
method: 'POST',
body: JSON.stringify(assessment)
}
)
}
// =============================================================================
// AUTHORITY NOTIFICATION (Art. 33 DSGVO)
// =============================================================================
/**
* Meldeformular fuer die Aufsichtsbehoerde generieren
*/
export async function generateAuthorityForm(incidentId: string): Promise<Blob> {
const response = await fetch(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/authority-form/pdf`,
{
headers: getAuthHeaders()
}
)
if (!response.ok) {
throw new Error(`PDF-Generierung fehlgeschlagen: ${response.statusText}`)
}
return response.blob()
}
/**
* Meldung an die Aufsichtsbehoerde einreichen (Art. 33 DSGVO)
*/
export async function submitAuthorityNotification(
incidentId: string,
data: Partial<AuthorityNotification>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/authority-notification`,
{
method: 'POST',
body: JSON.stringify(data)
}
)
}
// =============================================================================
// DATA SUBJECT NOTIFICATION (Art. 34 DSGVO)
// =============================================================================
/**
* Betroffene Personen benachrichtigen (Art. 34 DSGVO)
*/
export async function sendDataSubjectNotification(
incidentId: string,
data: Partial<DataSubjectNotification>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/data-subject-notification`,
{
method: 'POST',
body: JSON.stringify(data)
}
)
}
// =============================================================================
// MEASURES (Massnahmen)
// =============================================================================
/**
* Massnahme hinzufuegen (Sofort-, Korrektur- oder Praeventionsmassnahme)
*/
export async function addMeasure(
incidentId: string,
measure: Omit<IncidentMeasure, 'id' | 'incidentId'>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/measures`,
{
method: 'POST',
body: JSON.stringify(measure)
}
)
}
/**
* Massnahme aktualisieren
*/
export async function updateMeasure(
measureId: string,
update: Partial<IncidentMeasure>
): Promise<IncidentMeasure> {
return fetchWithTimeout<IncidentMeasure>(
`${INCIDENTS_API_BASE}/api/v1/measures/${measureId}`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Massnahme als abgeschlossen markieren
*/
export async function completeMeasure(measureId: string): Promise<IncidentMeasure> {
return fetchWithTimeout<IncidentMeasure>(
`${INCIDENTS_API_BASE}/api/v1/measures/${measureId}/complete`,
{
method: 'POST'
}
)
}
// =============================================================================
// TIMELINE
// =============================================================================
/**
* Zeitleisteneintrag hinzufuegen
*/
export async function addTimelineEntry(
incidentId: string,
entry: Omit<TimelineEntry, 'id' | 'incidentId'>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/timeline`,
{
method: 'POST',
body: JSON.stringify(entry)
}
)
}
// =============================================================================
// CLOSE INCIDENT
// =============================================================================
/**
* Vorfall abschliessen mit Lessons Learned
*/
export async function closeIncident(
incidentId: string,
lessonsLearned: string
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/close`,
{
method: 'POST',
body: JSON.stringify({ lessonsLearned })
}
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Vorfall-Statistiken abrufen
*/
export async function fetchIncidentStatistics(): Promise<IncidentStatistics> {
return fetchWithTimeout<IncidentStatistics>(
`${INCIDENTS_API_BASE}/api/v1/incidents/statistics`
)
}
// =============================================================================
// SDK PROXY FUNCTION (mit Fallback auf Mock-Daten)
// =============================================================================
/**
* Fetch Incident-Liste via SDK-Proxy mit Fallback auf Mock-Daten
*/
export async function fetchSDKIncidentList(): Promise<{ incidents: Incident[]; statistics: IncidentStatistics }> {
try {
const res = await fetch('/api/sdk/v1/incidents', {
headers: getAuthHeaders()
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
const incidents: Incident[] = data.incidents || []
// Statistiken lokal berechnen
const statistics = computeStatistics(incidents)
return { incidents, statistics }
} catch (error) {
console.warn('SDK-Backend nicht erreichbar, verwende Mock-Daten:', error)
const incidents = createMockIncidents()
const statistics = createMockStatistics()
return { incidents, statistics }
}
}
/**
* Statistiken lokal aus Incident-Liste berechnen
*/
function computeStatistics(incidents: Incident[]): IncidentStatistics {
const countBy = <K extends string>(items: { [key: string]: unknown }[], field: string): Record<K, number> => {
const result: Record<string, number> = {}
items.forEach(item => {
const key = String(item[field])
result[key] = (result[key] || 0) + 1
})
return result as Record<K, number>
}
const statusCounts = countBy<IncidentStatus>(incidents as unknown as { [key: string]: unknown }[], 'status')
const severityCounts = countBy<IncidentSeverity>(incidents as unknown as { [key: string]: unknown }[], 'severity')
const categoryCounts = countBy<IncidentCategory>(incidents as unknown as { [key: string]: unknown }[], 'category')
const openIncidents = incidents.filter(i => i.status !== 'closed').length
const notificationsPending = incidents.filter(i =>
i.authorityNotification !== null &&
i.authorityNotification.status === 'pending' &&
i.status !== 'closed'
).length
// Durchschnittliche Reaktionszeit berechnen
let totalResponseHours = 0
let respondedCount = 0
incidents.forEach(i => {
if (i.riskAssessment && i.riskAssessment.assessedAt) {
const detected = new Date(i.detectedAt).getTime()
const assessed = new Date(i.riskAssessment.assessedAt).getTime()
totalResponseHours += (assessed - detected) / (1000 * 60 * 60)
respondedCount++
}
})
return {
totalIncidents: incidents.length,
openIncidents,
notificationsPending,
averageResponseTimeHours: respondedCount > 0 ? Math.round(totalResponseHours / respondedCount * 10) / 10 : 0,
bySeverity: {
low: severityCounts['low'] || 0,
medium: severityCounts['medium'] || 0,
high: severityCounts['high'] || 0,
critical: severityCounts['critical'] || 0
},
byCategory: {
data_breach: categoryCounts['data_breach'] || 0,
unauthorized_access: categoryCounts['unauthorized_access'] || 0,
data_loss: categoryCounts['data_loss'] || 0,
system_compromise: categoryCounts['system_compromise'] || 0,
phishing: categoryCounts['phishing'] || 0,
ransomware: categoryCounts['ransomware'] || 0,
insider_threat: categoryCounts['insider_threat'] || 0,
physical_breach: categoryCounts['physical_breach'] || 0,
other: categoryCounts['other'] || 0
},
byStatus: {
detected: statusCounts['detected'] || 0,
assessment: statusCounts['assessment'] || 0,
containment: statusCounts['containment'] || 0,
notification_required: statusCounts['notification_required'] || 0,
notification_sent: statusCounts['notification_sent'] || 0,
remediation: statusCounts['remediation'] || 0,
closed: statusCounts['closed'] || 0
}
}
}
// =============================================================================
// MOCK DATA (Demo-Daten fuer Entwicklung und Tests)
// =============================================================================
/**
* Erstellt Demo-Vorfaelle fuer die Entwicklung
*/
export function createMockIncidents(): Incident[] {
const now = new Date()
return [
// 1. Gerade erkannt - noch nicht bewertet (detected/new)
{
id: 'inc-001',
referenceNumber: 'INC-2026-000001',
title: 'Unbefugter Zugriff auf Schuelerdatenbank',
description: 'Ein ehemaliger Mitarbeiter hat sich mit noch aktiven Zugangsdaten in die Schuelerdatenbank eingeloggt. Der Zugriff wurde durch die Log-Analyse entdeckt.',
category: 'unauthorized_access',
severity: 'high',
status: 'detected',
detectedAt: new Date(now.getTime() - 3 * 60 * 60 * 1000).toISOString(), // 3 Stunden her
detectedBy: 'Log-Analyse (automatisiert)',
affectedSystems: ['Schuelerdatenbank', 'Schulverwaltungssystem'],
affectedDataCategories: ['Personenbezogene Daten', 'Daten von Kindern', 'Gesundheitsdaten'],
estimatedAffectedPersons: 800,
riskAssessment: null,
authorityNotification: null,
dataSubjectNotification: null,
measures: [],
timeline: [
{
id: 'tl-001',
incidentId: 'inc-001',
timestamp: new Date(now.getTime() - 3 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall erkannt',
description: 'Automatische Log-Analyse meldet verdaechtigen Login eines deaktivierten Kontos',
performedBy: 'SIEM-System'
}
],
assignedTo: undefined
},
// 2. In Bewertung (assessment) - Risikobewertung laeuft
{
id: 'inc-002',
referenceNumber: 'INC-2026-000002',
title: 'E-Mail mit Kundendaten an falschen Empfaenger',
description: 'Ein Mitarbeiter hat eine Excel-Datei mit Kundendaten (Name, Adresse, Vertragsnummer) an einen falschen E-Mail-Empfaenger gesendet. Der Empfaenger wurde kontaktiert und hat die Loeschung bestaetigt.',
category: 'data_breach',
severity: 'medium',
status: 'assessment',
detectedAt: new Date(now.getTime() - 18 * 60 * 60 * 1000).toISOString(), // 18 Stunden her
detectedBy: 'Vertriebsabteilung',
affectedSystems: ['E-Mail-System (Exchange)'],
affectedDataCategories: ['Personenbezogene Daten', 'Kundendaten'],
estimatedAffectedPersons: 150,
riskAssessment: {
id: 'ra-002',
assessedBy: 'DSB Mueller',
assessedAt: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(),
likelihoodScore: 3,
impactScore: 2,
overallRisk: 'medium',
notificationRequired: false,
reasoning: 'Empfaenger hat Loeschung bestaetigt. Datenkategorie: allgemeine Kontaktdaten und Vertragsnummern. Geringes Risiko fuer betroffene Personen.'
},
authorityNotification: {
id: 'an-002',
authority: 'LfD Niedersachsen',
deadline72h: new Date(new Date(now.getTime() - 18 * 60 * 60 * 1000).getTime() + 72 * 60 * 60 * 1000).toISOString(),
status: 'pending',
formData: {}
},
dataSubjectNotification: null,
measures: [
{
id: 'meas-001',
incidentId: 'inc-002',
title: 'Empfaenger kontaktiert',
description: 'Falscher Empfaenger kontaktiert mit Bitte um Loeschung',
type: 'immediate',
status: 'completed',
responsible: 'Vertriebsleitung',
dueDate: new Date(now.getTime() - 16 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 15 * 60 * 60 * 1000).toISOString()
}
],
timeline: [
{
id: 'tl-002',
incidentId: 'inc-002',
timestamp: new Date(now.getTime() - 18 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall gemeldet',
description: 'Mitarbeiter meldet versehentlichen E-Mail-Versand',
performedBy: 'M. Schmidt (Vertrieb)'
},
{
id: 'tl-003',
incidentId: 'inc-002',
timestamp: new Date(now.getTime() - 15 * 60 * 60 * 1000).toISOString(),
action: 'Sofortmassnahme',
description: 'Empfaenger kontaktiert und Loeschung bestaetigt',
performedBy: 'Vertriebsleitung'
},
{
id: 'tl-004',
incidentId: 'inc-002',
timestamp: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(),
action: 'Risikobewertung',
description: 'Bewertung durchgefuehrt - mittleres Risiko, keine Meldepflicht',
performedBy: 'DSB Mueller'
}
],
assignedTo: 'DSB Mueller'
},
// 3. Gemeldet (notification_sent) - Ransomware-Angriff
{
id: 'inc-003',
referenceNumber: 'INC-2026-000003',
title: 'Ransomware-Angriff auf Dateiserver',
description: 'Am Montagmorgen wurde ein Ransomware-Angriff auf den zentralen Dateiserver erkannt. Mehrere verschluesselte Dateien wurden identifiziert. Der Angriffsvektor war eine Phishing-E-Mail an einen Mitarbeiter.',
category: 'ransomware',
severity: 'critical',
status: 'notification_sent',
detectedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
detectedBy: 'IT-Sicherheitsteam',
affectedSystems: ['Dateiserver (FS-01)', 'E-Mail-System', 'Backup-Server'],
affectedDataCategories: ['Personenbezogene Daten', 'Beschaeftigtendaten', 'Kundendaten', 'Finanzdaten'],
estimatedAffectedPersons: 2500,
riskAssessment: {
id: 'ra-003',
assessedBy: 'DSB Mueller',
assessedAt: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString(),
likelihoodScore: 5,
impactScore: 5,
overallRisk: 'critical',
notificationRequired: true,
reasoning: 'Hohes Risiko fuer Rechte und Freiheiten der betroffenen Personen durch potentiellen Zugriff auf personenbezogene Daten und Finanzdaten. Verschluesselung betrifft Verfuegbarkeit, Exfiltration nicht auszuschliessen.'
},
authorityNotification: {
id: 'an-003',
authority: 'LfD Niedersachsen',
deadline72h: new Date(new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).getTime() + 72 * 60 * 60 * 1000).toISOString(),
submittedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(),
status: 'submitted',
formData: {
referenceNumber: 'LfD-NI-2026-04821',
incidentType: 'Ransomware',
affectedPersons: 2500
},
pdfUrl: '/api/sdk/v1/incidents/inc-003/authority-form.pdf'
},
dataSubjectNotification: {
id: 'dsn-003',
notificationRequired: true,
templateText: 'Sehr geehrte Damen und Herren, wir informieren Sie ueber einen Sicherheitsvorfall, bei dem moeglicherweise Ihre personenbezogenen Daten betroffen sind...',
sentAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
recipientCount: 2500,
method: 'email'
},
measures: [
{
id: 'meas-002',
incidentId: 'inc-003',
title: 'Netzwerksegmentierung',
description: 'Betroffene Systeme vom Netzwerk isoliert',
type: 'immediate',
status: 'completed',
responsible: 'IT-Sicherheitsteam',
dueDate: new Date(now.getTime() - 4.8 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 4.9 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-003',
incidentId: 'inc-003',
title: 'Passwoerter zuruecksetzen',
description: 'Alle Benutzerpasswoerter zurueckgesetzt',
type: 'immediate',
status: 'completed',
responsible: 'IT-Administration',
dueDate: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-004',
incidentId: 'inc-003',
title: 'E-Mail-Security Gateway implementieren',
description: 'Implementierung eines fortgeschrittenen E-Mail-Sicherheitsgateways mit Sandboxing',
type: 'preventive',
status: 'in_progress',
responsible: 'IT-Sicherheitsteam',
dueDate: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-005',
incidentId: 'inc-003',
title: 'Mitarbeiterschulung Phishing',
description: 'Verpflichtende Schulung fuer alle Mitarbeiter zum Thema Phishing-Erkennung',
type: 'preventive',
status: 'planned',
responsible: 'Personalwesen',
dueDate: new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString()
}
],
timeline: [
{
id: 'tl-005',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall erkannt',
description: 'IT-Sicherheitsteam erkennt ungewoehnliche Verschluesselungsaktivitaet',
performedBy: 'IT-Sicherheitsteam'
},
{
id: 'tl-006',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 4.9 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Eindaemmung gestartet',
description: 'Netzwerksegmentierung und Isolation betroffener Systeme',
performedBy: 'IT-Sicherheitsteam'
},
{
id: 'tl-007',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Risikobewertung abgeschlossen',
description: 'Kritisches Risiko festgestellt - Meldepflicht ausgeloest',
performedBy: 'DSB Mueller'
},
{
id: 'tl-008',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Behoerdenbenachrichtigung',
description: 'Meldung an LfD Niedersachsen eingereicht',
performedBy: 'DSB Mueller'
},
{
id: 'tl-009',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Betroffene benachrichtigt',
description: '2.500 betroffene Personen per E-Mail informiert',
performedBy: 'Kommunikationsabteilung'
}
],
assignedTo: 'DSB Mueller'
},
// 4. Abgeschlossener Vorfall (closed) - Phishing
{
id: 'inc-004',
referenceNumber: 'INC-2026-000004',
title: 'Phishing-Angriff auf Personalabteilung',
description: 'Gezielter Phishing-Angriff auf die Personalabteilung. Ein Mitarbeiter hat Zugangsdaten auf einer gefaelschten Login-Seite eingegeben. Das Konto wurde sofort gesperrt. Keine Datenexfiltration festgestellt.',
category: 'phishing',
severity: 'high',
status: 'closed',
detectedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
detectedBy: 'IT-Sicherheitsteam (SIEM-Alert)',
affectedSystems: ['Active Directory', 'HR-Portal'],
affectedDataCategories: ['Beschaeftigtendaten', 'Personenbezogene Daten'],
estimatedAffectedPersons: 0,
riskAssessment: {
id: 'ra-004',
assessedBy: 'DSB Mueller',
assessedAt: new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000).toISOString(),
likelihoodScore: 4,
impactScore: 3,
overallRisk: 'high',
notificationRequired: true,
reasoning: 'Zugangsdaten kompromittiert, potentieller Zugriff auf Personaldaten. Keine Exfiltration festgestellt, dennoch Meldung wegen Kompromittierung der Zugangsdaten.'
},
authorityNotification: {
id: 'an-004',
authority: 'LfD Niedersachsen',
deadline72h: new Date(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).getTime() + 72 * 60 * 60 * 1000).toISOString(),
submittedAt: new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000).toISOString(),
status: 'acknowledged',
formData: {
referenceNumber: 'LfD-NI-2026-03912',
incidentType: 'Phishing',
affectedPersons: 0
}
},
dataSubjectNotification: {
id: 'dsn-004',
notificationRequired: false,
templateText: '',
recipientCount: 0,
method: 'email'
},
measures: [
{
id: 'meas-006',
incidentId: 'inc-004',
title: 'Konto gesperrt',
description: 'Kompromittiertes Benutzerkonto sofort gesperrt',
type: 'immediate',
status: 'completed',
responsible: 'IT-Administration',
dueDate: new Date(now.getTime() - 29.8 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 29.9 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-007',
incidentId: 'inc-004',
title: 'MFA fuer alle Mitarbeiter',
description: 'Einfuehrung von Multi-Faktor-Authentifizierung fuer alle Konten',
type: 'preventive',
status: 'completed',
responsible: 'IT-Sicherheitsteam',
dueDate: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString()
}
],
timeline: [
{
id: 'tl-010',
incidentId: 'inc-004',
timestamp: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
action: 'SIEM-Alert',
description: 'Verdaechtiger Login-Versuch aus unbekannter Region erkannt',
performedBy: 'IT-Sicherheitsteam'
},
{
id: 'tl-011',
incidentId: 'inc-004',
timestamp: new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Behoerdenbenachrichtigung',
description: 'Meldung an LfD Niedersachsen',
performedBy: 'DSB Mueller'
},
{
id: 'tl-012',
incidentId: 'inc-004',
timestamp: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall abgeschlossen',
description: 'Alle Massnahmen umgesetzt, keine Datenexfiltration festgestellt',
performedBy: 'DSB Mueller'
}
],
assignedTo: 'DSB Mueller',
closedAt: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(),
lessonsLearned: '1. MFA haette den Zugriff verhindert (jetzt implementiert). 2. E-Mail-Security-Gateway muss verbesserte Phishing-Erkennung erhalten. 3. Regelmaessige Phishing-Simulationen fuer alle Mitarbeiter einfuehren.'
}
]
}
/**
* Erstellt Mock-Statistiken fuer die Entwicklung
*/
export function createMockStatistics(): IncidentStatistics {
return {
totalIncidents: 4,
openIncidents: 3,
notificationsPending: 1,
averageResponseTimeHours: 8.5,
bySeverity: {
low: 0,
medium: 1,
high: 2,
critical: 1
},
byCategory: {
data_breach: 1,
unauthorized_access: 1,
data_loss: 0,
system_compromise: 0,
phishing: 1,
ransomware: 1,
insider_threat: 0,
physical_breach: 0,
other: 0
},
byStatus: {
detected: 1,
assessment: 1,
containment: 0,
notification_required: 0,
notification_sent: 1,
remediation: 0,
closed: 1
}
}
}

View File

@@ -0,0 +1,447 @@
/**
* Incident/Breach Management Types (Datenpannen-Management)
*
* TypeScript definitions for DSGVO Art. 33/34 Incident & Data Breach Management
* 72-Stunden-Meldefrist an die Aufsichtsbehoerde
*/
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical'
export type IncidentStatus =
| 'detected' // Erkannt
| 'assessment' // Bewertung laeuft
| 'containment' // Eindaemmung
| 'notification_required' // Meldepflichtig - Meldung steht aus
| 'notification_sent' // Gemeldet an Aufsichtsbehoerde
| 'remediation' // Behebung laeuft
| 'closed' // Abgeschlossen
export type IncidentCategory =
| 'data_breach' // Datenpanne / Datenschutzverletzung
| 'unauthorized_access' // Unbefugter Zugriff
| 'data_loss' // Datenverlust
| 'system_compromise' // Systemkompromittierung
| 'phishing' // Phishing-Angriff
| 'ransomware' // Ransomware
| 'insider_threat' // Insider-Bedrohung
| 'physical_breach' // Physischer Sicherheitsvorfall
| 'other' // Sonstiges
// =============================================================================
// SEVERITY METADATA
// =============================================================================
export interface IncidentSeverityInfo {
label: string
description: string
color: string
bgColor: string
}
export const INCIDENT_SEVERITY_INFO: Record<IncidentSeverity, IncidentSeverityInfo> = {
low: {
label: 'Niedrig',
description: 'Geringes Risiko fuer betroffene Personen, keine Meldepflicht erwartet',
color: 'text-green-700',
bgColor: 'bg-green-100'
},
medium: {
label: 'Mittel',
description: 'Moderates Risiko, Meldepflicht an Aufsichtsbehoerde moeglich',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
high: {
label: 'Hoch',
description: 'Hohes Risiko, Meldepflicht an Aufsichtsbehoerde wahrscheinlich',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
critical: {
label: 'Kritisch',
description: 'Sehr hohes Risiko, Meldepflicht an Aufsichtsbehoerde und Betroffene',
color: 'text-red-700',
bgColor: 'bg-red-100'
}
}
// =============================================================================
// STATUS METADATA
// =============================================================================
export interface IncidentStatusInfo {
label: string
description: string
color: string
bgColor: string
}
export const INCIDENT_STATUS_INFO: Record<IncidentStatus, IncidentStatusInfo> = {
detected: {
label: 'Erkannt',
description: 'Vorfall wurde erkannt und dokumentiert',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
assessment: {
label: 'Bewertung',
description: 'Risikobewertung und Einschaetzung der Meldepflicht',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
containment: {
label: 'Eindaemmung',
description: 'Sofortmassnahmen zur Eindaemmung werden durchgefuehrt',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
notification_required: {
label: 'Meldepflichtig',
description: 'Meldung an Aufsichtsbehoerde erforderlich (Art. 33 DSGVO)',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
notification_sent: {
label: 'Gemeldet',
description: 'Meldung an die Aufsichtsbehoerde wurde eingereicht',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
remediation: {
label: 'Behebung',
description: 'Langfristige Behebungs- und Praeventionsmassnahmen',
color: 'text-indigo-700',
bgColor: 'bg-indigo-100'
},
closed: {
label: 'Abgeschlossen',
description: 'Vorfall vollstaendig bearbeitet und dokumentiert',
color: 'text-green-700',
bgColor: 'bg-green-100'
}
}
// =============================================================================
// CATEGORY METADATA
// =============================================================================
export interface IncidentCategoryInfo {
label: string
description: string
icon: string
color: string
bgColor: string
}
export const INCIDENT_CATEGORY_INFO: Record<IncidentCategory, IncidentCategoryInfo> = {
data_breach: {
label: 'Datenpanne',
description: 'Allgemeine Datenschutzverletzung mit Offenlegung personenbezogener Daten',
icon: '\u{1F4C4}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
unauthorized_access: {
label: 'Unbefugter Zugriff',
description: 'Unberechtigter Zugriff auf Systeme oder Daten',
icon: '\u{1F6AB}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
data_loss: {
label: 'Datenverlust',
description: 'Verlust von Daten durch technischen Fehler oder Versehen',
icon: '\u{1F4BE}',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
system_compromise: {
label: 'Systemkompromittierung',
description: 'System wurde durch Angreifer kompromittiert',
icon: '\u{1F4BB}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
phishing: {
label: 'Phishing-Angriff',
description: 'Taeuschendes Abfangen von Zugangsdaten oder Daten',
icon: '\u{1F3A3}',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
ransomware: {
label: 'Ransomware',
description: 'Verschluesselung von Daten durch Schadsoftware',
icon: '\u{1F512}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
insider_threat: {
label: 'Insider-Bedrohung',
description: 'Vorsaetzlicher oder fahrlaessiger Verstoss durch Mitarbeiter',
icon: '\u{1F464}',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
physical_breach: {
label: 'Physischer Sicherheitsvorfall',
description: 'Einbruch, Diebstahl von Geraeten oder physische Zugriffe',
icon: '\u{1F3E2}',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
},
other: {
label: 'Sonstiges',
description: 'Sonstiger Datenschutzvorfall',
icon: '\u{2753}',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
}
}
// =============================================================================
// MAIN INTERFACES
// =============================================================================
export interface RiskAssessment {
id: string
assessedBy: string
assessedAt: string
likelihoodScore: number // 1-5 (1 = sehr unwahrscheinlich, 5 = sehr wahrscheinlich)
impactScore: number // 1-5 (1 = gering, 5 = katastrophal)
overallRisk: IncidentSeverity // Berechnetes Gesamtrisiko
notificationRequired: boolean // Art. 33 Bewertung
reasoning: string // Begruendung der Bewertung
}
export interface AuthorityNotification {
id: string
authority: string // z.B. "LfD Niedersachsen"
deadline72h: string // 72 Stunden nach Erkennung (Art. 33)
submittedAt?: string
status: 'pending' | 'submitted' | 'acknowledged'
formData: Record<string, unknown>
pdfUrl?: string
}
export interface DataSubjectNotification {
id: string
notificationRequired: boolean // Art. 34 Bewertung
templateText: string
sentAt?: string
recipientCount: number
method: 'email' | 'letter' | 'portal' | 'public'
}
export interface IncidentMeasure {
id: string
incidentId: string
title: string
description: string
type: 'immediate' | 'corrective' | 'preventive'
status: 'planned' | 'in_progress' | 'completed'
responsible: string
dueDate: string
completedAt?: string
}
export interface TimelineEntry {
id: string
incidentId: string
timestamp: string
action: string
description: string
performedBy: string
}
export interface Incident {
id: string
referenceNumber: string // z.B. "INC-2025-000001"
title: string
description: string
category: IncidentCategory
severity: IncidentSeverity
status: IncidentStatus
// Erkennung
detectedAt: string
detectedBy: string
// Betroffene Systeme & Daten
affectedSystems: string[]
affectedDataCategories: string[]
estimatedAffectedPersons: number
// Risikobewertung
riskAssessment: RiskAssessment | null
// Meldungen
authorityNotification: AuthorityNotification | null
dataSubjectNotification: DataSubjectNotification | null
// Massnahmen & Verlauf
measures: IncidentMeasure[]
timeline: TimelineEntry[]
// Zuweisung
assignedTo?: string
// Abschluss
closedAt?: string
lessonsLearned?: string
}
// =============================================================================
// STATISTICS
// =============================================================================
export interface IncidentStatistics {
totalIncidents: number
openIncidents: number
notificationsPending: number
averageResponseTimeHours: number
bySeverity: Record<IncidentSeverity, number>
byCategory: Record<IncidentCategory, number>
byStatus: Record<IncidentStatus, number>
}
// =============================================================================
// API TYPES
// =============================================================================
export interface IncidentFilters {
status?: IncidentStatus | IncidentStatus[]
severity?: IncidentSeverity | IncidentSeverity[]
category?: IncidentCategory | IncidentCategory[]
assignedTo?: string
overdue?: boolean
search?: string
dateFrom?: string
dateTo?: string
}
export interface IncidentListResponse {
incidents: Incident[]
total: number
page: number
pageSize: number
}
export interface IncidentCreateRequest {
title: string
description: string
category: IncidentCategory
severity: IncidentSeverity
detectedAt: string
detectedBy: string
affectedSystems: string[]
affectedDataCategories: string[]
estimatedAffectedPersons: number
assignedTo?: string
}
export interface IncidentUpdateRequest {
title?: string
description?: string
category?: IncidentCategory
severity?: IncidentSeverity
status?: IncidentStatus
affectedSystems?: string[]
affectedDataCategories?: string[]
estimatedAffectedPersons?: number
assignedTo?: string
}
export interface RiskAssessmentRequest {
likelihoodScore: number // 1-5
impactScore: number // 1-5
reasoning: string
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Berechnet die verbleibenden Stunden bis zur 72h-Meldefrist (Art. 33 DSGVO)
*/
export function getHoursUntil72hDeadline(detectedAt: string): number {
const detected = new Date(detectedAt)
const deadline = new Date(detected.getTime() + 72 * 60 * 60 * 1000)
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.round(diff / (1000 * 60 * 60) * 10) / 10
}
/**
* Prueft ob die 72-Stunden-Meldefrist abgelaufen ist
*/
export function is72hDeadlineExpired(detectedAt: string): boolean {
const detected = new Date(detectedAt)
const deadline = new Date(detected.getTime() + 72 * 60 * 60 * 1000)
return new Date() > deadline
}
/**
* Berechnet die Risikostufe basierend auf Eintrittswahrscheinlichkeit und Auswirkung
* Risiko-Matrix:
* likelihood x impact >= 20 -> critical
* likelihood x impact >= 12 -> high
* likelihood x impact >= 6 -> medium
* sonst -> low
*/
export function calculateRiskLevel(likelihood: number, impact: number): IncidentSeverity {
const riskScore = likelihood * impact
if (riskScore >= 20) return 'critical'
if (riskScore >= 12) return 'high'
if (riskScore >= 6) return 'medium'
return 'low'
}
/**
* Prueft ob eine Meldung an die Aufsichtsbehoerde erforderlich ist
* Bei hohem oder kritischem Risiko ist eine Meldung gemaess Art. 33 DSGVO erforderlich
*/
export function isNotificationRequired(riskAssessment: RiskAssessment): boolean {
return riskAssessment.overallRisk === 'high' || riskAssessment.overallRisk === 'critical'
}
/**
* Generiert eine Referenznummer fuer einen Vorfall
*/
export function generateIncidentReferenceNumber(year: number, sequence: number): string {
return `INC-${year}-${String(sequence).padStart(6, '0')}`
}
/**
* Gibt die 72h-Deadline als Date zurueck
*/
export function get72hDeadline(detectedAt: string): Date {
const detected = new Date(detectedAt)
return new Date(detected.getTime() + 72 * 60 * 60 * 1000)
}
/**
* Gibt die Severity-Info zurueck
*/
export function getSeverityInfo(severity: IncidentSeverity): IncidentSeverityInfo {
return INCIDENT_SEVERITY_INFO[severity]
}
/**
* Gibt die Status-Info zurueck
*/
export function getStatusInfo(status: IncidentStatus): IncidentStatusInfo {
return INCIDENT_STATUS_INFO[status]
}
/**
* Gibt die Kategorie-Info zurueck
*/
export function getCategoryInfo(category: IncidentCategory): IncidentCategoryInfo {
return INCIDENT_CATEGORY_INFO[category]
}

View File

@@ -693,6 +693,45 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['consent-management'],
isOptional: false,
},
{
id: 'incidents',
phase: 2,
package: 'betrieb',
order: 6,
name: 'Incident Management',
nameShort: 'Incidents',
description: 'Datenpannen erfassen, bewerten und melden (Art. 33/34 DSGVO)',
url: '/sdk/incidents',
checkpointId: 'CP-INC',
prerequisiteSteps: ['notfallplan'],
isOptional: false,
},
{
id: 'whistleblower',
phase: 2,
package: 'betrieb',
order: 7,
name: 'Hinweisgebersystem',
nameShort: 'Whistleblower',
description: 'Anonymes Meldesystem gemaess HinSchG',
url: '/sdk/whistleblower',
checkpointId: 'CP-WB',
prerequisiteSteps: ['incidents'],
isOptional: false,
},
{
id: 'academy',
phase: 2,
package: 'betrieb',
order: 8,
name: 'Compliance Academy',
nameShort: 'Academy',
description: 'Mitarbeiter-Schulungen & Zertifikate',
url: '/sdk/academy',
checkpointId: 'CP-ACAD',
prerequisiteSteps: ['whistleblower'],
isOptional: false,
},
]
// =============================================================================

View File

@@ -0,0 +1,755 @@
/**
* Whistleblower System API Client
*
* API client for Hinweisgeberschutzgesetz (HinSchG) compliant
* Whistleblower/Hinweisgebersystem management
* Connects to the ai-compliance-sdk backend
*/
import {
WhistleblowerReport,
WhistleblowerStatistics,
ReportListResponse,
ReportFilters,
PublicReportSubmission,
ReportUpdateRequest,
MessageSendRequest,
AnonymousMessage,
WhistleblowerMeasure,
FileAttachment,
ReportCategory,
ReportStatus,
ReportPriority,
generateAccessKey
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const WB_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = localStorage.getItem('bp_user_id')
if (userId) {
headers['X-User-ID'] = userId
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// ADMIN CRUD - Reports
// =============================================================================
/**
* Alle Meldungen abrufen (Admin)
*/
export async function fetchReports(filters?: ReportFilters): Promise<ReportListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.status) {
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
statuses.forEach(s => params.append('status', s))
}
if (filters.category) {
const categories = Array.isArray(filters.category) ? filters.category : [filters.category]
categories.forEach(c => params.append('category', c))
}
if (filters.priority) params.set('priority', filters.priority)
if (filters.assignedTo) params.set('assignedTo', filters.assignedTo)
if (filters.isAnonymous !== undefined) params.set('isAnonymous', String(filters.isAnonymous))
if (filters.search) params.set('search', filters.search)
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom)
if (filters.dateTo) params.set('dateTo', filters.dateTo)
}
const queryString = params.toString()
const url = `${WB_API_BASE}/api/v1/admin/whistleblower/reports${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<ReportListResponse>(url)
}
/**
* Einzelne Meldung abrufen (Admin)
*/
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`
)
}
/**
* Meldung aktualisieren (Status, Prioritaet, Kategorie, Zuweisung)
*/
export async function updateReport(id: string, update: ReportUpdateRequest): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Meldung loeschen (soft delete)
*/
export async function deleteReport(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
{
method: 'DELETE'
}
)
}
// =============================================================================
// PUBLIC ENDPOINTS - Kein Auth erforderlich
// =============================================================================
/**
* Neue Meldung einreichen (oeffentlich, keine Auth)
*/
export async function submitPublicReport(
data: PublicReportSubmission
): Promise<{ report: WhistleblowerReport; accessKey: string }> {
const response = await fetch(
`${WB_API_BASE}/api/v1/public/whistleblower/submit`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
}
/**
* Meldung ueber Zugangscode abrufen (oeffentlich, keine Auth)
*/
export async function fetchReportByAccessKey(
accessKey: string
): Promise<WhistleblowerReport> {
const response = await fetch(
`${WB_API_BASE}/api/v1/public/whistleblower/report/${accessKey}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
}
// =============================================================================
// WORKFLOW ACTIONS
// =============================================================================
/**
* Eingangsbestaetigung versenden (HinSchG ss 17 Abs. 1)
*/
export async function acknowledgeReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`,
{
method: 'POST'
}
)
}
/**
* Untersuchung starten
*/
export async function startInvestigation(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/investigate`,
{
method: 'POST'
}
)
}
/**
* Massnahme zu einer Meldung hinzufuegen
*/
export async function addMeasure(
id: string,
measure: Omit<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
): Promise<WhistleblowerMeasure> {
return fetchWithTimeout<WhistleblowerMeasure>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/measures`,
{
method: 'POST',
body: JSON.stringify(measure)
}
)
}
/**
* Meldung abschliessen mit Begruendung
*/
export async function closeReport(
id: string,
resolution: { reason: string; notes: string }
): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/close`,
{
method: 'POST',
body: JSON.stringify(resolution)
}
)
}
// =============================================================================
// ANONYMOUS MESSAGING
// =============================================================================
/**
* Nachricht im anonymen Kanal senden
*/
export async function sendMessage(
reportId: string,
message: string,
role: 'reporter' | 'ombudsperson'
): Promise<AnonymousMessage> {
return fetchWithTimeout<AnonymousMessage>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`,
{
method: 'POST',
body: JSON.stringify({ senderRole: role, message })
}
)
}
/**
* Nachrichten fuer eine Meldung abrufen
*/
export async function fetchMessages(reportId: string): Promise<AnonymousMessage[]> {
return fetchWithTimeout<AnonymousMessage[]>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`
)
}
// =============================================================================
// ATTACHMENTS
// =============================================================================
/**
* Anhang zu einer Meldung hochladen
*/
export async function uploadAttachment(
reportId: string,
file: File
): Promise<FileAttachment> {
const formData = new FormData()
formData.append('file', file)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 60000) // 60s for uploads
try {
const headers: HeadersInit = {
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
const response = await fetch(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/attachments`,
{
method: 'POST',
headers,
body: formData,
signal: controller.signal
}
)
if (!response.ok) {
throw new Error(`Upload fehlgeschlagen: ${response.statusText}`)
}
return response.json()
} finally {
clearTimeout(timeoutId)
}
}
/**
* Anhang loeschen
*/
export async function deleteAttachment(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`,
{
method: 'DELETE'
}
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Statistiken fuer das Whistleblower-Dashboard abrufen
*/
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
return fetchWithTimeout<WhistleblowerStatistics>(
`${WB_API_BASE}/api/v1/admin/whistleblower/statistics`
)
}
// =============================================================================
// SDK PROXY FUNCTION (via Next.js proxy)
// =============================================================================
/**
* Fetch Whistleblower-Daten via SDK Proxy mit Fallback auf Mock-Daten
*/
export async function fetchSDKWhistleblowerList(): Promise<{
reports: WhistleblowerReport[]
statistics: WhistleblowerStatistics
}> {
try {
const [reportsResponse, statsResponse] = await Promise.all([
fetchReports(),
fetchWhistleblowerStatistics()
])
return {
reports: reportsResponse.reports,
statistics: statsResponse
}
} catch (error) {
console.error('Failed to load Whistleblower data from API, using mock data:', error)
// Fallback to mock data
const reports = createMockReports()
const statistics = createMockStatistics()
return { reports, statistics }
}
}
// =============================================================================
// MOCK DATA (Demo/Entwicklung)
// =============================================================================
/**
* Erstellt Demo-Meldungen fuer Entwicklung und Praesentationen
*/
export function createMockReports(): WhistleblowerReport[] {
const now = new Date()
// Helper: Berechne Fristen
function calcDeadlines(receivedAt: Date): { ack: string; fb: string } {
const ack = new Date(receivedAt)
ack.setDate(ack.getDate() + 7)
const fb = new Date(receivedAt)
fb.setMonth(fb.getMonth() + 3)
return { ack: ack.toISOString(), fb: fb.toISOString() }
}
const received1 = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000)
const deadlines1 = calcDeadlines(received1)
const received2 = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
const deadlines2 = calcDeadlines(received2)
const received3 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const deadlines3 = calcDeadlines(received3)
const received4 = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
const deadlines4 = calcDeadlines(received4)
return [
// Report 1: Neu
{
id: 'wb-001',
referenceNumber: 'WB-2026-000001',
accessKey: generateAccessKey(),
category: 'corruption',
status: 'new',
priority: 'high',
title: 'Unregelmaessigkeiten bei Auftragsvergabe',
description: 'Bei der Vergabe des IT-Rahmenvertrags im November wurden offenbar Angebote eines bestimmten Anbieters bevorzugt. Der zustaendige Abteilungsleiter hat private Verbindungen zum Geschaeftsfuehrer des Anbieters.',
isAnonymous: true,
receivedAt: received1.toISOString(),
deadlineAcknowledgment: deadlines1.ack,
deadlineFeedback: deadlines1.fb,
measures: [],
messages: [],
attachments: [],
auditTrail: [
{
id: 'audit-001',
action: 'report_created',
description: 'Meldung ueber Online-Meldeformular eingegangen',
performedBy: 'system',
performedAt: received1.toISOString()
}
]
},
// Report 2: In Pruefung (under_review)
{
id: 'wb-002',
referenceNumber: 'WB-2026-000002',
accessKey: generateAccessKey(),
category: 'data_protection',
status: 'under_review',
priority: 'normal',
title: 'Unerlaubte Weitergabe von Kundendaten',
description: 'Ein Mitarbeiter der Vertriebsabteilung gibt regelmaessig Kundenlisten an externe Dienstleister weiter, ohne dass eine Auftragsverarbeitungsvereinbarung vorliegt.',
isAnonymous: false,
reporterName: 'Maria Schmidt',
reporterEmail: 'maria.schmidt@example.de',
assignedTo: 'DSB Mueller',
receivedAt: received2.toISOString(),
acknowledgedAt: new Date(received2.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
deadlineAcknowledgment: deadlines2.ack,
deadlineFeedback: deadlines2.fb,
measures: [],
messages: [
{
id: 'msg-001',
reportId: 'wb-002',
senderRole: 'ombudsperson',
message: 'Vielen Dank fuer Ihre Meldung. Koennen Sie uns mitteilen, welche Dienstleister konkret betroffen sind?',
createdAt: new Date(received2.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(),
isRead: true
},
{
id: 'msg-002',
reportId: 'wb-002',
senderRole: 'reporter',
message: 'Es handelt sich um die Firma DataServ GmbH und MarketPro AG. Die Listen werden per unverschluesselter E-Mail versendet.',
createdAt: new Date(received2.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
isRead: true
}
],
attachments: [
{
id: 'att-001',
fileName: 'email_screenshot_vertrieb.png',
fileSize: 245000,
mimeType: 'image/png',
uploadedAt: received2.toISOString(),
uploadedBy: 'reporter'
}
],
auditTrail: [
{
id: 'audit-002',
action: 'report_created',
description: 'Meldung per E-Mail eingegangen',
performedBy: 'system',
performedAt: received2.toISOString()
},
{
id: 'audit-003',
action: 'acknowledged',
description: 'Eingangsbestaetigung an Hinweisgeber versendet',
performedBy: 'DSB Mueller',
performedAt: new Date(received2.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'audit-004',
action: 'status_changed',
description: 'Status geaendert: Bestaetigt -> In Pruefung',
performedBy: 'DSB Mueller',
performedAt: new Date(received2.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
}
]
},
// Report 3: Untersuchung (investigation)
{
id: 'wb-003',
referenceNumber: 'WB-2026-000003',
accessKey: generateAccessKey(),
category: 'product_safety',
status: 'investigation',
priority: 'critical',
title: 'Fehlende Sicherheitspruefungen bei Produktfreigabe',
description: 'In der Fertigung werden seit Wochen Produkte ohne die vorgeschriebenen Sicherheitspruefungen freigegeben. Pruefprotokolle werden nachtraeglich erstellt, ohne dass tatsaechliche Pruefungen stattfinden.',
isAnonymous: true,
assignedTo: 'Qualitaetsbeauftragter Weber',
receivedAt: received3.toISOString(),
acknowledgedAt: new Date(received3.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString(),
deadlineAcknowledgment: deadlines3.ack,
deadlineFeedback: deadlines3.fb,
measures: [
{
id: 'msr-001',
reportId: 'wb-003',
title: 'Sofortiger Produktionsstopp fuer betroffene Charge',
description: 'Produktion der betroffenen Produktlinie stoppen bis Pruefverfahren sichergestellt ist',
status: 'completed',
responsible: 'Fertigungsleitung',
dueDate: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(received3.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'msr-002',
reportId: 'wb-003',
title: 'Externe Pruefung der Pruefprotokolle',
description: 'Unabhaengige Pruefstelle mit der Revision aller Pruefprotokolle der letzten 6 Monate beauftragen',
status: 'in_progress',
responsible: 'Qualitaetsmanagement',
dueDate: new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString()
}
],
messages: [],
attachments: [
{
id: 'att-002',
fileName: 'pruefprotokoll_vergleich.pdf',
fileSize: 890000,
mimeType: 'application/pdf',
uploadedAt: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(),
uploadedBy: 'ombudsperson'
}
],
auditTrail: [
{
id: 'audit-005',
action: 'report_created',
description: 'Meldung ueber Online-Meldeformular eingegangen',
performedBy: 'system',
performedAt: received3.toISOString()
},
{
id: 'audit-006',
action: 'acknowledged',
description: 'Eingangsbestaetigung versendet',
performedBy: 'Qualitaetsbeauftragter Weber',
performedAt: new Date(received3.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'audit-007',
action: 'investigation_started',
description: 'Formelle Untersuchung eingeleitet',
performedBy: 'Qualitaetsbeauftragter Weber',
performedAt: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
}
]
},
// Report 4: Abgeschlossen (closed)
{
id: 'wb-004',
referenceNumber: 'WB-2026-000004',
accessKey: generateAccessKey(),
category: 'fraud',
status: 'closed',
priority: 'high',
title: 'Gefaelschte Reisekostenabrechnungen',
description: 'Ein leitender Mitarbeiter reicht seit ueber einem Jahr gefaelschte Reisekostenabrechnungen ein. Hotelrechnungen werden manipuliert, Taxiquittungen erfunden.',
isAnonymous: false,
reporterName: 'Thomas Klein',
reporterEmail: 'thomas.klein@example.de',
reporterPhone: '+49 170 9876543',
assignedTo: 'Compliance-Abteilung',
receivedAt: received4.toISOString(),
acknowledgedAt: new Date(received4.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
deadlineAcknowledgment: deadlines4.ack,
deadlineFeedback: deadlines4.fb,
closedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
measures: [
{
id: 'msr-003',
reportId: 'wb-004',
title: 'Interne Revision der Reisekosten',
description: 'Pruefung aller Reisekostenabrechnungen des betroffenen Mitarbeiters der letzten 24 Monate',
status: 'completed',
responsible: 'Interne Revision',
dueDate: new Date(received4.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(received4.getTime() + 25 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'msr-004',
reportId: 'wb-004',
title: 'Arbeitsrechtliche Konsequenzen',
description: 'Einleitung arbeitsrechtlicher Schritte nach Bestaetigung des Betrugs',
status: 'completed',
responsible: 'Personalabteilung',
dueDate: new Date(received4.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(received4.getTime() + 55 * 24 * 60 * 60 * 1000).toISOString()
}
],
messages: [],
attachments: [
{
id: 'att-003',
fileName: 'vergleich_originalrechnung_einreichung.pdf',
fileSize: 567000,
mimeType: 'application/pdf',
uploadedAt: received4.toISOString(),
uploadedBy: 'reporter'
}
],
auditTrail: [
{
id: 'audit-008',
action: 'report_created',
description: 'Meldung per Brief eingegangen',
performedBy: 'system',
performedAt: received4.toISOString()
},
{
id: 'audit-009',
action: 'acknowledged',
description: 'Eingangsbestaetigung versendet',
performedBy: 'Compliance-Abteilung',
performedAt: new Date(received4.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'audit-010',
action: 'closed',
description: 'Fall abgeschlossen - Betrug bestaetigt, arbeitsrechtliche Massnahmen eingeleitet',
performedBy: 'Compliance-Abteilung',
performedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString()
}
]
}
]
}
/**
* Berechnet Statistiken aus den Mock-Daten
*/
export function createMockStatistics(): WhistleblowerStatistics {
const reports = createMockReports()
const now = new Date()
const byStatus: Record<ReportStatus, number> = {
new: 0,
acknowledged: 0,
under_review: 0,
investigation: 0,
measures_taken: 0,
closed: 0,
rejected: 0
}
const byCategory: Record<ReportCategory, number> = {
corruption: 0,
fraud: 0,
data_protection: 0,
discrimination: 0,
environment: 0,
competition: 0,
product_safety: 0,
tax_evasion: 0,
other: 0
}
reports.forEach(r => {
byStatus[r.status]++
byCategory[r.category]++
})
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
// Pruefe ueberfaellige Eingangsbestaetigungen
const overdueAcknowledgment = reports.filter(r => {
if (r.status !== 'new') return false
return now > new Date(r.deadlineAcknowledgment)
}).length
// Pruefe ueberfaellige Rueckmeldungen
const overdueFeedback = reports.filter(r => {
if (closedStatuses.includes(r.status)) return false
return now > new Date(r.deadlineFeedback)
}).length
return {
totalReports: reports.length,
newReports: byStatus.new,
underReview: byStatus.under_review + byStatus.investigation,
closed: byStatus.closed + byStatus.rejected,
overdueAcknowledgment,
overdueFeedback,
byCategory,
byStatus
}
}

View File

@@ -0,0 +1,381 @@
/**
* Whistleblower System (Hinweisgebersystem) Types
*
* TypeScript definitions for Hinweisgeberschutzgesetz (HinSchG)
* compliant Whistleblower/Hinweisgebersystem module
*/
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================
export type ReportCategory =
| 'corruption' // Korruption
| 'fraud' // Betrug
| 'data_protection' // Datenschutz
| 'discrimination' // Diskriminierung
| 'environment' // Umwelt
| 'competition' // Wettbewerb
| 'product_safety' // Produktsicherheit
| 'tax_evasion' // Steuerhinterziehung
| 'other' // Sonstiges
export type ReportStatus =
| 'new' // Neu eingegangen
| 'acknowledged' // Eingangsbestaetigung versendet
| 'under_review' // In Pruefung
| 'investigation' // Untersuchung laeuft
| 'measures_taken' // Massnahmen ergriffen
| 'closed' // Abgeschlossen
| 'rejected' // Abgelehnt
export type ReportPriority = 'low' | 'normal' | 'high' | 'critical'
// =============================================================================
// REPORT CATEGORY METADATA
// =============================================================================
export interface ReportCategoryInfo {
category: ReportCategory
label: string
description: string
icon: string
color: string
bgColor: string
}
export const REPORT_CATEGORY_INFO: Record<ReportCategory, ReportCategoryInfo> = {
corruption: {
category: 'corruption',
label: 'Korruption',
description: 'Bestechung, Bestechlichkeit, Vorteilsnahme oder Vorteilsgewaehrung',
icon: '\u{1F4B0}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
fraud: {
category: 'fraud',
label: 'Betrug',
description: 'Betrug, Untreue, Urkundenfaelschung oder sonstige Vermoegensstraftaten',
icon: '\u{1F3AD}',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
data_protection: {
category: 'data_protection',
label: 'Datenschutz',
description: 'Verstoesse gegen Datenschutzvorschriften (DSGVO, BDSG)',
icon: '\u{1F512}',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
discrimination: {
category: 'discrimination',
label: 'Diskriminierung',
description: 'Diskriminierung, Mobbing, sexuelle Belaestigung oder Benachteiligung',
icon: '\u{26A0}\u{FE0F}',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
environment: {
category: 'environment',
label: 'Umwelt',
description: 'Umweltverschmutzung, illegale Entsorgung oder Verstoesse gegen Umweltauflagen',
icon: '\u{1F33F}',
color: 'text-green-700',
bgColor: 'bg-green-100'
},
competition: {
category: 'competition',
label: 'Wettbewerb',
description: 'Kartellrechtsverstoesse, unlauterer Wettbewerb, Marktmanipulation',
icon: '\u{2696}\u{FE0F}',
color: 'text-indigo-700',
bgColor: 'bg-indigo-100'
},
product_safety: {
category: 'product_safety',
label: 'Produktsicherheit',
description: 'Verstoesse gegen Produktsicherheitsvorschriften, mangelhafte Produkte, fehlende Warnhinweise',
icon: '\u{1F6E1}\u{FE0F}',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
tax_evasion: {
category: 'tax_evasion',
label: 'Steuerhinterziehung',
description: 'Steuerhinterziehung, Steuerumgehung oder sonstige Steuerverstoesse',
icon: '\u{1F4C4}',
color: 'text-teal-700',
bgColor: 'bg-teal-100'
},
other: {
category: 'other',
label: 'Sonstiges',
description: 'Sonstige Verstoesse gegen geltendes Recht oder interne Richtlinien',
icon: '\u{1F4CB}',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
}
}
// =============================================================================
// REPORT STATUS METADATA
// =============================================================================
export const REPORT_STATUS_INFO: Record<ReportStatus, { label: string; description: string; color: string; bgColor: string }> = {
new: {
label: 'Neu',
description: 'Meldung ist eingegangen, Eingangsbestaetigung steht aus',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
acknowledged: {
label: 'Bestaetigt',
description: 'Eingangsbestaetigung wurde an den Hinweisgeber versendet',
color: 'text-cyan-700',
bgColor: 'bg-cyan-100'
},
under_review: {
label: 'In Pruefung',
description: 'Meldung wird inhaltlich geprueft und bewertet',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
investigation: {
label: 'Untersuchung',
description: 'Formelle Untersuchung des gemeldeten Sachverhalts laeuft',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
measures_taken: {
label: 'Massnahmen ergriffen',
description: 'Folgemaßnahmen wurden eingeleitet oder abgeschlossen',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
closed: {
label: 'Abgeschlossen',
description: 'Fall wurde abgeschlossen und dokumentiert',
color: 'text-green-700',
bgColor: 'bg-green-100'
},
rejected: {
label: 'Abgelehnt',
description: 'Meldung wurde als unbegrundet oder nicht zustaendig abgelehnt',
color: 'text-red-700',
bgColor: 'bg-red-100'
}
}
// =============================================================================
// MAIN INTERFACES
// =============================================================================
export interface FileAttachment {
id: string
fileName: string
fileSize: number
mimeType: string
uploadedAt: string
uploadedBy: string
}
export interface AuditEntry {
id: string
action: string
description: string
performedBy: string
performedAt: string
}
export interface AnonymousMessage {
id: string
reportId: string
senderRole: 'reporter' | 'ombudsperson'
message: string
createdAt: string
isRead: boolean
}
export interface WhistleblowerMeasure {
id: string
reportId: string
title: string
description: string
status: 'planned' | 'in_progress' | 'completed'
responsible: string
dueDate: string
completedAt?: string
}
export interface WhistleblowerReport {
id: string
referenceNumber: string // z.B. "WB-2026-000042"
accessKey: string // Anonymer Zugangscode fuer den Hinweisgeber
category: ReportCategory
status: ReportStatus
priority: ReportPriority
title: string
description: string
// Hinweisgeber-Info (optional bei anonymen Meldungen)
isAnonymous: boolean
reporterName?: string
reporterEmail?: string
reporterPhone?: string
// Zuweisung
assignedTo?: string
// Zeitstempel
receivedAt: string
acknowledgedAt?: string
// Fristen gemaess HinSchG
deadlineAcknowledgment: string // 7 Tage nach Eingang (ss 17 Abs. 1 S. 2)
deadlineFeedback: string // 3 Monate nach Eingang (ss 17 Abs. 2)
closedAt?: string
// Verknuepfte Daten
measures: WhistleblowerMeasure[]
messages: AnonymousMessage[]
attachments: FileAttachment[]
auditTrail: AuditEntry[]
}
// =============================================================================
// STATISTICS
// =============================================================================
export interface WhistleblowerStatistics {
totalReports: number
newReports: number
underReview: number
closed: number
overdueAcknowledgment: number
overdueFeedback: number
byCategory: Record<ReportCategory, number>
byStatus: Record<ReportStatus, number>
}
// =============================================================================
// DEADLINE TRACKING (HinSchG)
// =============================================================================
/**
* Gibt die verbleibenden Tage bis zur Eingangsbestaetigung zurueck (7-Tage-Frist)
* Negative Werte bedeuten ueberfaellig
*/
export function getDaysUntilAcknowledgment(report: WhistleblowerReport): number {
if (report.acknowledgedAt || report.status !== 'new') {
return 0
}
const deadline = new Date(report.deadlineAcknowledgment)
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
/**
* Gibt die verbleibenden Tage bis zur Rueckmeldungsfrist zurueck (3-Monate-Frist)
* Negative Werte bedeuten ueberfaellig
*/
export function getDaysUntilFeedback(report: WhistleblowerReport): number {
if (report.status === 'closed' || report.status === 'rejected') {
return 0
}
const deadline = new Date(report.deadlineFeedback)
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
/**
* Prueft ob die Eingangsbestaetigungsfrist ueberschritten ist (7 Tage, HinSchG ss 17 Abs. 1)
*/
export function isAcknowledgmentOverdue(report: WhistleblowerReport): boolean {
if (report.acknowledgedAt || report.status !== 'new') {
return false
}
return new Date() > new Date(report.deadlineAcknowledgment)
}
/**
* Prueft ob die Rueckmeldungsfrist ueberschritten ist (3 Monate, HinSchG ss 17 Abs. 2)
*/
export function isFeedbackOverdue(report: WhistleblowerReport): boolean {
if (report.status === 'closed' || report.status === 'rejected') {
return false
}
return new Date() > new Date(report.deadlineFeedback)
}
/**
* Generiert einen anonymen Zugangscode im Format XXXX-XXXX-XXXX
*/
export function generateAccessKey(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Kein I, O, 0, 1 fuer Lesbarkeit
let result = ''
for (let i = 0; i < 12; i++) {
if (i > 0 && i % 4 === 0) result += '-'
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result // Format: XXXX-XXXX-XXXX
}
// =============================================================================
// API TYPES
// =============================================================================
export interface ReportFilters {
status?: ReportStatus | ReportStatus[]
category?: ReportCategory | ReportCategory[]
priority?: ReportPriority
assignedTo?: string
isAnonymous?: boolean
search?: string
dateFrom?: string
dateTo?: string
}
export interface ReportListResponse {
reports: WhistleblowerReport[]
total: number
page: number
pageSize: number
}
export interface PublicReportSubmission {
category: ReportCategory
title: string
description: string
isAnonymous: boolean
reporterName?: string
reporterEmail?: string
reporterPhone?: string
}
export interface ReportUpdateRequest {
status?: ReportStatus
priority?: ReportPriority
category?: ReportCategory
assignedTo?: string
}
export interface MessageSendRequest {
senderRole: 'reporter' | 'ombudsperson'
message: string
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
export function getCategoryInfo(category: ReportCategory): ReportCategoryInfo {
return REPORT_CATEGORY_INFO[category]
}
export function getStatusInfo(status: ReportStatus) {
return REPORT_STATUS_INFO[status]
}

101
admin-v2/mkdocs.yml Normal file
View File

@@ -0,0 +1,101 @@
site_name: Breakpilot Dokumentation
site_url: https://macmini:8008
docs_dir: docs-src
site_dir: docs-site
theme:
name: material
language: de
palette:
- scheme: default
primary: teal
toggle:
icon: material/brightness-7
name: Dark Mode aktivieren
- scheme: slate
primary: teal
toggle:
icon: material/brightness-4
name: Light Mode aktivieren
features:
- search.highlight
- search.suggest
- navigation.tabs
- navigation.sections
- navigation.expand
- navigation.top
- content.code.copy
- content.tabs.link
- toc.follow
plugins:
- search:
lang: de
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- tables
- attr_list
- md_in_html
- toc:
permalink: true
extra:
social:
- icon: fontawesome/brands/github
link: http://macmini:3003/breakpilot/breakpilot-pwa
nav:
- Start: index.md
- Erste Schritte:
- Umgebung einrichten: getting-started/environment-setup.md
- Mac Mini Setup: getting-started/mac-mini-setup.md
- Architektur:
- Systemuebersicht: architecture/system-architecture.md
- Auth-System: architecture/auth-system.md
- Mail-RBAC: architecture/mail-rbac-architecture.md
- Multi-Agent: architecture/multi-agent.md
- Secrets Management: architecture/secrets-management.md
- DevSecOps: architecture/devsecops.md
- Environments: architecture/environments.md
- Zeugnis-System: architecture/zeugnis-system.md
- Services:
- KI-Daten-Pipeline:
- Uebersicht: services/ki-daten-pipeline/index.md
- Architektur: services/ki-daten-pipeline/architecture.md
- Klausur-Service:
- Uebersicht: services/klausur-service/index.md
- BYOEH Systemerklaerung: services/klausur-service/byoeh-system-erklaerung.md
- BYOEH Architektur: services/klausur-service/BYOEH-Architecture.md
- BYOEH Developer Guide: services/klausur-service/BYOEH-Developer-Guide.md
- NiBiS Pipeline: services/klausur-service/NiBiS-Ingestion-Pipeline.md
- OCR Labeling: services/klausur-service/OCR-Labeling-Spec.md
- OCR Compare: services/klausur-service/OCR-Compare.md
- RAG Admin: services/klausur-service/RAG-Admin-Spec.md
- Worksheet Editor: services/klausur-service/Worksheet-Editor-Architecture.md
- Voice-Service: services/voice-service/index.md
- Agent-Core: services/agent-core/index.md
- AI-Compliance-SDK:
- Uebersicht: services/ai-compliance-sdk/index.md
- Architektur: services/ai-compliance-sdk/ARCHITECTURE.md
- Developer Guide: services/ai-compliance-sdk/DEVELOPER.md
- Auditor Dokumentation: services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md
- SBOM: services/ai-compliance-sdk/SBOM.md
- API:
- Backend API: api/backend-api.md
- Entwicklung:
- Testing: development/testing.md
- Dokumentation: development/documentation.md
- CI/CD Pipeline: development/ci-cd-pipeline.md

File diff suppressed because it is too large Load Diff

66
admin-v2/run-ingestion.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# ============================================================
# RAG DACH Ingestion — Nur Ingestion (Builds schon fertig)
# ============================================================
PROJ="/Users/benjaminadmin/Projekte/breakpilot-pwa"
DOCKER="/usr/local/bin/docker"
COMPOSE="$DOCKER compose -f $PROJ/docker-compose.yml"
LOG_FILE="$PROJ/ingest-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "============================================================"
echo "RAG DACH Ingestion — Start: $(date)"
echo "Logfile: $LOG_FILE"
echo "============================================================"
# Health Check (via docker exec, Port nicht auf Host exponiert)
echo ""
echo "[1/5] Pruefe klausur-service..."
if ! $COMPOSE exec -T klausur-service python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8086/health')" 2>/dev/null; then
echo "FEHLER: klausur-service nicht erreichbar!"
exit 1
fi
echo "klausur-service ist bereit."
# P1 — Deutschland
echo ""
echo "[2/5] Ingestion P1 — Deutschland (7 Gesetze)..."
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
DE_DDG DE_BGB_AGB DE_EGBGB DE_UWG DE_HGB_RET DE_AO_RET DE_TKG 2>&1 || echo "DE P1 hatte Fehler"
# P1 — Oesterreich
echo ""
echo "[3/5] Ingestion P1 — Oesterreich (7 Gesetze)..."
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
AT_ECG AT_TKG AT_KSCHG AT_FAGG AT_UGB_RET AT_BAO_RET AT_MEDIENG 2>&1 || echo "AT P1 hatte Fehler"
# P1 — Schweiz
echo ""
echo "[4/5] Ingestion P1 — Schweiz (4 Gesetze)..."
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
CH_DSV CH_OR_AGB CH_UWG CH_FMG 2>&1 || echo "CH P1 hatte Fehler"
# 3 fehlgeschlagene Quellen + P2 + P3
echo ""
echo "[5/5] Ingestion P2/P3 + Fixes (14 Gesetze)..."
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \
LU_DPA_LAW DK_DATABESKYTTELSESLOVEN EDPB_GUIDELINES_1_2022 \
DE_PANGV DE_DLINFOV DE_BETRVG \
AT_ABGB_AGB AT_UWG \
CH_GEBUV CH_ZERTES \
DE_GESCHGEHG DE_BSIG DE_USTG_RET CH_ZGB_PERS 2>&1 || echo "P2/P3 hatte Fehler"
# Status
echo ""
echo "============================================================"
echo "FINAL STATUS CHECK"
echo "============================================================"
$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --status 2>&1
echo ""
echo "============================================================"
echo "Fertig: $(date)"
echo "Logfile: $LOG_FILE"
echo "============================================================"

View File

@@ -0,0 +1,80 @@
# Investor Agent — BreakPilot ComplAI
## Identitaet
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
potenziellen Investoren ueber das Unternehmen, das Produkt, den Markt und die Finanzprognosen.
Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
## Kernprinzipien
- **Datengetrieben**: Beziehe dich immer auf die bereitgestellten Unternehmensdaten
- **Praezise**: Nenne immer konkrete Zahlen, Prozentsaetze und Zeitraeume
- **Begeisternd aber ehrlich**: Stelle das Unternehmen positiv dar, ohne zu uebertreiben
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird (Deutsch oder Englisch)
## Kernbotschaften (IMMER betonen wenn passend)
1. **AI-First Geschaeftsmodell**: "Wir loesen alles mit KI was moeglich ist — kein klassischer Support, kein grosses Sales-Team. Unser 1000b Cloud-LLM bearbeitet Kundenanfragen vollstaendig autonom."
2. **Skalierbarkeit**: "10x Kunden bedeutet NICHT 10x Personal. Die KI skaliert mit — deshalb steigen unsere Kosten nur linear, waehrend der Umsatz exponentiell waechst."
3. **Hardware-Differenzierung**: "Datensouveraenitaet durch Self-Hosting auf Apple-Hardware im Serverraum des Kunden. Kein Byte verlaesst das Unternehmen. Das kann keiner unserer Wettbewerber."
4. **Kostenstruktur**: "Minimale Personalkosten durch AI-First-Ansatz. Nur Engineering + Recht, kein klassischer Vertrieb. 18 Mitarbeiter in 2030 bei 8.4 Mio EUR Umsatz."
5. **Marktchance**: "12.4 Mrd EUR TAM mit zweistelligem Wachstum. DSGVO, AI Act und NIS2 zwingen Unternehmen zum Handeln — der Markt waechst regulatorisch getrieben."
## Kommunikationsstil
- Professionell, knapp und ueberzeugend
- Wie ein Top-Gruender im Investorengespraech
- Strukturierte Antworten mit klaren Abschnitten
- Zahlen hervorheben und kontextualisieren
- Maximal 3-4 Absaetze pro Antwort
- Deutsch oder Englisch, je nach Frage
## IP-Schutz-Layer (KRITISCH — NIEMALS verletzen!)
### NIEMALS offenbaren:
- Exakte Modellnamen (z.B. "Qwen", "Ollama", "LLaMA")
- Spezifische Frameworks oder Bibliotheken (z.B. "Next.js", "FastAPI", "PostgreSQL")
- Code-Architektur, Datenbankschema oder API-Struktur
- Sicherheitsimplementierung oder Verschluesselung-Details
- Interne Tooling-Details oder DevOps-Stack
- Docker/Container-Architektur
- Spezifische Cloud-Provider-Namen
### Stattdessen verwenden (Abstraktionsebene):
- "Proprietaere KI-Engine" statt spezifischer Modellnamen
- "Self-Hosted Appliance auf Apple-Hardware" statt "Mac Mini mit Ollama"
- "BSI-zertifizierte deutsche Cloud-Infrastruktur" statt Provider-Details
- "Fortgeschrittene PII-Erkennung" statt Algorithmus-Details
- "Enterprise-Grade Verschluesselung" statt Protokoll-Details
- "Modulare Microservice-Architektur" statt Stack-Details
### Erlaubt zu diskutieren:
- Geschaeftsmodell und Preise
- Marktdaten und TAM/SAM/SOM
- Features auf Produktebene
- Team und Kompetenzen
- Finanzprognosen und Unit Economics
- Wettbewerbsvergleich auf Feature-Ebene
- Use of Funds
- Hardware-Spezifikationen (oeffentlich verfuegbar: Mac Mini, Mac Studio)
- LLM-Groessen in Parametern (32b, 40b, 1000b)
## Datenzugriff
Du erhaeltst alle Unternehmensdaten als Kontext. Nutze diese Daten fuer praezise Antworten.
Sage nie "Ich weiss es nicht" wenn die Information in den Daten verfuegbar ist.
## Beispiel-Interaktionen
**Frage:** "Wie skaliert das Geschaeftsmodell?"
**Antwort:** Unser AI-First-Ansatz bedeutet: Skalierung ohne lineares Personalwachstum. Waehrend der Umsatz von 36k EUR (2026) auf 8.4 Mio EUR (2030) steigt, waechst das Team nur von 2 auf 18 Personen. Der Schluessel ist unser 1000b Cloud-LLM, das Kundenanfragen vollstaendig autonom bearbeitet — kein klassischer Customer Support noetig. Das ergibt 800 Kunden pro 18 Mitarbeiter, waehrend Wettbewerber wie DataGuard 4.000 Kunden mit hunderten Mitarbeitern betreuen.
**Frage:** "What's the exit strategy?"
**Answer:** Multiple exit paths: (1) Strategic acquisition by a major compliance player (Proliance, OneTrust) seeking self-hosted AI capabilities — our unique hardware moat makes us an attractive target. (2) PE buyout once we reach 3M+ ARR with proven unit economics. (3) IPO path if we achieve category leadership in DACH. The compliance market is consolidating, with recent exits at 8-15x ARR multiples.
## Einschraenkungen
- Keine Rechtsberatung geben
- Keine Garantien fuer Renditen oder Exits
- Bei technischen Detailfragen: Auf IP-Schutz-Layer verweisen
- Bei Fragen ausserhalb des Kompetenzbereichs: "Dazu wuerde ich gerne ein separates Gespraech mit unserem Gruenderteam arrangieren."

View File

@@ -15,8 +15,12 @@ import (
"github.com/breakpilot/ai-compliance-sdk/internal/dsgvo"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/incidents"
"github.com/breakpilot/ai-compliance-sdk/internal/roadmap"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
"github.com/breakpilot/ai-compliance-sdk/internal/whistleblower"
"github.com/breakpilot/ai-compliance-sdk/internal/vendor"
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
"github.com/gin-contrib/cors"
@@ -59,6 +63,10 @@ func main() {
roadmapStore := roadmap.NewStore(pool)
workshopStore := workshop.NewStore(pool)
portfolioStore := portfolio.NewStore(pool)
academyStore := academy.NewStore(pool)
whistleblowerStore := whistleblower.NewStore(pool)
incidentStore := incidents.NewStore(pool)
vendorStore := vendor.NewStore(pool)
// Initialize services
rbacService := rbac.NewService(rbacStore)
@@ -98,6 +106,10 @@ func main() {
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
draftingHandlers := handlers.NewDraftingHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder)
academyHandlers := handlers.NewAcademyHandlers(academyStore)
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
incidentHandlers := handlers.NewIncidentHandlers(incidentStore)
vendorHandlers := handlers.NewVendorHandlers(vendorStore)
// Initialize middleware
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
@@ -435,6 +447,129 @@ func main() {
draftingRoutes.POST("/validate", draftingHandlers.ValidateDocument)
draftingRoutes.GET("/history", draftingHandlers.GetDraftHistory)
}
// Academy routes - E-Learning / Compliance Training
academyRoutes := v1.Group("/academy")
{
// Courses
academyRoutes.POST("/courses", academyHandlers.CreateCourse)
academyRoutes.GET("/courses", academyHandlers.ListCourses)
academyRoutes.GET("/courses/:id", academyHandlers.GetCourse)
academyRoutes.PUT("/courses/:id", academyHandlers.UpdateCourse)
academyRoutes.DELETE("/courses/:id", academyHandlers.DeleteCourse)
// Enrollments
academyRoutes.POST("/enrollments", academyHandlers.CreateEnrollment)
academyRoutes.GET("/enrollments", academyHandlers.ListEnrollments)
academyRoutes.PUT("/enrollments/:id/progress", academyHandlers.UpdateProgress)
academyRoutes.POST("/enrollments/:id/complete", academyHandlers.CompleteEnrollment)
// Certificates
academyRoutes.GET("/certificates/:id", academyHandlers.GetCertificate)
academyRoutes.POST("/enrollments/:id/certificate", academyHandlers.GenerateCertificate)
// Quiz
academyRoutes.POST("/courses/:id/quiz", academyHandlers.SubmitQuiz)
// Statistics
academyRoutes.GET("/stats", academyHandlers.GetStatistics)
}
// Whistleblower routes - Hinweisgebersystem (HinSchG)
whistleblowerRoutes := v1.Group("/whistleblower")
{
// Public endpoints (anonymous reporting)
whistleblowerRoutes.POST("/reports/submit", whistleblowerHandlers.SubmitReport)
whistleblowerRoutes.GET("/reports/access/:accessKey", whistleblowerHandlers.GetReportByAccessKey)
whistleblowerRoutes.POST("/reports/access/:accessKey/messages", whistleblowerHandlers.SendPublicMessage)
// Admin endpoints
whistleblowerRoutes.GET("/reports", whistleblowerHandlers.ListReports)
whistleblowerRoutes.GET("/reports/:id", whistleblowerHandlers.GetReport)
whistleblowerRoutes.PUT("/reports/:id", whistleblowerHandlers.UpdateReport)
whistleblowerRoutes.DELETE("/reports/:id", whistleblowerHandlers.DeleteReport)
whistleblowerRoutes.POST("/reports/:id/acknowledge", whistleblowerHandlers.AcknowledgeReport)
whistleblowerRoutes.POST("/reports/:id/investigate", whistleblowerHandlers.StartInvestigation)
whistleblowerRoutes.POST("/reports/:id/measures", whistleblowerHandlers.AddMeasure)
whistleblowerRoutes.POST("/reports/:id/close", whistleblowerHandlers.CloseReport)
whistleblowerRoutes.POST("/reports/:id/messages", whistleblowerHandlers.SendAdminMessage)
whistleblowerRoutes.GET("/reports/:id/messages", whistleblowerHandlers.ListMessages)
// Statistics
whistleblowerRoutes.GET("/stats", whistleblowerHandlers.GetStatistics)
}
// Incidents routes - Datenpannen-Management (DSGVO Art. 33/34)
incidentRoutes := v1.Group("/incidents")
{
// Incident CRUD
incidentRoutes.POST("", incidentHandlers.CreateIncident)
incidentRoutes.GET("", incidentHandlers.ListIncidents)
incidentRoutes.GET("/:id", incidentHandlers.GetIncident)
incidentRoutes.PUT("/:id", incidentHandlers.UpdateIncident)
incidentRoutes.DELETE("/:id", incidentHandlers.DeleteIncident)
// Risk Assessment
incidentRoutes.POST("/:id/assess-risk", incidentHandlers.AssessRisk)
// Authority Notification (Art. 33)
incidentRoutes.POST("/:id/notify-authority", incidentHandlers.SubmitAuthorityNotification)
// Data Subject Notification (Art. 34)
incidentRoutes.POST("/:id/notify-subjects", incidentHandlers.NotifyDataSubjects)
// Measures
incidentRoutes.POST("/:id/measures", incidentHandlers.AddMeasure)
incidentRoutes.PUT("/:id/measures/:measureId", incidentHandlers.UpdateMeasure)
incidentRoutes.POST("/:id/measures/:measureId/complete", incidentHandlers.CompleteMeasure)
// Timeline
incidentRoutes.POST("/:id/timeline", incidentHandlers.AddTimelineEntry)
// Lifecycle
incidentRoutes.POST("/:id/close", incidentHandlers.CloseIncident)
// Statistics
incidentRoutes.GET("/stats", incidentHandlers.GetStatistics)
}
// Vendor Compliance routes - Vendor Management & AVV/DPA (DSGVO Art. 28)
vendorRoutes := v1.Group("/vendors")
{
// Vendor CRUD
vendorRoutes.POST("", vendorHandlers.CreateVendor)
vendorRoutes.GET("", vendorHandlers.ListVendors)
vendorRoutes.GET("/:id", vendorHandlers.GetVendor)
vendorRoutes.PUT("/:id", vendorHandlers.UpdateVendor)
vendorRoutes.DELETE("/:id", vendorHandlers.DeleteVendor)
// Contracts (AVV/DPA)
vendorRoutes.POST("/contracts", vendorHandlers.CreateContract)
vendorRoutes.GET("/contracts", vendorHandlers.ListContracts)
vendorRoutes.GET("/contracts/:id", vendorHandlers.GetContract)
vendorRoutes.PUT("/contracts/:id", vendorHandlers.UpdateContract)
vendorRoutes.DELETE("/contracts/:id", vendorHandlers.DeleteContract)
// Findings
vendorRoutes.POST("/findings", vendorHandlers.CreateFinding)
vendorRoutes.GET("/findings", vendorHandlers.ListFindings)
vendorRoutes.GET("/findings/:id", vendorHandlers.GetFinding)
vendorRoutes.PUT("/findings/:id", vendorHandlers.UpdateFinding)
vendorRoutes.POST("/findings/:id/resolve", vendorHandlers.ResolveFinding)
// Control Instances
vendorRoutes.POST("/controls", vendorHandlers.UpsertControlInstance)
vendorRoutes.GET("/controls", vendorHandlers.ListControlInstances)
// Templates
vendorRoutes.GET("/templates", vendorHandlers.ListTemplates)
vendorRoutes.GET("/templates/:templateId", vendorHandlers.GetTemplate)
vendorRoutes.POST("/templates", vendorHandlers.CreateTemplate)
vendorRoutes.POST("/templates/:templateId/apply", vendorHandlers.ApplyTemplate)
// Statistics
vendorRoutes.GET("/stats", vendorHandlers.GetStatistics)
}
}
// Create HTTP server

View File

@@ -0,0 +1,226 @@
package academy
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// CourseCategory represents the category of a compliance course
type CourseCategory string
const (
CourseCategoryDSGVOBasics CourseCategory = "dsgvo_basics"
CourseCategoryITSecurity CourseCategory = "it_security"
CourseCategoryAILiteracy CourseCategory = "ai_literacy"
CourseCategoryWhistleblowerProtection CourseCategory = "whistleblower_protection"
CourseCategoryCustom CourseCategory = "custom"
)
// EnrollmentStatus represents the status of an enrollment
type EnrollmentStatus string
const (
EnrollmentStatusNotStarted EnrollmentStatus = "not_started"
EnrollmentStatusInProgress EnrollmentStatus = "in_progress"
EnrollmentStatusCompleted EnrollmentStatus = "completed"
EnrollmentStatusExpired EnrollmentStatus = "expired"
)
// LessonType represents the type of a lesson
type LessonType string
const (
LessonTypeVideo LessonType = "video"
LessonTypeText LessonType = "text"
LessonTypeQuiz LessonType = "quiz"
LessonTypeInteractive LessonType = "interactive"
)
// ============================================================================
// Main Entities
// ============================================================================
// Course represents a compliance training course
type Course struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category CourseCategory `json:"category"`
DurationMinutes int `json:"duration_minutes"`
RequiredForRoles []string `json:"required_for_roles"` // JSONB in DB
Lessons []Lesson `json:"lessons,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Lesson represents a single lesson within a course
type Lesson struct {
ID uuid.UUID `json:"id"`
CourseID uuid.UUID `json:"course_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
LessonType LessonType `json:"lesson_type"`
ContentURL string `json:"content_url,omitempty"`
DurationMinutes int `json:"duration_minutes"`
OrderIndex int `json:"order_index"`
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"` // JSONB in DB
}
// QuizQuestion represents a single quiz question embedded in a lesson
type QuizQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation"`
}
// Enrollment represents a user's enrollment in a course
type Enrollment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
CourseID uuid.UUID `json:"course_id"`
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Status EnrollmentStatus `json:"status"`
ProgressPercent int `json:"progress_percent"`
CurrentLessonIndex int `json:"current_lesson_index"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Certificate represents a completion certificate for an enrollment
type Certificate struct {
ID uuid.UUID `json:"id"`
EnrollmentID uuid.UUID `json:"enrollment_id"`
UserName string `json:"user_name"`
CourseTitle string `json:"course_title"`
IssuedAt time.Time `json:"issued_at"`
ValidUntil *time.Time `json:"valid_until,omitempty"`
PDFURL string `json:"pdf_url,omitempty"`
}
// AcademyStatistics contains aggregated academy metrics
type AcademyStatistics struct {
TotalCourses int `json:"total_courses"`
TotalEnrollments int `json:"total_enrollments"`
CompletionRate float64 `json:"completion_rate"` // 0-100
OverdueCount int `json:"overdue_count"`
AvgCompletionDays float64 `json:"avg_completion_days"`
}
// ============================================================================
// Filter Types
// ============================================================================
// CourseFilters defines filters for listing courses
type CourseFilters struct {
Category CourseCategory
IsActive *bool
Search string
Limit int
Offset int
}
// EnrollmentFilters defines filters for listing enrollments
type EnrollmentFilters struct {
CourseID *uuid.UUID
UserID *uuid.UUID
Status EnrollmentStatus
Limit int
Offset int
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateCourseRequest is the API request for creating a course
type CreateCourseRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
Category CourseCategory `json:"category" binding:"required"`
DurationMinutes int `json:"duration_minutes"`
RequiredForRoles []string `json:"required_for_roles,omitempty"`
Lessons []CreateLessonRequest `json:"lessons,omitempty"`
}
// CreateLessonRequest is the API request for creating a lesson
type CreateLessonRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
LessonType LessonType `json:"lesson_type" binding:"required"`
ContentURL string `json:"content_url,omitempty"`
DurationMinutes int `json:"duration_minutes"`
OrderIndex int `json:"order_index"`
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"`
}
// UpdateCourseRequest is the API request for updating a course
type UpdateCourseRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Category *CourseCategory `json:"category,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
RequiredForRoles []string `json:"required_for_roles,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
// EnrollUserRequest is the API request for enrolling a user in a course
type EnrollUserRequest struct {
CourseID uuid.UUID `json:"course_id" binding:"required"`
UserID uuid.UUID `json:"user_id" binding:"required"`
UserName string `json:"user_name" binding:"required"`
UserEmail string `json:"user_email" binding:"required"`
Deadline *time.Time `json:"deadline,omitempty"`
}
// UpdateProgressRequest is the API request for updating enrollment progress
type UpdateProgressRequest struct {
Progress int `json:"progress" binding:"required"`
CurrentLesson int `json:"current_lesson"`
}
// SubmitQuizRequest is the API request for submitting quiz answers
type SubmitQuizRequest struct {
LessonID uuid.UUID `json:"lesson_id" binding:"required"`
Answers []int `json:"answers" binding:"required"` // Index of selected answer per question
}
// SubmitQuizResponse is the API response for quiz submission
type SubmitQuizResponse struct {
Score int `json:"score"` // 0-100
Passed bool `json:"passed"`
CorrectAnswers int `json:"correct_answers"`
TotalQuestions int `json:"total_questions"`
Results []QuizResult `json:"results"`
}
// QuizResult represents the result for a single quiz question
type QuizResult struct {
Question string `json:"question"`
Correct bool `json:"correct"`
Explanation string `json:"explanation"`
}
// CourseListResponse is the API response for listing courses
type CourseListResponse struct {
Courses []Course `json:"courses"`
Total int `json:"total"`
}
// EnrollmentListResponse is the API response for listing enrollments
type EnrollmentListResponse struct {
Enrollments []Enrollment `json:"enrollments"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,666 @@
package academy
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles academy data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new academy store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Course CRUD Operations
// ============================================================================
// CreateCourse creates a new course
func (s *Store) CreateCourse(ctx context.Context, course *Course) error {
course.ID = uuid.New()
course.CreatedAt = time.Now().UTC()
course.UpdatedAt = course.CreatedAt
if !course.IsActive {
course.IsActive = true
}
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_courses (
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10
)
`,
course.ID, course.TenantID, course.Title, course.Description, string(course.Category),
course.DurationMinutes, requiredForRoles, course.IsActive,
course.CreatedAt, course.UpdatedAt,
)
return err
}
// GetCourse retrieves a course by ID
func (s *Store) GetCourse(ctx context.Context, id uuid.UUID) (*Course, error) {
var course Course
var category string
var requiredForRoles []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
FROM academy_courses WHERE id = $1
`, id).Scan(
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
&course.CreatedAt, &course.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
course.Category = CourseCategory(category)
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
// Load lessons for this course
lessons, err := s.ListLessons(ctx, course.ID)
if err != nil {
return nil, err
}
course.Lessons = lessons
return &course, nil
}
// ListCourses lists courses for a tenant with optional filters
func (s *Store) ListCourses(ctx context.Context, tenantID uuid.UUID, filters *CourseFilters) ([]Course, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
// List query
query := `
SELECT
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
FROM academy_courses WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
if filters.IsActive != nil {
query += fmt.Sprintf(" AND is_active = $%d", argIdx)
args = append(args, *filters.IsActive)
argIdx++
countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx)
countArgs = append(countArgs, *filters.IsActive)
countArgIdx++
}
if filters.Search != "" {
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+filters.Search+"%")
argIdx++
countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", countArgIdx, countArgIdx)
countArgs = append(countArgs, "%"+filters.Search+"%")
countArgIdx++
}
}
// Get total count
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var courses []Course
for rows.Next() {
var course Course
var category string
var requiredForRoles []byte
err := rows.Scan(
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
&course.CreatedAt, &course.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
course.Category = CourseCategory(category)
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
courses = append(courses, course)
}
if courses == nil {
courses = []Course{}
}
return courses, total, nil
}
// UpdateCourse updates a course
func (s *Store) UpdateCourse(ctx context.Context, course *Course) error {
course.UpdatedAt = time.Now().UTC()
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
_, err := s.pool.Exec(ctx, `
UPDATE academy_courses SET
title = $2, description = $3, category = $4,
duration_minutes = $5, required_for_roles = $6, is_active = $7,
updated_at = $8
WHERE id = $1
`,
course.ID, course.Title, course.Description, string(course.Category),
course.DurationMinutes, requiredForRoles, course.IsActive,
course.UpdatedAt,
)
return err
}
// DeleteCourse deletes a course and its related data (via CASCADE)
func (s *Store) DeleteCourse(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM academy_courses WHERE id = $1", id)
return err
}
// ============================================================================
// Lesson Operations
// ============================================================================
// CreateLesson creates a new lesson
func (s *Store) CreateLesson(ctx context.Context, lesson *Lesson) error {
lesson.ID = uuid.New()
quizQuestions, _ := json.Marshal(lesson.QuizQuestions)
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_lessons (
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
lesson.ID, lesson.CourseID, lesson.Title, lesson.Description, string(lesson.LessonType),
lesson.ContentURL, lesson.DurationMinutes, lesson.OrderIndex, quizQuestions,
)
return err
}
// ListLessons lists lessons for a course ordered by order_index
func (s *Store) ListLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
FROM academy_lessons WHERE course_id = $1
ORDER BY order_index ASC
`, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var lessons []Lesson
for rows.Next() {
var lesson Lesson
var lessonType string
var quizQuestions []byte
err := rows.Scan(
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
)
if err != nil {
return nil, err
}
lesson.LessonType = LessonType(lessonType)
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
if lesson.QuizQuestions == nil {
lesson.QuizQuestions = []QuizQuestion{}
}
lessons = append(lessons, lesson)
}
if lessons == nil {
lessons = []Lesson{}
}
return lessons, nil
}
// GetLesson retrieves a single lesson by ID
func (s *Store) GetLesson(ctx context.Context, id uuid.UUID) (*Lesson, error) {
var lesson Lesson
var lessonType string
var quizQuestions []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
FROM academy_lessons WHERE id = $1
`, id).Scan(
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
lesson.LessonType = LessonType(lessonType)
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
if lesson.QuizQuestions == nil {
lesson.QuizQuestions = []QuizQuestion{}
}
return &lesson, nil
}
// ============================================================================
// Enrollment Operations
// ============================================================================
// CreateEnrollment creates a new enrollment
func (s *Store) CreateEnrollment(ctx context.Context, enrollment *Enrollment) error {
enrollment.ID = uuid.New()
enrollment.CreatedAt = time.Now().UTC()
enrollment.UpdatedAt = enrollment.CreatedAt
if enrollment.Status == "" {
enrollment.Status = EnrollmentStatusNotStarted
}
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_enrollments (
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
$10, $11, $12,
$13, $14
)
`,
enrollment.ID, enrollment.TenantID, enrollment.CourseID, enrollment.UserID, enrollment.UserName, enrollment.UserEmail,
string(enrollment.Status), enrollment.ProgressPercent, enrollment.CurrentLessonIndex,
enrollment.StartedAt, enrollment.CompletedAt, enrollment.Deadline,
enrollment.CreatedAt, enrollment.UpdatedAt,
)
return err
}
// GetEnrollment retrieves an enrollment by ID
func (s *Store) GetEnrollment(ctx context.Context, id uuid.UUID) (*Enrollment, error) {
var enrollment Enrollment
var status string
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
FROM academy_enrollments WHERE id = $1
`, id).Scan(
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
&enrollment.CreatedAt, &enrollment.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
enrollment.Status = EnrollmentStatus(status)
return &enrollment, nil
}
// ListEnrollments lists enrollments for a tenant with optional filters
func (s *Store) ListEnrollments(ctx context.Context, tenantID uuid.UUID, filters *EnrollmentFilters) ([]Enrollment, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
// List query
query := `
SELECT
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
FROM academy_enrollments WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.CourseID != nil {
query += fmt.Sprintf(" AND course_id = $%d", argIdx)
args = append(args, *filters.CourseID)
argIdx++
countQuery += fmt.Sprintf(" AND course_id = $%d", countArgIdx)
countArgs = append(countArgs, *filters.CourseID)
countArgIdx++
}
if filters.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filters.UserID)
argIdx++
countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx)
countArgs = append(countArgs, *filters.UserID)
countArgIdx++
}
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
}
// Get total count
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var enrollments []Enrollment
for rows.Next() {
var enrollment Enrollment
var status string
err := rows.Scan(
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
&enrollment.CreatedAt, &enrollment.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
enrollment.Status = EnrollmentStatus(status)
enrollments = append(enrollments, enrollment)
}
if enrollments == nil {
enrollments = []Enrollment{}
}
return enrollments, total, nil
}
// UpdateEnrollmentProgress updates the progress for an enrollment
func (s *Store) UpdateEnrollmentProgress(ctx context.Context, id uuid.UUID, progress int, currentLesson int) error {
now := time.Now().UTC()
// If progress > 0, set started_at if not already set and update status to in_progress
_, err := s.pool.Exec(ctx, `
UPDATE academy_enrollments SET
progress_percent = $2,
current_lesson_index = $3,
status = CASE
WHEN $2 >= 100 THEN 'completed'
WHEN $2 > 0 THEN 'in_progress'
ELSE status
END,
started_at = CASE
WHEN started_at IS NULL AND $2 > 0 THEN $4
ELSE started_at
END,
completed_at = CASE
WHEN $2 >= 100 THEN $4
ELSE completed_at
END,
updated_at = $4
WHERE id = $1
`, id, progress, currentLesson, now)
return err
}
// CompleteEnrollment marks an enrollment as completed
func (s *Store) CompleteEnrollment(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE academy_enrollments SET
status = 'completed',
progress_percent = 100,
completed_at = $2,
updated_at = $2
WHERE id = $1
`, id, now)
return err
}
// ============================================================================
// Certificate Operations
// ============================================================================
// GetCertificate retrieves a certificate by ID
func (s *Store) GetCertificate(ctx context.Context, id uuid.UUID) (*Certificate, error) {
var cert Certificate
err := s.pool.QueryRow(ctx, `
SELECT
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
FROM academy_certificates WHERE id = $1
`, id).Scan(
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &cert, nil
}
// GetCertificateByEnrollment retrieves a certificate by enrollment ID
func (s *Store) GetCertificateByEnrollment(ctx context.Context, enrollmentID uuid.UUID) (*Certificate, error) {
var cert Certificate
err := s.pool.QueryRow(ctx, `
SELECT
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
FROM academy_certificates WHERE enrollment_id = $1
`, enrollmentID).Scan(
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &cert, nil
}
// CreateCertificate creates a new certificate
func (s *Store) CreateCertificate(ctx context.Context, cert *Certificate) error {
cert.ID = uuid.New()
cert.IssuedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_certificates (
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
) VALUES (
$1, $2, $3, $4,
$5, $6, $7
)
`,
cert.ID, cert.EnrollmentID, cert.UserName, cert.CourseTitle,
cert.IssuedAt, cert.ValidUntil, cert.PDFURL,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated academy statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*AcademyStatistics, error) {
stats := &AcademyStatistics{}
// Total active courses
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1 AND is_active = true",
tenantID).Scan(&stats.TotalCourses)
// Total enrollments
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalEnrollments)
// Completion rate
if stats.TotalEnrollments > 0 {
var completed int
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed'",
tenantID).Scan(&completed)
stats.CompletionRate = float64(completed) / float64(stats.TotalEnrollments) * 100
}
// Overdue count (past deadline, not completed)
s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM academy_enrollments
WHERE tenant_id = $1
AND status NOT IN ('completed', 'expired')
AND deadline IS NOT NULL
AND deadline < NOW()`,
tenantID).Scan(&stats.OverdueCount)
// Average completion days
s.pool.QueryRow(ctx,
`SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0)
FROM academy_enrollments
WHERE tenant_id = $1
AND status = 'completed'
AND started_at IS NOT NULL
AND completed_at IS NOT NULL`,
tenantID).Scan(&stats.AvgCompletionDays)
return stats, nil
}

View File

@@ -0,0 +1,587 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AcademyHandlers handles academy HTTP requests
type AcademyHandlers struct {
store *academy.Store
}
// NewAcademyHandlers creates new academy handlers
func NewAcademyHandlers(store *academy.Store) *AcademyHandlers {
return &AcademyHandlers{store: store}
}
// ============================================================================
// Course Management
// ============================================================================
// CreateCourse creates a new compliance training course
// POST /sdk/v1/academy/courses
func (h *AcademyHandlers) CreateCourse(c *gin.Context) {
var req academy.CreateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
course := &academy.Course{
TenantID: tenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
DurationMinutes: req.DurationMinutes,
RequiredForRoles: req.RequiredForRoles,
IsActive: true,
}
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Create lessons if provided
for i := range req.Lessons {
lesson := &academy.Lesson{
CourseID: course.ID,
Title: req.Lessons[i].Title,
Description: req.Lessons[i].Description,
LessonType: req.Lessons[i].LessonType,
ContentURL: req.Lessons[i].ContentURL,
DurationMinutes: req.Lessons[i].DurationMinutes,
OrderIndex: req.Lessons[i].OrderIndex,
QuizQuestions: req.Lessons[i].QuizQuestions,
}
if err := h.store.CreateLesson(c.Request.Context(), lesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
course.Lessons = append(course.Lessons, *lesson)
}
if course.Lessons == nil {
course.Lessons = []academy.Lesson{}
}
c.JSON(http.StatusCreated, gin.H{"course": course})
}
// GetCourse retrieves a course with its lessons
// GET /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) GetCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
course, err := h.store.GetCourse(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
c.JSON(http.StatusOK, gin.H{"course": course})
}
// ListCourses lists courses for the current tenant
// GET /sdk/v1/academy/courses
func (h *AcademyHandlers) ListCourses(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.CourseFilters{
Limit: 50,
}
if category := c.Query("category"); category != "" {
filters.Category = academy.CourseCategory(category)
}
if search := c.Query("search"); search != "" {
filters.Search = search
}
if activeStr := c.Query("is_active"); activeStr != "" {
active := activeStr == "true"
filters.IsActive = &active
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
courses, total, err := h.store.ListCourses(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.CourseListResponse{
Courses: courses,
Total: total,
})
}
// UpdateCourse updates a course
// PUT /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) UpdateCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
course, err := h.store.GetCourse(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
var req academy.UpdateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
course.Title = *req.Title
}
if req.Description != nil {
course.Description = *req.Description
}
if req.Category != nil {
course.Category = *req.Category
}
if req.DurationMinutes != nil {
course.DurationMinutes = *req.DurationMinutes
}
if req.RequiredForRoles != nil {
course.RequiredForRoles = req.RequiredForRoles
}
if req.IsActive != nil {
course.IsActive = *req.IsActive
}
if err := h.store.UpdateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"course": course})
}
// DeleteCourse deletes a course
// DELETE /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) DeleteCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
if err := h.store.DeleteCourse(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "course deleted"})
}
// ============================================================================
// Enrollment Management
// ============================================================================
// CreateEnrollment enrolls a user in a course
// POST /sdk/v1/academy/enrollments
func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) {
var req academy.EnrollUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
// Verify course exists
course, err := h.store.GetCourse(c.Request.Context(), req.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
enrollment := &academy.Enrollment{
TenantID: tenantID,
CourseID: req.CourseID,
UserID: req.UserID,
UserName: req.UserName,
UserEmail: req.UserEmail,
Status: academy.EnrollmentStatusNotStarted,
Deadline: req.Deadline,
}
if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment})
}
// ListEnrollments lists enrollments for the current tenant
// GET /sdk/v1/academy/enrollments
func (h *AcademyHandlers) ListEnrollments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.EnrollmentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = academy.EnrollmentStatus(status)
}
if courseIDStr := c.Query("course_id"); courseIDStr != "" {
if courseID, err := uuid.Parse(courseIDStr); err == nil {
filters.CourseID = &courseID
}
}
if userIDStr := c.Query("user_id"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
filters.UserID = &userID
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.EnrollmentListResponse{
Enrollments: enrollments,
Total: total,
})
}
// UpdateProgress updates an enrollment's progress
// PUT /sdk/v1/academy/enrollments/:id/progress
func (h *AcademyHandlers) UpdateProgress(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
var req academy.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Progress < 0 || req.Progress > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"})
return
}
if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enrollment": updated})
}
// CompleteEnrollment marks an enrollment as completed
// POST /sdk/v1/academy/enrollments/:id/complete
func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status == academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"})
return
}
if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"enrollment": updated,
"message": "enrollment completed",
})
}
// ============================================================================
// Certificate Management
// ============================================================================
// GetCertificate retrieves a certificate
// GET /sdk/v1/academy/certificates/:id
func (h *AcademyHandlers) GetCertificate(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
c.JSON(http.StatusOK, gin.H{"certificate": cert})
}
// GenerateCertificate generates a certificate for a completed enrollment
// POST /sdk/v1/academy/enrollments/:id/certificate
func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status != academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"})
return
}
// Check if certificate already exists
existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if existing != nil {
c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"})
return
}
// Get the course for the certificate title
course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
courseTitle := "Unknown Course"
if course != nil {
courseTitle = course.Title
}
// Certificate is valid for 1 year by default
validUntil := time.Now().UTC().AddDate(1, 0, 0)
cert := &academy.Certificate{
EnrollmentID: enrollmentID,
UserName: enrollment.UserName,
CourseTitle: courseTitle,
ValidUntil: &validUntil,
}
if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"certificate": cert})
}
// ============================================================================
// Quiz Submission
// ============================================================================
// SubmitQuiz submits quiz answers and returns the results
// POST /sdk/v1/academy/enrollments/:id/quiz
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
var req academy.SubmitQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify enrollment exists
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
// Get the lesson with quiz questions
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
// Grade the quiz
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
// Pass threshold: 70%
passed := score >= 70
response := academy.SubmitQuizResponse{
Score: score,
Passed: passed,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
}
c.JSON(http.StatusOK, response)
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns academy statistics for the current tenant
// GET /sdk/v1/academy/statistics
func (h *AcademyHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,668 @@
package handlers
import (
"fmt"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/incidents"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// IncidentHandlers handles incident/breach management HTTP requests
type IncidentHandlers struct {
store *incidents.Store
}
// NewIncidentHandlers creates new incident handlers
func NewIncidentHandlers(store *incidents.Store) *IncidentHandlers {
return &IncidentHandlers{store: store}
}
// ============================================================================
// Incident CRUD
// ============================================================================
// CreateIncident creates a new incident
// POST /sdk/v1/incidents
func (h *IncidentHandlers) CreateIncident(c *gin.Context) {
var req incidents.CreateIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
detectedAt := time.Now().UTC()
if req.DetectedAt != nil {
detectedAt = *req.DetectedAt
}
// Auto-calculate 72h deadline per DSGVO Art. 33
deadline := incidents.Calculate72hDeadline(detectedAt)
incident := &incidents.Incident{
TenantID: tenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
Status: incidents.IncidentStatusDetected,
Severity: req.Severity,
DetectedAt: detectedAt,
ReportedBy: userID,
AffectedDataCategories: req.AffectedDataCategories,
AffectedDataSubjectCount: req.AffectedDataSubjectCount,
AffectedSystems: req.AffectedSystems,
AuthorityNotification: &incidents.AuthorityNotification{
Status: incidents.NotificationStatusPending,
Deadline: deadline,
},
DataSubjectNotification: &incidents.DataSubjectNotification{
Required: false,
Status: incidents.NotificationStatusNotRequired,
},
Timeline: []incidents.TimelineEntry{
{
Timestamp: time.Now().UTC(),
Action: "incident_created",
UserID: userID,
Details: "Incident detected and reported",
},
},
}
if err := h.store.CreateIncident(c.Request.Context(), incident); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"incident": incident,
"authority_deadline": deadline,
"hours_until_deadline": time.Until(deadline).Hours(),
})
}
// GetIncident retrieves an incident by ID
// GET /sdk/v1/incidents/:id
func (h *IncidentHandlers) GetIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
// Get measures
measures, _ := h.store.ListMeasures(c.Request.Context(), id)
// Calculate deadline info if authority notification exists
var deadlineInfo gin.H
if incident.AuthorityNotification != nil {
hoursRemaining := time.Until(incident.AuthorityNotification.Deadline).Hours()
deadlineInfo = gin.H{
"deadline": incident.AuthorityNotification.Deadline,
"hours_remaining": hoursRemaining,
"overdue": hoursRemaining < 0,
}
}
c.JSON(http.StatusOK, gin.H{
"incident": incident,
"measures": measures,
"deadline_info": deadlineInfo,
})
}
// ListIncidents lists incidents for a tenant
// GET /sdk/v1/incidents
func (h *IncidentHandlers) ListIncidents(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &incidents.IncidentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = incidents.IncidentStatus(status)
}
if severity := c.Query("severity"); severity != "" {
filters.Severity = incidents.IncidentSeverity(severity)
}
if category := c.Query("category"); category != "" {
filters.Category = incidents.IncidentCategory(category)
}
incidentList, total, err := h.store.ListIncidents(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, incidents.IncidentListResponse{
Incidents: incidentList,
Total: total,
})
}
// UpdateIncident updates an incident
// PUT /sdk/v1/incidents/:id
func (h *IncidentHandlers) UpdateIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.UpdateIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != "" {
incident.Title = req.Title
}
if req.Description != "" {
incident.Description = req.Description
}
if req.Category != "" {
incident.Category = req.Category
}
if req.Status != "" {
incident.Status = req.Status
}
if req.Severity != "" {
incident.Severity = req.Severity
}
if req.AffectedDataCategories != nil {
incident.AffectedDataCategories = req.AffectedDataCategories
}
if req.AffectedDataSubjectCount != nil {
incident.AffectedDataSubjectCount = *req.AffectedDataSubjectCount
}
if req.AffectedSystems != nil {
incident.AffectedSystems = req.AffectedSystems
}
if err := h.store.UpdateIncident(c.Request.Context(), incident); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"incident": incident})
}
// DeleteIncident deletes an incident
// DELETE /sdk/v1/incidents/:id
func (h *IncidentHandlers) DeleteIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
if err := h.store.DeleteIncident(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "incident deleted"})
}
// ============================================================================
// Risk Assessment
// ============================================================================
// AssessRisk performs a risk assessment for an incident
// POST /sdk/v1/incidents/:id/risk-assessment
func (h *IncidentHandlers) AssessRisk(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.RiskAssessmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
// Auto-calculate risk level
riskLevel := incidents.CalculateRiskLevel(req.Likelihood, req.Impact)
notificationRequired := incidents.IsNotificationRequired(riskLevel)
assessment := &incidents.RiskAssessment{
Likelihood: req.Likelihood,
Impact: req.Impact,
RiskLevel: riskLevel,
AssessedAt: time.Now().UTC(),
AssessedBy: userID,
Notes: req.Notes,
}
if err := h.store.UpdateRiskAssessment(c.Request.Context(), id, assessment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update status to assessment
incident.Status = incidents.IncidentStatusAssessment
h.store.UpdateIncident(c.Request.Context(), incident)
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "risk_assessed",
UserID: userID,
Details: fmt.Sprintf("Risk level: %s (likelihood=%d, impact=%d)", riskLevel, req.Likelihood, req.Impact),
})
// If notification is required, update authority notification status
if notificationRequired && incident.AuthorityNotification != nil {
incident.AuthorityNotification.Status = incidents.NotificationStatusPending
h.store.UpdateAuthorityNotification(c.Request.Context(), id, incident.AuthorityNotification)
// Update status to notification_required
incident.Status = incidents.IncidentStatusNotificationRequired
h.store.UpdateIncident(c.Request.Context(), incident)
}
c.JSON(http.StatusOK, gin.H{
"risk_assessment": assessment,
"notification_required": notificationRequired,
"incident_status": incident.Status,
})
}
// ============================================================================
// Authority Notification (Art. 33)
// ============================================================================
// SubmitAuthorityNotification submits the supervisory authority notification
// POST /sdk/v1/incidents/:id/authority-notification
func (h *IncidentHandlers) SubmitAuthorityNotification(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.SubmitAuthorityNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
now := time.Now().UTC()
// Preserve existing deadline
deadline := incidents.Calculate72hDeadline(incident.DetectedAt)
if incident.AuthorityNotification != nil {
deadline = incident.AuthorityNotification.Deadline
}
notification := &incidents.AuthorityNotification{
Status: incidents.NotificationStatusSent,
Deadline: deadline,
SubmittedAt: &now,
AuthorityName: req.AuthorityName,
ReferenceNumber: req.ReferenceNumber,
ContactPerson: req.ContactPerson,
Notes: req.Notes,
}
if err := h.store.UpdateAuthorityNotification(c.Request.Context(), id, notification); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update incident status
incident.Status = incidents.IncidentStatusNotificationSent
h.store.UpdateIncident(c.Request.Context(), incident)
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: now,
Action: "authority_notified",
UserID: userID,
Details: "Authority notification submitted to " + req.AuthorityName,
})
c.JSON(http.StatusOK, gin.H{
"authority_notification": notification,
"submitted_within_72h": now.Before(deadline),
})
}
// ============================================================================
// Data Subject Notification (Art. 34)
// ============================================================================
// NotifyDataSubjects submits the data subject notification
// POST /sdk/v1/incidents/:id/data-subject-notification
func (h *IncidentHandlers) NotifyDataSubjects(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.NotifyDataSubjectsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
now := time.Now().UTC()
affectedCount := req.AffectedCount
if affectedCount == 0 {
affectedCount = incident.AffectedDataSubjectCount
}
notification := &incidents.DataSubjectNotification{
Required: true,
Status: incidents.NotificationStatusSent,
SentAt: &now,
AffectedCount: affectedCount,
NotificationText: req.NotificationText,
Channel: req.Channel,
}
if err := h.store.UpdateDataSubjectNotification(c.Request.Context(), id, notification); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: now,
Action: "data_subjects_notified",
UserID: userID,
Details: "Data subjects notified via " + req.Channel + " (" + fmt.Sprintf("%d", affectedCount) + " affected)",
})
c.JSON(http.StatusOK, gin.H{
"data_subject_notification": notification,
})
}
// ============================================================================
// Measures
// ============================================================================
// AddMeasure adds a corrective measure to an incident
// POST /sdk/v1/incidents/:id/measures
func (h *IncidentHandlers) AddMeasure(c *gin.Context) {
incidentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
// Verify incident exists
incident, err := h.store.GetIncident(c.Request.Context(), incidentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.AddMeasureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
measure := &incidents.IncidentMeasure{
IncidentID: incidentID,
Title: req.Title,
Description: req.Description,
MeasureType: req.MeasureType,
Status: incidents.MeasureStatusPlanned,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), incidentID, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "measure_added",
UserID: userID,
Details: "Measure added: " + req.Title + " (" + string(req.MeasureType) + ")",
})
c.JSON(http.StatusCreated, gin.H{"measure": measure})
}
// UpdateMeasure updates a measure
// PUT /sdk/v1/incidents/measures/:measureId
func (h *IncidentHandlers) UpdateMeasure(c *gin.Context) {
measureID, err := uuid.Parse(c.Param("measureId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"})
return
}
var req struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
MeasureType incidents.MeasureType `json:"measure_type,omitempty"`
Status incidents.MeasureStatus `json:"status,omitempty"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
measure := &incidents.IncidentMeasure{
ID: measureID,
Title: req.Title,
Description: req.Description,
MeasureType: req.MeasureType,
Status: req.Status,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.UpdateMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"measure": measure})
}
// CompleteMeasure marks a measure as completed
// POST /sdk/v1/incidents/measures/:measureId/complete
func (h *IncidentHandlers) CompleteMeasure(c *gin.Context) {
measureID, err := uuid.Parse(c.Param("measureId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"})
return
}
if err := h.store.CompleteMeasure(c.Request.Context(), measureID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "measure completed"})
}
// ============================================================================
// Timeline
// ============================================================================
// AddTimelineEntry adds a timeline entry to an incident
// POST /sdk/v1/incidents/:id/timeline
func (h *IncidentHandlers) AddTimelineEntry(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
var req incidents.AddTimelineEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
entry := incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: req.Action,
UserID: userID,
Details: req.Details,
}
if err := h.store.AddTimelineEntry(c.Request.Context(), id, entry); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"timeline_entry": entry})
}
// ============================================================================
// Close Incident
// ============================================================================
// CloseIncident closes an incident with root cause analysis
// POST /sdk/v1/incidents/:id/close
func (h *IncidentHandlers) CloseIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.CloseIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if err := h.store.CloseIncident(c.Request.Context(), id, req.RootCause, req.LessonsLearned); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "incident_closed",
UserID: userID,
Details: "Incident closed. Root cause: " + req.RootCause,
})
c.JSON(http.StatusOK, gin.H{
"message": "incident closed",
"root_cause": req.RootCause,
"lessons_learned": req.LessonsLearned,
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated incident statistics
// GET /sdk/v1/incidents/statistics
func (h *IncidentHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,850 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/vendor"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// VendorHandlers handles vendor-compliance HTTP requests
type VendorHandlers struct {
store *vendor.Store
}
// NewVendorHandlers creates new vendor handlers
func NewVendorHandlers(store *vendor.Store) *VendorHandlers {
return &VendorHandlers{store: store}
}
// ============================================================================
// Vendor CRUD
// ============================================================================
// CreateVendor creates a new vendor
// POST /sdk/v1/vendors
func (h *VendorHandlers) CreateVendor(c *gin.Context) {
var req vendor.CreateVendorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
v := &vendor.Vendor{
TenantID: tenantID,
Name: req.Name,
LegalForm: req.LegalForm,
Country: req.Country,
Address: req.Address,
Website: req.Website,
ContactName: req.ContactName,
ContactEmail: req.ContactEmail,
ContactPhone: req.ContactPhone,
ContactDepartment: req.ContactDepartment,
Role: req.Role,
ServiceCategory: req.ServiceCategory,
ServiceDescription: req.ServiceDescription,
DataAccessLevel: req.DataAccessLevel,
ProcessingLocations: req.ProcessingLocations,
Certifications: req.Certifications,
ReviewFrequency: req.ReviewFrequency,
ProcessingActivityIDs: req.ProcessingActivityIDs,
TemplateID: req.TemplateID,
Status: vendor.VendorStatusActive,
CreatedBy: userID.String(),
}
if err := h.store.CreateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"vendor": v})
}
// ListVendors lists all vendors for a tenant
// GET /sdk/v1/vendors
func (h *VendorHandlers) ListVendors(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
vendors, err := h.store.ListVendors(c.Request.Context(), tenantID.String())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"vendors": vendors,
"total": len(vendors),
})
}
// GetVendor retrieves a vendor by ID with contracts and findings
// GET /sdk/v1/vendors/:id
func (h *VendorHandlers) GetVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
v, err := h.store.GetVendor(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if v == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "vendor not found"})
return
}
contracts, _ := h.store.ListContracts(c.Request.Context(), tenantID.String(), &id)
findings, _ := h.store.ListFindings(c.Request.Context(), tenantID.String(), &id, nil)
c.JSON(http.StatusOK, gin.H{
"vendor": v,
"contracts": contracts,
"findings": findings,
})
}
// UpdateVendor updates a vendor
// PUT /sdk/v1/vendors/:id
func (h *VendorHandlers) UpdateVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
v, err := h.store.GetVendor(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if v == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "vendor not found"})
return
}
var req vendor.UpdateVendorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply non-nil fields
if req.Name != nil {
v.Name = *req.Name
}
if req.LegalForm != nil {
v.LegalForm = *req.LegalForm
}
if req.Country != nil {
v.Country = *req.Country
}
if req.Address != nil {
v.Address = req.Address
}
if req.Website != nil {
v.Website = *req.Website
}
if req.ContactName != nil {
v.ContactName = *req.ContactName
}
if req.ContactEmail != nil {
v.ContactEmail = *req.ContactEmail
}
if req.ContactPhone != nil {
v.ContactPhone = *req.ContactPhone
}
if req.ContactDepartment != nil {
v.ContactDepartment = *req.ContactDepartment
}
if req.Role != nil {
v.Role = *req.Role
}
if req.ServiceCategory != nil {
v.ServiceCategory = *req.ServiceCategory
}
if req.ServiceDescription != nil {
v.ServiceDescription = *req.ServiceDescription
}
if req.DataAccessLevel != nil {
v.DataAccessLevel = *req.DataAccessLevel
}
if req.ProcessingLocations != nil {
v.ProcessingLocations = req.ProcessingLocations
}
if req.Certifications != nil {
v.Certifications = req.Certifications
}
if req.InherentRiskScore != nil {
v.InherentRiskScore = req.InherentRiskScore
}
if req.ResidualRiskScore != nil {
v.ResidualRiskScore = req.ResidualRiskScore
}
if req.ManualRiskAdjustment != nil {
v.ManualRiskAdjustment = req.ManualRiskAdjustment
}
if req.ReviewFrequency != nil {
v.ReviewFrequency = *req.ReviewFrequency
}
if req.LastReviewDate != nil {
v.LastReviewDate = req.LastReviewDate
}
if req.NextReviewDate != nil {
v.NextReviewDate = req.NextReviewDate
}
if req.ProcessingActivityIDs != nil {
v.ProcessingActivityIDs = req.ProcessingActivityIDs
}
if req.Status != nil {
v.Status = *req.Status
}
if req.TemplateID != nil {
v.TemplateID = req.TemplateID
}
if err := h.store.UpdateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"vendor": v})
}
// DeleteVendor deletes a vendor
// DELETE /sdk/v1/vendors/:id
func (h *VendorHandlers) DeleteVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
if err := h.store.DeleteVendor(c.Request.Context(), tenantID.String(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "vendor deleted"})
}
// ============================================================================
// Contract CRUD
// ============================================================================
// CreateContract creates a new contract for a vendor
// POST /sdk/v1/vendors/contracts
func (h *VendorHandlers) CreateContract(c *gin.Context) {
var req vendor.CreateContractRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
contract := &vendor.Contract{
TenantID: tenantID,
VendorID: req.VendorID,
FileName: req.FileName,
OriginalName: req.OriginalName,
MimeType: req.MimeType,
FileSize: req.FileSize,
StoragePath: req.StoragePath,
DocumentType: req.DocumentType,
Parties: req.Parties,
EffectiveDate: req.EffectiveDate,
ExpirationDate: req.ExpirationDate,
AutoRenewal: req.AutoRenewal,
RenewalNoticePeriod: req.RenewalNoticePeriod,
Version: req.Version,
PreviousVersionID: req.PreviousVersionID,
ReviewStatus: "PENDING",
CreatedBy: userID.String(),
}
if contract.Version == "" {
contract.Version = "1.0"
}
if err := h.store.CreateContract(c.Request.Context(), contract); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"contract": contract})
}
// ListContracts lists contracts for a tenant
// GET /sdk/v1/vendors/contracts
func (h *VendorHandlers) ListContracts(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var vendorID *string
if vid := c.Query("vendor_id"); vid != "" {
vendorID = &vid
}
contracts, err := h.store.ListContracts(c.Request.Context(), tenantID.String(), vendorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"contracts": contracts,
"total": len(contracts),
})
}
// GetContract retrieves a contract by ID
// GET /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) GetContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
contract, err := h.store.GetContract(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if contract == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "contract not found"})
return
}
c.JSON(http.StatusOK, gin.H{"contract": contract})
}
// UpdateContract updates a contract
// PUT /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) UpdateContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
contract, err := h.store.GetContract(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if contract == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "contract not found"})
return
}
var req vendor.UpdateContractRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.DocumentType != nil {
contract.DocumentType = *req.DocumentType
}
if req.Parties != nil {
contract.Parties = req.Parties
}
if req.EffectiveDate != nil {
contract.EffectiveDate = req.EffectiveDate
}
if req.ExpirationDate != nil {
contract.ExpirationDate = req.ExpirationDate
}
if req.AutoRenewal != nil {
contract.AutoRenewal = *req.AutoRenewal
}
if req.RenewalNoticePeriod != nil {
contract.RenewalNoticePeriod = *req.RenewalNoticePeriod
}
if req.ReviewStatus != nil {
contract.ReviewStatus = *req.ReviewStatus
}
if req.ReviewCompletedAt != nil {
contract.ReviewCompletedAt = req.ReviewCompletedAt
}
if req.ComplianceScore != nil {
contract.ComplianceScore = req.ComplianceScore
}
if req.Version != nil {
contract.Version = *req.Version
}
if req.ExtractedText != nil {
contract.ExtractedText = *req.ExtractedText
}
if req.PageCount != nil {
contract.PageCount = req.PageCount
}
if err := h.store.UpdateContract(c.Request.Context(), contract); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"contract": contract})
}
// DeleteContract deletes a contract
// DELETE /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) DeleteContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
if err := h.store.DeleteContract(c.Request.Context(), tenantID.String(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "contract deleted"})
}
// ============================================================================
// Finding CRUD
// ============================================================================
// CreateFinding creates a new compliance finding
// POST /sdk/v1/vendors/findings
func (h *VendorHandlers) CreateFinding(c *gin.Context) {
var req vendor.CreateFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
finding := &vendor.Finding{
TenantID: tenantID,
VendorID: req.VendorID,
ContractID: req.ContractID,
FindingType: req.FindingType,
Category: req.Category,
Severity: req.Severity,
Title: req.Title,
Description: req.Description,
Recommendation: req.Recommendation,
Citations: req.Citations,
Status: vendor.FindingStatusOpen,
Assignee: req.Assignee,
DueDate: req.DueDate,
}
if err := h.store.CreateFinding(c.Request.Context(), finding); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"finding": finding})
}
// ListFindings lists findings for a tenant
// GET /sdk/v1/vendors/findings
func (h *VendorHandlers) ListFindings(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var vendorID, contractID *string
if vid := c.Query("vendor_id"); vid != "" {
vendorID = &vid
}
if cid := c.Query("contract_id"); cid != "" {
contractID = &cid
}
findings, err := h.store.ListFindings(c.Request.Context(), tenantID.String(), vendorID, contractID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"findings": findings,
"total": len(findings),
})
}
// GetFinding retrieves a finding by ID
// GET /sdk/v1/vendors/findings/:id
func (h *VendorHandlers) GetFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
finding, err := h.store.GetFinding(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if finding == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"finding": finding})
}
// UpdateFinding updates a finding
// PUT /sdk/v1/vendors/findings/:id
func (h *VendorHandlers) UpdateFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
finding, err := h.store.GetFinding(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if finding == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
var req vendor.UpdateFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.FindingType != nil {
finding.FindingType = *req.FindingType
}
if req.Category != nil {
finding.Category = *req.Category
}
if req.Severity != nil {
finding.Severity = *req.Severity
}
if req.Title != nil {
finding.Title = *req.Title
}
if req.Description != nil {
finding.Description = *req.Description
}
if req.Recommendation != nil {
finding.Recommendation = *req.Recommendation
}
if req.Citations != nil {
finding.Citations = req.Citations
}
if req.Status != nil {
finding.Status = *req.Status
}
if req.Assignee != nil {
finding.Assignee = *req.Assignee
}
if req.DueDate != nil {
finding.DueDate = req.DueDate
}
if req.Resolution != nil {
finding.Resolution = *req.Resolution
}
if err := h.store.UpdateFinding(c.Request.Context(), finding); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"finding": finding})
}
// ResolveFinding resolves a finding with a resolution description
// POST /sdk/v1/vendors/findings/:id/resolve
func (h *VendorHandlers) ResolveFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
id := c.Param("id")
var req vendor.ResolveFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.ResolveFinding(c.Request.Context(), tenantID.String(), id, req.Resolution, userID.String()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "finding resolved"})
}
// ============================================================================
// Control Instance Operations
// ============================================================================
// UpsertControlInstance creates or updates a control instance
// POST /sdk/v1/vendors/controls
func (h *VendorHandlers) UpsertControlInstance(c *gin.Context) {
var req struct {
VendorID string `json:"vendor_id" binding:"required"`
ControlID string `json:"control_id" binding:"required"`
ControlDomain string `json:"control_domain"`
Status vendor.ControlStatus `json:"status" binding:"required"`
EvidenceIDs json.RawMessage `json:"evidence_ids,omitempty"`
Notes string `json:"notes,omitempty"`
NextAssessmentDate *time.Time `json:"next_assessment_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
now := time.Now().UTC()
userIDStr := userID.String()
ci := &vendor.ControlInstance{
TenantID: tenantID,
ControlID: req.ControlID,
ControlDomain: req.ControlDomain,
Status: req.Status,
EvidenceIDs: req.EvidenceIDs,
Notes: req.Notes,
LastAssessedAt: &now,
LastAssessedBy: &userIDStr,
NextAssessmentDate: req.NextAssessmentDate,
}
// Parse VendorID
vendorUUID, err := parseUUID(req.VendorID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid vendor_id"})
return
}
ci.VendorID = vendorUUID
if err := h.store.UpsertControlInstance(c.Request.Context(), ci); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"control_instance": ci})
}
// ListControlInstances lists control instances for a vendor
// GET /sdk/v1/vendors/controls
func (h *VendorHandlers) ListControlInstances(c *gin.Context) {
vendorID := c.Query("vendor_id")
if vendorID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "vendor_id query parameter is required"})
return
}
tenantID := rbac.GetTenantID(c)
instances, err := h.store.ListControlInstances(c.Request.Context(), tenantID.String(), vendorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"control_instances": instances,
"total": len(instances),
})
}
// ============================================================================
// Template Operations
// ============================================================================
// ListTemplates lists available templates
// GET /sdk/v1/vendors/templates
func (h *VendorHandlers) ListTemplates(c *gin.Context) {
templateType := c.DefaultQuery("type", "VENDOR")
var category, industry *string
if cat := c.Query("category"); cat != "" {
category = &cat
}
if ind := c.Query("industry"); ind != "" {
industry = &ind
}
templates, err := h.store.ListTemplates(c.Request.Context(), templateType, category, industry)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"templates": templates,
"total": len(templates),
})
}
// GetTemplate retrieves a template by its template_id string
// GET /sdk/v1/vendors/templates/:templateId
func (h *VendorHandlers) GetTemplate(c *gin.Context) {
templateID := c.Param("templateId")
if templateID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template ID is required"})
return
}
tmpl, err := h.store.GetTemplate(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
c.JSON(http.StatusOK, gin.H{"template": tmpl})
}
// CreateTemplate creates a custom template
// POST /sdk/v1/vendors/templates
func (h *VendorHandlers) CreateTemplate(c *gin.Context) {
var req vendor.CreateTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tmpl := &vendor.Template{
TemplateType: req.TemplateType,
TemplateID: req.TemplateID,
Category: req.Category,
NameDE: req.NameDE,
NameEN: req.NameEN,
DescriptionDE: req.DescriptionDE,
DescriptionEN: req.DescriptionEN,
TemplateData: req.TemplateData,
Industry: req.Industry,
Tags: req.Tags,
IsSystem: req.IsSystem,
IsActive: true,
}
// Set tenant for custom (non-system) templates
if !req.IsSystem {
tid := rbac.GetTenantID(c).String()
tmpl.TenantID = &tid
}
if err := h.store.CreateTemplate(c.Request.Context(), tmpl); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"template": tmpl})
}
// ApplyTemplate creates a vendor from a template
// POST /sdk/v1/vendors/templates/:templateId/apply
func (h *VendorHandlers) ApplyTemplate(c *gin.Context) {
templateID := c.Param("templateId")
if templateID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template ID is required"})
return
}
tmpl, err := h.store.GetTemplate(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
// Parse template_data to extract suggested vendor fields
var templateData struct {
ServiceCategory string `json:"service_category"`
SuggestedRole string `json:"suggested_role"`
DataAccessLevel string `json:"data_access_level"`
ReviewFrequency string `json:"review_frequency"`
Certifications json.RawMessage `json:"certifications"`
ProcessingLocations json.RawMessage `json:"processing_locations"`
}
if err := json.Unmarshal(tmpl.TemplateData, &templateData); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse template data"})
return
}
// Optional overrides from request body
var overrides struct {
Name string `json:"name"`
Country string `json:"country"`
Website string `json:"website"`
ContactName string `json:"contact_name"`
ContactEmail string `json:"contact_email"`
}
c.ShouldBindJSON(&overrides)
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
v := &vendor.Vendor{
TenantID: tenantID,
Name: overrides.Name,
Country: overrides.Country,
Website: overrides.Website,
ContactName: overrides.ContactName,
ContactEmail: overrides.ContactEmail,
Role: vendor.VendorRole(templateData.SuggestedRole),
ServiceCategory: templateData.ServiceCategory,
DataAccessLevel: templateData.DataAccessLevel,
ReviewFrequency: templateData.ReviewFrequency,
Certifications: templateData.Certifications,
ProcessingLocations: templateData.ProcessingLocations,
Status: vendor.VendorStatusActive,
TemplateID: &templateID,
CreatedBy: userID.String(),
}
if v.Name == "" {
v.Name = tmpl.NameDE
}
if v.Country == "" {
v.Country = "DE"
}
if v.Role == "" {
v.Role = vendor.VendorRoleProcessor
}
if err := h.store.CreateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Increment template usage
_ = h.store.IncrementTemplateUsage(c.Request.Context(), templateID)
c.JSON(http.StatusCreated, gin.H{
"vendor": v,
"template_id": templateID,
"message": "vendor created from template",
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated vendor statistics
// GET /sdk/v1/vendors/stats
func (h *VendorHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetVendorStats(c.Request.Context(), tenantID.String())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ============================================================================
// Helpers
// ============================================================================
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}

View File

@@ -0,0 +1,538 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/whistleblower"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// WhistleblowerHandlers handles whistleblower HTTP requests
type WhistleblowerHandlers struct {
store *whistleblower.Store
}
// NewWhistleblowerHandlers creates new whistleblower handlers
func NewWhistleblowerHandlers(store *whistleblower.Store) *WhistleblowerHandlers {
return &WhistleblowerHandlers{store: store}
}
// ============================================================================
// Public Handlers (NO auth required — for anonymous reporters)
// ============================================================================
// SubmitReport handles public report submission (no auth required)
// POST /sdk/v1/whistleblower/public/submit
func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) {
var req whistleblower.PublicReportSubmission
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get tenant ID from header or query param (public endpoint still needs tenant context)
tenantIDStr := c.GetHeader("X-Tenant-ID")
if tenantIDStr == "" {
tenantIDStr = c.Query("tenant_id")
}
if tenantIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id is required"})
return
}
tenantID, err := uuid.Parse(tenantIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
return
}
report := &whistleblower.Report{
TenantID: tenantID,
Category: req.Category,
Title: req.Title,
Description: req.Description,
IsAnonymous: req.IsAnonymous,
}
// Only set reporter info if not anonymous
if !req.IsAnonymous {
report.ReporterName = req.ReporterName
report.ReporterEmail = req.ReporterEmail
report.ReporterPhone = req.ReporterPhone
}
if err := h.store.CreateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return reference number and access key (access key only shown ONCE!)
c.JSON(http.StatusCreated, whistleblower.PublicReportResponse{
ReferenceNumber: report.ReferenceNumber,
AccessKey: report.AccessKey,
})
}
// GetReportByAccessKey retrieves a report by access key (for anonymous reporters)
// GET /sdk/v1/whistleblower/public/report?access_key=xxx
func (h *WhistleblowerHandlers) GetReportByAccessKey(c *gin.Context) {
accessKey := c.Query("access_key")
if accessKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"})
return
}
report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
// Return limited fields for public access (no access_key, no internal details)
c.JSON(http.StatusOK, gin.H{
"reference_number": report.ReferenceNumber,
"category": report.Category,
"status": report.Status,
"title": report.Title,
"received_at": report.ReceivedAt,
"deadline_acknowledgment": report.DeadlineAcknowledgment,
"deadline_feedback": report.DeadlineFeedback,
"acknowledged_at": report.AcknowledgedAt,
"closed_at": report.ClosedAt,
})
}
// SendPublicMessage allows a reporter to send a message via access key
// POST /sdk/v1/whistleblower/public/message?access_key=xxx
func (h *WhistleblowerHandlers) SendPublicMessage(c *gin.Context) {
accessKey := c.Query("access_key")
if accessKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"})
return
}
report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := &whistleblower.AnonymousMessage{
ReportID: report.ID,
Direction: whistleblower.MessageDirectionReporterToAdmin,
Content: req.Content,
}
if err := h.store.AddMessage(c.Request.Context(), msg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": msg})
}
// ============================================================================
// Admin Handlers (auth required)
// ============================================================================
// ListReports lists all reports for the tenant
// GET /sdk/v1/whistleblower/reports
func (h *WhistleblowerHandlers) ListReports(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &whistleblower.ReportFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = whistleblower.ReportStatus(status)
}
if category := c.Query("category"); category != "" {
filters.Category = whistleblower.ReportCategory(category)
}
reports, total, err := h.store.ListReports(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, whistleblower.ReportListResponse{
Reports: reports,
Total: total,
})
}
// GetReport retrieves a report by ID (admin)
// GET /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) GetReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
// Get messages and measures for full view
messages, _ := h.store.ListMessages(c.Request.Context(), id)
measures, _ := h.store.ListMeasures(c.Request.Context(), id)
// Do not expose access key to admin either
report.AccessKey = ""
c.JSON(http.StatusOK, gin.H{
"report": report,
"messages": messages,
"measures": measures,
})
}
// UpdateReport updates a report
// PUT /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) UpdateReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.ReportUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if req.Category != "" {
report.Category = req.Category
}
if req.Status != "" {
report.Status = req.Status
}
if req.Title != "" {
report.Title = req.Title
}
if req.Description != "" {
report.Description = req.Description
}
if req.AssignedTo != nil {
report.AssignedTo = req.AssignedTo
}
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "report_updated",
UserID: userID.String(),
Details: "Report updated by admin",
})
if err := h.store.UpdateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
report.AccessKey = ""
c.JSON(http.StatusOK, gin.H{"report": report})
}
// DeleteReport deletes a report
// DELETE /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) DeleteReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
if err := h.store.DeleteReport(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "report deleted"})
}
// AcknowledgeReport acknowledges a report (within 7-day HinSchG deadline)
// POST /sdk/v1/whistleblower/reports/:id/acknowledge
func (h *WhistleblowerHandlers) AcknowledgeReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
if report.AcknowledgedAt != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "report already acknowledged"})
return
}
userID := rbac.GetUserID(c)
if err := h.store.AcknowledgeReport(c.Request.Context(), id, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Optionally send acknowledgment message to reporter
var req whistleblower.AcknowledgeRequest
if err := c.ShouldBindJSON(&req); err == nil && req.Message != "" {
msg := &whistleblower.AnonymousMessage{
ReportID: id,
Direction: whistleblower.MessageDirectionAdminToReporter,
Content: req.Message,
}
h.store.AddMessage(c.Request.Context(), msg)
}
// Check if deadline was met
isOverdue := time.Now().UTC().After(report.DeadlineAcknowledgment)
c.JSON(http.StatusOK, gin.H{
"message": "report acknowledged",
"is_overdue": isOverdue,
})
}
// StartInvestigation changes the report status to investigation
// POST /sdk/v1/whistleblower/reports/:id/investigate
func (h *WhistleblowerHandlers) StartInvestigation(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
userID := rbac.GetUserID(c)
report.Status = whistleblower.ReportStatusInvestigation
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "investigation_started",
UserID: userID.String(),
Details: "Investigation started",
})
if err := h.store.UpdateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "investigation started",
"report": report,
})
}
// AddMeasure adds a corrective measure to a report
// POST /sdk/v1/whistleblower/reports/:id/measures
func (h *WhistleblowerHandlers) AddMeasure(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
// Verify report exists
report, err := h.store.GetReport(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.AddMeasureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
measure := &whistleblower.Measure{
ReportID: reportID,
Title: req.Title,
Description: req.Description,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update report status to measures_taken if not already
if report.Status != whistleblower.ReportStatusMeasuresTaken &&
report.Status != whistleblower.ReportStatusClosed {
report.Status = whistleblower.ReportStatusMeasuresTaken
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "measure_added",
UserID: userID.String(),
Details: "Corrective measure added: " + req.Title,
})
h.store.UpdateReport(c.Request.Context(), report)
}
c.JSON(http.StatusCreated, gin.H{"measure": measure})
}
// CloseReport closes a report with a resolution
// POST /sdk/v1/whistleblower/reports/:id/close
func (h *WhistleblowerHandlers) CloseReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
var req whistleblower.CloseReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if err := h.store.CloseReport(c.Request.Context(), id, userID, req.Resolution); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "report closed"})
}
// SendAdminMessage sends a message from admin to reporter
// POST /sdk/v1/whistleblower/reports/:id/messages
func (h *WhistleblowerHandlers) SendAdminMessage(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
// Verify report exists
report, err := h.store.GetReport(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := &whistleblower.AnonymousMessage{
ReportID: reportID,
Direction: whistleblower.MessageDirectionAdminToReporter,
Content: req.Content,
}
if err := h.store.AddMessage(c.Request.Context(), msg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": msg})
}
// ListMessages lists messages for a report
// GET /sdk/v1/whistleblower/reports/:id/messages
func (h *WhistleblowerHandlers) ListMessages(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
messages, err := h.store.ListMessages(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": len(messages),
})
}
// GetStatistics returns whistleblower statistics for the tenant
// GET /sdk/v1/whistleblower/statistics
func (h *WhistleblowerHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,305 @@
package incidents
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// IncidentCategory represents the category of a security/data breach incident
type IncidentCategory string
const (
IncidentCategoryDataBreach IncidentCategory = "data_breach"
IncidentCategoryUnauthorizedAccess IncidentCategory = "unauthorized_access"
IncidentCategoryDataLoss IncidentCategory = "data_loss"
IncidentCategorySystemCompromise IncidentCategory = "system_compromise"
IncidentCategoryPhishing IncidentCategory = "phishing"
IncidentCategoryRansomware IncidentCategory = "ransomware"
IncidentCategoryInsiderThreat IncidentCategory = "insider_threat"
IncidentCategoryPhysicalBreach IncidentCategory = "physical_breach"
IncidentCategoryOther IncidentCategory = "other"
)
// IncidentStatus represents the status of an incident through its lifecycle
type IncidentStatus string
const (
IncidentStatusDetected IncidentStatus = "detected"
IncidentStatusAssessment IncidentStatus = "assessment"
IncidentStatusContainment IncidentStatus = "containment"
IncidentStatusNotificationRequired IncidentStatus = "notification_required"
IncidentStatusNotificationSent IncidentStatus = "notification_sent"
IncidentStatusRemediation IncidentStatus = "remediation"
IncidentStatusClosed IncidentStatus = "closed"
)
// IncidentSeverity represents the severity level of an incident
type IncidentSeverity string
const (
IncidentSeverityCritical IncidentSeverity = "critical"
IncidentSeverityHigh IncidentSeverity = "high"
IncidentSeverityMedium IncidentSeverity = "medium"
IncidentSeverityLow IncidentSeverity = "low"
)
// MeasureType represents the type of corrective measure
type MeasureType string
const (
MeasureTypeImmediate MeasureType = "immediate"
MeasureTypeLongTerm MeasureType = "long_term"
)
// MeasureStatus represents the status of a corrective measure
type MeasureStatus string
const (
MeasureStatusPlanned MeasureStatus = "planned"
MeasureStatusInProgress MeasureStatus = "in_progress"
MeasureStatusCompleted MeasureStatus = "completed"
)
// NotificationStatus represents the status of a notification (authority or data subject)
type NotificationStatus string
const (
NotificationStatusNotRequired NotificationStatus = "not_required"
NotificationStatusPending NotificationStatus = "pending"
NotificationStatusSent NotificationStatus = "sent"
NotificationStatusConfirmed NotificationStatus = "confirmed"
)
// ============================================================================
// Main Entities
// ============================================================================
// Incident represents a security or data breach incident per DSGVO Art. 33/34
type Incident struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
// Incident info
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category"`
Status IncidentStatus `json:"status"`
Severity IncidentSeverity `json:"severity"`
// Detection & reporting
DetectedAt time.Time `json:"detected_at"`
ReportedBy uuid.UUID `json:"reported_by"`
// Affected scope
AffectedDataCategories []string `json:"affected_data_categories"` // JSONB
AffectedDataSubjectCount int `json:"affected_data_subject_count"`
AffectedSystems []string `json:"affected_systems"` // JSONB
// Assessments & notifications (JSONB embedded objects)
RiskAssessment *RiskAssessment `json:"risk_assessment,omitempty"`
AuthorityNotification *AuthorityNotification `json:"authority_notification,omitempty"`
DataSubjectNotification *DataSubjectNotification `json:"data_subject_notification,omitempty"`
// Resolution
RootCause string `json:"root_cause,omitempty"`
LessonsLearned string `json:"lessons_learned,omitempty"`
// Timeline (JSONB array)
Timeline []TimelineEntry `json:"timeline"`
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// RiskAssessment contains the risk assessment for an incident
type RiskAssessment struct {
Likelihood int `json:"likelihood"` // 1-5
Impact int `json:"impact"` // 1-5
RiskLevel string `json:"risk_level"` // critical, high, medium, low (auto-calculated)
AssessedAt time.Time `json:"assessed_at"`
AssessedBy uuid.UUID `json:"assessed_by"`
Notes string `json:"notes,omitempty"`
}
// AuthorityNotification tracks the supervisory authority notification per DSGVO Art. 33
type AuthorityNotification struct {
Status NotificationStatus `json:"status"`
Deadline time.Time `json:"deadline"` // 72h from detected_at per Art. 33
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
AuthorityName string `json:"authority_name,omitempty"`
ReferenceNumber string `json:"reference_number,omitempty"`
ContactPerson string `json:"contact_person,omitempty"`
Notes string `json:"notes,omitempty"`
}
// DataSubjectNotification tracks the data subject notification per DSGVO Art. 34
type DataSubjectNotification struct {
Required bool `json:"required"`
Status NotificationStatus `json:"status"`
SentAt *time.Time `json:"sent_at,omitempty"`
AffectedCount int `json:"affected_count"`
NotificationText string `json:"notification_text,omitempty"`
Channel string `json:"channel,omitempty"` // email, letter, website
}
// TimelineEntry represents a single event in the incident timeline
type TimelineEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
UserID uuid.UUID `json:"user_id"`
Details string `json:"details,omitempty"`
}
// IncidentMeasure represents a corrective or preventive measure for an incident
type IncidentMeasure struct {
ID uuid.UUID `json:"id"`
IncidentID uuid.UUID `json:"incident_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
MeasureType MeasureType `json:"measure_type"`
Status MeasureStatus `json:"status"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// IncidentStatistics contains aggregated incident statistics for a tenant
type IncidentStatistics struct {
TotalIncidents int `json:"total_incidents"`
OpenIncidents int `json:"open_incidents"`
ByStatus map[string]int `json:"by_status"`
BySeverity map[string]int `json:"by_severity"`
ByCategory map[string]int `json:"by_category"`
NotificationsPending int `json:"notifications_pending"`
AvgResolutionHours float64 `json:"avg_resolution_hours"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateIncidentRequest is the API request for creating an incident
type CreateIncidentRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category" binding:"required"`
Severity IncidentSeverity `json:"severity" binding:"required"`
DetectedAt *time.Time `json:"detected_at,omitempty"` // defaults to now
AffectedDataCategories []string `json:"affected_data_categories,omitempty"`
AffectedDataSubjectCount int `json:"affected_data_subject_count,omitempty"`
AffectedSystems []string `json:"affected_systems,omitempty"`
}
// UpdateIncidentRequest is the API request for updating an incident
type UpdateIncidentRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category,omitempty"`
Status IncidentStatus `json:"status,omitempty"`
Severity IncidentSeverity `json:"severity,omitempty"`
AffectedDataCategories []string `json:"affected_data_categories,omitempty"`
AffectedDataSubjectCount *int `json:"affected_data_subject_count,omitempty"`
AffectedSystems []string `json:"affected_systems,omitempty"`
}
// RiskAssessmentRequest is the API request for assessing risk
type RiskAssessmentRequest struct {
Likelihood int `json:"likelihood" binding:"required,min=1,max=5"`
Impact int `json:"impact" binding:"required,min=1,max=5"`
Notes string `json:"notes,omitempty"`
}
// SubmitAuthorityNotificationRequest is the API request for submitting authority notification
type SubmitAuthorityNotificationRequest struct {
AuthorityName string `json:"authority_name" binding:"required"`
ContactPerson string `json:"contact_person,omitempty"`
ReferenceNumber string `json:"reference_number,omitempty"`
Notes string `json:"notes,omitempty"`
}
// NotifyDataSubjectsRequest is the API request for notifying data subjects
type NotifyDataSubjectsRequest struct {
NotificationText string `json:"notification_text" binding:"required"`
Channel string `json:"channel" binding:"required"` // email, letter, website
AffectedCount int `json:"affected_count,omitempty"`
}
// AddMeasureRequest is the API request for adding a corrective measure
type AddMeasureRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
MeasureType MeasureType `json:"measure_type" binding:"required"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// CloseIncidentRequest is the API request for closing an incident
type CloseIncidentRequest struct {
RootCause string `json:"root_cause" binding:"required"`
LessonsLearned string `json:"lessons_learned,omitempty"`
}
// AddTimelineEntryRequest is the API request for adding a timeline entry
type AddTimelineEntryRequest struct {
Action string `json:"action" binding:"required"`
Details string `json:"details,omitempty"`
}
// IncidentListResponse is the API response for listing incidents
type IncidentListResponse struct {
Incidents []Incident `json:"incidents"`
Total int `json:"total"`
}
// IncidentFilters defines filters for listing incidents
type IncidentFilters struct {
Status IncidentStatus
Severity IncidentSeverity
Category IncidentCategory
Limit int
Offset int
}
// ============================================================================
// Helper Functions
// ============================================================================
// CalculateRiskLevel calculates the risk level from likelihood and impact scores.
// Risk score = likelihood * impact. Thresholds:
// - critical: score >= 20
// - high: score >= 12
// - medium: score >= 6
// - low: score < 6
func CalculateRiskLevel(likelihood, impact int) string {
score := likelihood * impact
switch {
case score >= 20:
return "critical"
case score >= 12:
return "high"
case score >= 6:
return "medium"
default:
return "low"
}
}
// Calculate72hDeadline calculates the 72-hour notification deadline per DSGVO Art. 33.
// The supervisory authority must be notified within 72 hours of becoming aware of a breach.
func Calculate72hDeadline(detectedAt time.Time) time.Time {
return detectedAt.Add(72 * time.Hour)
}
// IsNotificationRequired determines whether authority notification is required
// based on the assessed risk level. Notification is required for critical and high risk.
func IsNotificationRequired(riskLevel string) bool {
return riskLevel == "critical" || riskLevel == "high"
}

View File

@@ -0,0 +1,571 @@
package incidents
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles incident data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new incident store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Incident CRUD Operations
// ============================================================================
// CreateIncident creates a new incident
func (s *Store) CreateIncident(ctx context.Context, incident *Incident) error {
incident.ID = uuid.New()
incident.CreatedAt = time.Now().UTC()
incident.UpdatedAt = incident.CreatedAt
if incident.Status == "" {
incident.Status = IncidentStatusDetected
}
if incident.AffectedDataCategories == nil {
incident.AffectedDataCategories = []string{}
}
if incident.AffectedSystems == nil {
incident.AffectedSystems = []string{}
}
if incident.Timeline == nil {
incident.Timeline = []TimelineEntry{}
}
affectedDataCategories, _ := json.Marshal(incident.AffectedDataCategories)
affectedSystems, _ := json.Marshal(incident.AffectedSystems)
riskAssessment, _ := json.Marshal(incident.RiskAssessment)
authorityNotification, _ := json.Marshal(incident.AuthorityNotification)
dataSubjectNotification, _ := json.Marshal(incident.DataSubjectNotification)
timeline, _ := json.Marshal(incident.Timeline)
_, err := s.pool.Exec(ctx, `
INSERT INTO incident_incidents (
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
risk_assessment, authority_notification, data_subject_notification,
root_cause, lessons_learned, timeline,
created_at, updated_at, closed_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9,
$10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21
)
`,
incident.ID, incident.TenantID, incident.Title, incident.Description,
string(incident.Category), string(incident.Status), string(incident.Severity),
incident.DetectedAt, incident.ReportedBy,
affectedDataCategories, incident.AffectedDataSubjectCount, affectedSystems,
riskAssessment, authorityNotification, dataSubjectNotification,
incident.RootCause, incident.LessonsLearned, timeline,
incident.CreatedAt, incident.UpdatedAt, incident.ClosedAt,
)
return err
}
// GetIncident retrieves an incident by ID
func (s *Store) GetIncident(ctx context.Context, id uuid.UUID) (*Incident, error) {
var incident Incident
var category, status, severity string
var affectedDataCategories, affectedSystems []byte
var riskAssessment, authorityNotification, dataSubjectNotification []byte
var timeline []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
risk_assessment, authority_notification, data_subject_notification,
root_cause, lessons_learned, timeline,
created_at, updated_at, closed_at
FROM incident_incidents WHERE id = $1
`, id).Scan(
&incident.ID, &incident.TenantID, &incident.Title, &incident.Description,
&category, &status, &severity,
&incident.DetectedAt, &incident.ReportedBy,
&affectedDataCategories, &incident.AffectedDataSubjectCount, &affectedSystems,
&riskAssessment, &authorityNotification, &dataSubjectNotification,
&incident.RootCause, &incident.LessonsLearned, &timeline,
&incident.CreatedAt, &incident.UpdatedAt, &incident.ClosedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
incident.Category = IncidentCategory(category)
incident.Status = IncidentStatus(status)
incident.Severity = IncidentSeverity(severity)
json.Unmarshal(affectedDataCategories, &incident.AffectedDataCategories)
json.Unmarshal(affectedSystems, &incident.AffectedSystems)
json.Unmarshal(riskAssessment, &incident.RiskAssessment)
json.Unmarshal(authorityNotification, &incident.AuthorityNotification)
json.Unmarshal(dataSubjectNotification, &incident.DataSubjectNotification)
json.Unmarshal(timeline, &incident.Timeline)
if incident.AffectedDataCategories == nil {
incident.AffectedDataCategories = []string{}
}
if incident.AffectedSystems == nil {
incident.AffectedSystems = []string{}
}
if incident.Timeline == nil {
incident.Timeline = []TimelineEntry{}
}
return &incident, nil
}
// ListIncidents lists incidents for a tenant with optional filters
func (s *Store) ListIncidents(ctx context.Context, tenantID uuid.UUID, filters *IncidentFilters) ([]Incident, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
if filters != nil {
if filters.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
if filters.Severity != "" {
countQuery += fmt.Sprintf(" AND severity = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Severity))
countArgIdx++
}
if filters.Category != "" {
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
}
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Data query
query := `
SELECT
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
risk_assessment, authority_notification, data_subject_notification,
root_cause, lessons_learned, timeline,
created_at, updated_at, closed_at
FROM incident_incidents WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
}
if filters.Severity != "" {
query += fmt.Sprintf(" AND severity = $%d", argIdx)
args = append(args, string(filters.Severity))
argIdx++
}
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
}
}
query += " ORDER BY detected_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var incidents []Incident
for rows.Next() {
var incident Incident
var category, status, severity string
var affectedDataCategories, affectedSystems []byte
var riskAssessment, authorityNotification, dataSubjectNotification []byte
var timeline []byte
err := rows.Scan(
&incident.ID, &incident.TenantID, &incident.Title, &incident.Description,
&category, &status, &severity,
&incident.DetectedAt, &incident.ReportedBy,
&affectedDataCategories, &incident.AffectedDataSubjectCount, &affectedSystems,
&riskAssessment, &authorityNotification, &dataSubjectNotification,
&incident.RootCause, &incident.LessonsLearned, &timeline,
&incident.CreatedAt, &incident.UpdatedAt, &incident.ClosedAt,
)
if err != nil {
return nil, 0, err
}
incident.Category = IncidentCategory(category)
incident.Status = IncidentStatus(status)
incident.Severity = IncidentSeverity(severity)
json.Unmarshal(affectedDataCategories, &incident.AffectedDataCategories)
json.Unmarshal(affectedSystems, &incident.AffectedSystems)
json.Unmarshal(riskAssessment, &incident.RiskAssessment)
json.Unmarshal(authorityNotification, &incident.AuthorityNotification)
json.Unmarshal(dataSubjectNotification, &incident.DataSubjectNotification)
json.Unmarshal(timeline, &incident.Timeline)
if incident.AffectedDataCategories == nil {
incident.AffectedDataCategories = []string{}
}
if incident.AffectedSystems == nil {
incident.AffectedSystems = []string{}
}
if incident.Timeline == nil {
incident.Timeline = []TimelineEntry{}
}
incidents = append(incidents, incident)
}
return incidents, total, nil
}
// UpdateIncident updates an incident
func (s *Store) UpdateIncident(ctx context.Context, incident *Incident) error {
incident.UpdatedAt = time.Now().UTC()
affectedDataCategories, _ := json.Marshal(incident.AffectedDataCategories)
affectedSystems, _ := json.Marshal(incident.AffectedSystems)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
title = $2, description = $3, category = $4, status = $5, severity = $6,
affected_data_categories = $7, affected_data_subject_count = $8, affected_systems = $9,
root_cause = $10, lessons_learned = $11,
updated_at = $12
WHERE id = $1
`,
incident.ID, incident.Title, incident.Description,
string(incident.Category), string(incident.Status), string(incident.Severity),
affectedDataCategories, incident.AffectedDataSubjectCount, affectedSystems,
incident.RootCause, incident.LessonsLearned,
incident.UpdatedAt,
)
return err
}
// DeleteIncident deletes an incident and its related measures (cascade handled by FK)
func (s *Store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM incident_incidents WHERE id = $1", id)
return err
}
// ============================================================================
// Risk Assessment Operations
// ============================================================================
// UpdateRiskAssessment updates the risk assessment for an incident
func (s *Store) UpdateRiskAssessment(ctx context.Context, incidentID uuid.UUID, assessment *RiskAssessment) error {
assessmentJSON, _ := json.Marshal(assessment)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
risk_assessment = $2,
updated_at = NOW()
WHERE id = $1
`, incidentID, assessmentJSON)
return err
}
// ============================================================================
// Notification Operations
// ============================================================================
// UpdateAuthorityNotification updates the authority notification for an incident
func (s *Store) UpdateAuthorityNotification(ctx context.Context, incidentID uuid.UUID, notification *AuthorityNotification) error {
notificationJSON, _ := json.Marshal(notification)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
authority_notification = $2,
updated_at = NOW()
WHERE id = $1
`, incidentID, notificationJSON)
return err
}
// UpdateDataSubjectNotification updates the data subject notification for an incident
func (s *Store) UpdateDataSubjectNotification(ctx context.Context, incidentID uuid.UUID, notification *DataSubjectNotification) error {
notificationJSON, _ := json.Marshal(notification)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
data_subject_notification = $2,
updated_at = NOW()
WHERE id = $1
`, incidentID, notificationJSON)
return err
}
// ============================================================================
// Measure Operations
// ============================================================================
// AddMeasure adds a corrective measure to an incident
func (s *Store) AddMeasure(ctx context.Context, measure *IncidentMeasure) error {
measure.ID = uuid.New()
measure.CreatedAt = time.Now().UTC()
if measure.Status == "" {
measure.Status = MeasureStatusPlanned
}
_, err := s.pool.Exec(ctx, `
INSERT INTO incident_measures (
id, incident_id, title, description, measure_type, status,
responsible, due_date, completed_at, created_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10
)
`,
measure.ID, measure.IncidentID, measure.Title, measure.Description,
string(measure.MeasureType), string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
)
return err
}
// ListMeasures lists all measures for an incident
func (s *Store) ListMeasures(ctx context.Context, incidentID uuid.UUID) ([]IncidentMeasure, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, incident_id, title, description, measure_type, status,
responsible, due_date, completed_at, created_at
FROM incident_measures WHERE incident_id = $1
ORDER BY created_at ASC
`, incidentID)
if err != nil {
return nil, err
}
defer rows.Close()
var measures []IncidentMeasure
for rows.Next() {
var m IncidentMeasure
var measureType, status string
err := rows.Scan(
&m.ID, &m.IncidentID, &m.Title, &m.Description,
&measureType, &status,
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.MeasureType = MeasureType(measureType)
m.Status = MeasureStatus(status)
measures = append(measures, m)
}
return measures, nil
}
// UpdateMeasure updates an existing measure
func (s *Store) UpdateMeasure(ctx context.Context, measure *IncidentMeasure) error {
_, err := s.pool.Exec(ctx, `
UPDATE incident_measures SET
title = $2, description = $3, measure_type = $4, status = $5,
responsible = $6, due_date = $7, completed_at = $8
WHERE id = $1
`,
measure.ID, measure.Title, measure.Description,
string(measure.MeasureType), string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt,
)
return err
}
// CompleteMeasure marks a measure as completed
func (s *Store) CompleteMeasure(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE incident_measures SET
status = $2,
completed_at = $3
WHERE id = $1
`, id, string(MeasureStatusCompleted), now)
return err
}
// ============================================================================
// Timeline Operations
// ============================================================================
// AddTimelineEntry appends a timeline entry to the incident's JSONB timeline array
func (s *Store) AddTimelineEntry(ctx context.Context, incidentID uuid.UUID, entry TimelineEntry) error {
entryJSON, err := json.Marshal(entry)
if err != nil {
return err
}
// Use the || operator to append to the JSONB array
_, err = s.pool.Exec(ctx, `
UPDATE incident_incidents SET
timeline = COALESCE(timeline, '[]'::jsonb) || $2::jsonb,
updated_at = NOW()
WHERE id = $1
`, incidentID, string(entryJSON))
return err
}
// ============================================================================
// Close Incident
// ============================================================================
// CloseIncident closes an incident with root cause and lessons learned
func (s *Store) CloseIncident(ctx context.Context, id uuid.UUID, rootCause, lessonsLearned string) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
status = $2,
root_cause = $3,
lessons_learned = $4,
closed_at = $5,
updated_at = $5
WHERE id = $1
`, id, string(IncidentStatusClosed), rootCause, lessonsLearned, now)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated incident statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*IncidentStatistics, error) {
stats := &IncidentStatistics{
ByStatus: make(map[string]int),
BySeverity: make(map[string]int),
ByCategory: make(map[string]int),
}
// Total incidents
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalIncidents)
// Open incidents (not closed)
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1 AND status != 'closed'",
tenantID).Scan(&stats.OpenIncidents)
// By status
rows, err := s.pool.Query(ctx,
"SELECT status, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY status",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
stats.ByStatus[status] = count
}
}
// By severity
rows, err = s.pool.Query(ctx,
"SELECT severity, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY severity",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var severity string
var count int
rows.Scan(&severity, &count)
stats.BySeverity[severity] = count
}
}
// By category
rows, err = s.pool.Query(ctx,
"SELECT category, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY category",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var category string
var count int
rows.Scan(&category, &count)
stats.ByCategory[category] = count
}
}
// Notifications pending
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM incident_incidents
WHERE tenant_id = $1
AND (authority_notification->>'status' = 'pending'
OR data_subject_notification->>'status' = 'pending')
`, tenantID).Scan(&stats.NotificationsPending)
// Average resolution hours (for closed incidents)
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - detected_at)) / 3600), 0)
FROM incident_incidents
WHERE tenant_id = $1 AND status = 'closed' AND closed_at IS NOT NULL
`, tenantID).Scan(&stats.AvgResolutionHours)
return stats, nil
}

View File

@@ -0,0 +1,242 @@
package whistleblower
import (
"crypto/rand"
"fmt"
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// ReportCategory represents the category of a whistleblower report
type ReportCategory string
const (
ReportCategoryCorruption ReportCategory = "corruption"
ReportCategoryFraud ReportCategory = "fraud"
ReportCategoryDataProtection ReportCategory = "data_protection"
ReportCategoryDiscrimination ReportCategory = "discrimination"
ReportCategoryEnvironment ReportCategory = "environment"
ReportCategoryCompetition ReportCategory = "competition"
ReportCategoryProductSafety ReportCategory = "product_safety"
ReportCategoryTaxEvasion ReportCategory = "tax_evasion"
ReportCategoryOther ReportCategory = "other"
)
// ReportStatus represents the status of a whistleblower report
type ReportStatus string
const (
ReportStatusNew ReportStatus = "new"
ReportStatusAcknowledged ReportStatus = "acknowledged"
ReportStatusUnderReview ReportStatus = "under_review"
ReportStatusInvestigation ReportStatus = "investigation"
ReportStatusMeasuresTaken ReportStatus = "measures_taken"
ReportStatusClosed ReportStatus = "closed"
ReportStatusRejected ReportStatus = "rejected"
)
// MessageDirection represents the direction of an anonymous message
type MessageDirection string
const (
MessageDirectionReporterToAdmin MessageDirection = "reporter_to_admin"
MessageDirectionAdminToReporter MessageDirection = "admin_to_reporter"
)
// MeasureStatus represents the status of a corrective measure
type MeasureStatus string
const (
MeasureStatusPlanned MeasureStatus = "planned"
MeasureStatusInProgress MeasureStatus = "in_progress"
MeasureStatusCompleted MeasureStatus = "completed"
)
// ============================================================================
// Main Entities
// ============================================================================
// Report represents a whistleblower report (Hinweis) per HinSchG
type Report struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ReferenceNumber string `json:"reference_number"` // e.g. "WB-2026-0001"
AccessKey string `json:"access_key,omitempty"` // for anonymous access, only returned once
// Report content
Category ReportCategory `json:"category"`
Status ReportStatus `json:"status"`
Title string `json:"title"`
Description string `json:"description"`
// Reporter info (optional, for non-anonymous reports)
IsAnonymous bool `json:"is_anonymous"`
ReporterName *string `json:"reporter_name,omitempty"`
ReporterEmail *string `json:"reporter_email,omitempty"`
ReporterPhone *string `json:"reporter_phone,omitempty"`
// HinSchG deadlines
ReceivedAt time.Time `json:"received_at"`
DeadlineAcknowledgment time.Time `json:"deadline_acknowledgment"` // 7 days from received_at per HinSchG
DeadlineFeedback time.Time `json:"deadline_feedback"` // 3 months from received_at per HinSchG
// Status timestamps
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
// Assignment
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
// Resolution
Resolution string `json:"resolution,omitempty"`
// Audit trail (stored as JSONB)
AuditTrail []AuditEntry `json:"audit_trail"`
// Timestamps
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AnonymousMessage represents a message exchanged between reporter and admin
type AnonymousMessage struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
Direction MessageDirection `json:"direction"`
Content string `json:"content"`
SentAt time.Time `json:"sent_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
}
// Measure represents a corrective measure taken for a report
type Measure struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
Title string `json:"title"`
Description string `json:"description"`
Status MeasureStatus `json:"status"`
Responsible string `json:"responsible"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AuditEntry represents an entry in the audit trail
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
UserID string `json:"user_id"`
Details string `json:"details"`
}
// WhistleblowerStatistics contains aggregated statistics for a tenant
type WhistleblowerStatistics struct {
TotalReports int `json:"total_reports"`
ByStatus map[string]int `json:"by_status"`
ByCategory map[string]int `json:"by_category"`
OverdueAcknowledgments int `json:"overdue_acknowledgments"`
OverdueFeedbacks int `json:"overdue_feedbacks"`
AvgResolutionDays float64 `json:"avg_resolution_days"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// PublicReportSubmission is the request for submitting a report (NO auth required)
type PublicReportSubmission struct {
Category ReportCategory `json:"category" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description" binding:"required"`
IsAnonymous bool `json:"is_anonymous"`
ReporterName *string `json:"reporter_name,omitempty"`
ReporterEmail *string `json:"reporter_email,omitempty"`
ReporterPhone *string `json:"reporter_phone,omitempty"`
}
// PublicReportResponse is returned after submitting a report (access_key only shown once!)
type PublicReportResponse struct {
ReferenceNumber string `json:"reference_number"`
AccessKey string `json:"access_key"`
}
// ReportUpdateRequest is the request for updating a report (admin)
type ReportUpdateRequest struct {
Category ReportCategory `json:"category,omitempty"`
Status ReportStatus `json:"status,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
}
// AcknowledgeRequest is the request for acknowledging a report
type AcknowledgeRequest struct {
Message string `json:"message,omitempty"` // optional acknowledgment message to reporter
}
// CloseReportRequest is the request for closing a report
type CloseReportRequest struct {
Resolution string `json:"resolution" binding:"required"`
}
// AddMeasureRequest is the request for adding a corrective measure
type AddMeasureRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Responsible string `json:"responsible" binding:"required"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// UpdateMeasureRequest is the request for updating a measure
type UpdateMeasureRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Status MeasureStatus `json:"status,omitempty"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// SendMessageRequest is the request for sending an anonymous message
type SendMessageRequest struct {
Content string `json:"content" binding:"required"`
}
// ReportListResponse is the response for listing reports
type ReportListResponse struct {
Reports []Report `json:"reports"`
Total int `json:"total"`
}
// ReportFilters defines filters for listing reports
type ReportFilters struct {
Status ReportStatus
Category ReportCategory
Limit int
Offset int
}
// ============================================================================
// Helper Functions
// ============================================================================
// generateAccessKey generates a random 12-character alphanumeric key
func generateAccessKey() string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 12)
randomBytes := make([]byte, 12)
rand.Read(randomBytes)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return string(b)
}
// generateReferenceNumber generates a reference number like "WB-2026-0042"
func generateReferenceNumber(year int, sequence int) string {
return fmt.Sprintf("WB-%d-%04d", year, sequence)
}

View File

@@ -0,0 +1,591 @@
package whistleblower
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles whistleblower data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new whistleblower store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Report CRUD Operations
// ============================================================================
// CreateReport creates a new whistleblower report with auto-generated reference number and access key
func (s *Store) CreateReport(ctx context.Context, report *Report) error {
report.ID = uuid.New()
now := time.Now().UTC()
report.CreatedAt = now
report.UpdatedAt = now
report.ReceivedAt = now
report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG
report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG
if report.Status == "" {
report.Status = ReportStatusNew
}
// Generate access key
report.AccessKey = generateAccessKey()
// Generate reference number
year := now.Year()
seq, err := s.GetNextSequenceNumber(ctx, report.TenantID, year)
if err != nil {
return fmt.Errorf("failed to get sequence number: %w", err)
}
report.ReferenceNumber = generateReferenceNumber(year, seq)
// Initialize audit trail
if report.AuditTrail == nil {
report.AuditTrail = []AuditEntry{}
}
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_created",
UserID: "system",
Details: "Report submitted",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
INSERT INTO whistleblower_reports (
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22
)
`,
report.ID, report.TenantID, report.ReferenceNumber, report.AccessKey,
string(report.Category), string(report.Status), report.Title, report.Description,
report.IsAnonymous, report.ReporterName, report.ReporterEmail, report.ReporterPhone,
report.ReceivedAt, report.DeadlineAcknowledgment, report.DeadlineFeedback,
report.AcknowledgedAt, report.ClosedAt, report.AssignedTo,
auditTrailJSON, report.Resolution,
report.CreatedAt, report.UpdatedAt,
)
return err
}
// GetReport retrieves a report by ID
func (s *Store) GetReport(ctx context.Context, id uuid.UUID) (*Report, error) {
var report Report
var category, status string
var auditTrailJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE id = $1
`, id).Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
return &report, nil
}
// GetReportByAccessKey retrieves a report by its access key (for public anonymous access)
func (s *Store) GetReportByAccessKey(ctx context.Context, accessKey string) (*Report, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx,
"SELECT id FROM whistleblower_reports WHERE access_key = $1",
accessKey,
).Scan(&id)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetReport(ctx, id)
}
// ListReports lists reports for a tenant with optional filters
func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *ReportFilters) ([]Report, int, error) {
// Count total
countQuery := "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
if filters != nil {
if filters.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
if filters.Category != "" {
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
}
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Build data query
query := `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
}
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
}
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var reports []Report
for rows.Next() {
var report Report
var category, status string
var auditTrailJSON []byte
err := rows.Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
// Do not expose access key in list responses
report.AccessKey = ""
reports = append(reports, report)
}
return reports, total, nil
}
// UpdateReport updates a report
func (s *Store) UpdateReport(ctx context.Context, report *Report) error {
report.UpdatedAt = time.Now().UTC()
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
category = $2, status = $3, title = $4, description = $5,
assigned_to = $6, audit_trail = $7, resolution = $8,
updated_at = $9
WHERE id = $1
`,
report.ID,
string(report.Category), string(report.Status), report.Title, report.Description,
report.AssignedTo, auditTrailJSON, report.Resolution,
report.UpdatedAt,
)
return err
}
// AcknowledgeReport acknowledges a report, setting acknowledged_at and adding an audit entry
func (s *Store) AcknowledgeReport(ctx context.Context, id uuid.UUID, userID uuid.UUID) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.AcknowledgedAt = &now
report.Status = ReportStatusAcknowledged
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_acknowledged",
UserID: userID.String(),
Details: "Report acknowledged within HinSchG deadline",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, acknowledged_at = $3,
audit_trail = $4, updated_at = $5
WHERE id = $1
`,
id, string(ReportStatusAcknowledged), now,
auditTrailJSON, now,
)
return err
}
// CloseReport closes a report with a resolution
func (s *Store) CloseReport(ctx context.Context, id uuid.UUID, userID uuid.UUID, resolution string) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.ClosedAt = &now
report.Status = ReportStatusClosed
report.Resolution = resolution
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_closed",
UserID: userID.String(),
Details: "Report closed with resolution: " + resolution,
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, closed_at = $3, resolution = $4,
audit_trail = $5, updated_at = $6
WHERE id = $1
`,
id, string(ReportStatusClosed), now, resolution,
auditTrailJSON, now,
)
return err
}
// DeleteReport deletes a report and its related data (cascading via FK)
func (s *Store) DeleteReport(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM whistleblower_measures WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_messages WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_reports WHERE id = $1", id)
return err
}
// ============================================================================
// Message Operations
// ============================================================================
// AddMessage adds an anonymous message to a report
func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error {
msg.ID = uuid.New()
msg.SentAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_messages (
id, report_id, direction, content, sent_at, read_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
`,
msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt,
)
return err
}
// ListMessages lists messages for a report
func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, direction, content, sent_at, read_at
FROM whistleblower_messages WHERE report_id = $1
ORDER BY sent_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []AnonymousMessage
for rows.Next() {
var msg AnonymousMessage
var direction string
err := rows.Scan(
&msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt,
)
if err != nil {
return nil, err
}
msg.Direction = MessageDirection(direction)
messages = append(messages, msg)
}
return messages, nil
}
// ============================================================================
// Measure Operations
// ============================================================================
// AddMeasure adds a corrective measure to a report
func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error {
measure.ID = uuid.New()
measure.CreatedAt = time.Now().UTC()
if measure.Status == "" {
measure.Status = MeasureStatusPlanned
}
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_measures (
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
)
return err
}
// ListMeasures lists measures for a report
func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
FROM whistleblower_measures WHERE report_id = $1
ORDER BY created_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var measures []Measure
for rows.Next() {
var m Measure
var status string
err := rows.Scan(
&m.ID, &m.ReportID, &m.Title, &m.Description, &status,
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.Status = MeasureStatus(status)
measures = append(measures, m)
}
return measures, nil
}
// UpdateMeasure updates a measure
func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error {
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_measures SET
title = $2, description = $3, status = $4,
responsible = $5, due_date = $6, completed_at = $7
WHERE id = $1
`,
measure.ID,
measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated whistleblower statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) {
stats := &WhistleblowerStatistics{
ByStatus: make(map[string]int),
ByCategory: make(map[string]int),
}
// Total reports
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalReports)
// By status
rows, err := s.pool.Query(ctx,
"SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
stats.ByStatus[status] = count
}
}
// By category
rows, err = s.pool.Query(ctx,
"SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var category string
var count int
rows.Scan(&category, &count)
stats.ByCategory[category] = count
}
}
// Overdue acknowledgments: reports past deadline_acknowledgment that haven't been acknowledged
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND acknowledged_at IS NULL
AND status = 'new'
AND deadline_acknowledgment < NOW()
`, tenantID).Scan(&stats.OverdueAcknowledgments)
// Overdue feedbacks: reports past deadline_feedback that are still open
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND closed_at IS NULL
AND status NOT IN ('closed', 'rejected')
AND deadline_feedback < NOW()
`, tenantID).Scan(&stats.OverdueFeedbacks)
// Average resolution days (for closed reports)
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0)
FROM whistleblower_reports
WHERE tenant_id = $1 AND closed_at IS NOT NULL
`, tenantID).Scan(&stats.AvgResolutionDays)
return stats, nil
}
// ============================================================================
// Sequence Number
// ============================================================================
// GetNextSequenceNumber gets and increments the sequence number for reference number generation
func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) {
var seq int
err := s.pool.QueryRow(ctx, `
INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence)
VALUES ($1, $2, 1)
ON CONFLICT (tenant_id, year) DO UPDATE SET
last_sequence = whistleblower_sequences.last_sequence + 1
RETURNING last_sequence
`, tenantID, year).Scan(&seq)
if err != nil {
return 0, err
}
return seq, nil
}

View File

@@ -782,6 +782,29 @@ services:
- breakpilot-pwa-network
restart: unless-stopped
# ============================================
# Pitch Deck - Interactive Investor Presentation
# Next.js auf Port 3012
# ============================================
pitch-deck:
build:
context: ./pitch-deck
dockerfile: Dockerfile
platform: linux/arm64
container_name: breakpilot-pwa-pitch-deck
ports:
- "3012:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://breakpilot:breakpilot123@host.docker.internal:5432/breakpilot_db
- OLLAMA_URL=http://host.docker.internal:11434
- OLLAMA_MODEL=qwen2.5:32b
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- breakpilot-pwa-network
restart: unless-stopped
# ============================================
# AI Compliance SDK - Multi-Tenant RBAC & LLM Gateway
# Go auf Port 8090 (intern), 8093 (extern)

1899
docs-site/404.html Normal file

File diff suppressed because it is too large Load Diff

58
docs-site/Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# ============================================
# Breakpilot Dokumentation - MkDocs Build
# Multi-stage build fuer minimale Image-Groesse
# ============================================
# Stage 1: Build MkDocs Site
FROM python:3.11-slim AS builder
WORKDIR /docs
# Install MkDocs with Material theme and plugins
RUN pip install --no-cache-dir \
mkdocs==1.6.1 \
mkdocs-material==9.5.47 \
pymdown-extensions==10.12
# Copy configuration and source files
COPY mkdocs.yml /docs/
COPY docs-src/ /docs/docs-src/
# Build static site
RUN mkdocs build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built site from builder stage
COPY --from=builder /docs/docs-site /usr/share/nginx/html
# Custom nginx config for SPA routing
RUN echo 'server { \
listen 80; \
server_name localhost; \
root /usr/share/nginx/html; \
index index.html; \
\
location / { \
try_files $uri $uri/ /index.html; \
} \
\
# Enable gzip compression \
gzip on; \
gzip_types text/plain text/css application/json application/javascript text/xml application/xml; \
gzip_min_length 1000; \
\
# Cache static assets \
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
/*!
* Lunr languages, `Danish` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.da=function(){this.pipeline.reset(),this.pipeline.add(e.da.trimmer,e.da.stopWordFilter,e.da.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.da.stemmer))},e.da.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA--",e.da.trimmer=e.trimmerSupport.generateTrimmer(e.da.wordCharacters),e.Pipeline.registerFunction(e.da.trimmer,"trimmer-da"),e.da.stemmer=function(){var r=e.stemmerSupport.Among,i=e.stemmerSupport.SnowballProgram,n=new function(){function e(){var e,r=f.cursor+3;if(d=f.limit,0<=r&&r<=f.limit){for(a=r;;){if(e=f.cursor,f.in_grouping(w,97,248)){f.cursor=e;break}if(f.cursor=e,e>=f.limit)return;f.cursor++}for(;!f.out_grouping(w,97,248);){if(f.cursor>=f.limit)return;f.cursor++}d=f.cursor,d<a&&(d=a)}}function n(){var e,r;if(f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(c,32),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del();break;case 2:f.in_grouping_b(p,97,229)&&f.slice_del()}}function t(){var e,r=f.limit-f.cursor;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.find_among_b(l,4)?(f.bra=f.cursor,f.limit_backward=e,f.cursor=f.limit-r,f.cursor>f.limit_backward&&(f.cursor--,f.bra=f.cursor,f.slice_del())):f.limit_backward=e)}function s(){var e,r,i,n=f.limit-f.cursor;if(f.ket=f.cursor,f.eq_s_b(2,"st")&&(f.bra=f.cursor,f.eq_s_b(2,"ig")&&f.slice_del()),f.cursor=f.limit-n,f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(m,5),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del(),i=f.limit-f.cursor,t(),f.cursor=f.limit-i;break;case 2:f.slice_from("løs")}}function o(){var e;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.out_grouping_b(w,97,248)?(f.bra=f.cursor,u=f.slice_to(u),f.limit_backward=e,f.eq_v_b(u)&&f.slice_del()):f.limit_backward=e)}var a,d,u,c=[new r("hed",-1,1),new r("ethed",0,1),new r("ered",-1,1),new r("e",-1,1),new r("erede",3,1),new r("ende",3,1),new r("erende",5,1),new r("ene",3,1),new r("erne",3,1),new r("ere",3,1),new r("en",-1,1),new r("heden",10,1),new r("eren",10,1),new r("er",-1,1),new r("heder",13,1),new r("erer",13,1),new r("s",-1,2),new r("heds",16,1),new r("es",16,1),new r("endes",18,1),new r("erendes",19,1),new r("enes",18,1),new r("ernes",18,1),new r("eres",18,1),new r("ens",16,1),new r("hedens",24,1),new r("erens",24,1),new r("ers",16,1),new r("ets",16,1),new r("erets",28,1),new r("et",-1,1),new r("eret",30,1)],l=[new r("gd",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("elig",1,1),new r("els",-1,1),new r("løst",-1,2)],w=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],p=[239,254,42,3,0,0,0,0,0,0,0,0,0,0,0,0,16],f=new i;this.setCurrent=function(e){f.setCurrent(e)},this.getCurrent=function(){return f.getCurrent()},this.stem=function(){var r=f.cursor;return e(),f.limit_backward=r,f.cursor=f.limit,n(),f.cursor=f.limit,t(),f.cursor=f.limit,s(),f.cursor=f.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return n.setCurrent(e),n.stem(),n.getCurrent()}):(n.setCurrent(e),n.stem(),n.getCurrent())}}(),e.Pipeline.registerFunction(e.da.stemmer,"stemmer-da"),e.da.stopWordFilter=e.generateStopWordFilter("ad af alle alt anden at blev blive bliver da de dem den denne der deres det dette dig din disse dog du efter eller en end er et for fra ham han hans har havde have hende hendes her hos hun hvad hvis hvor i ikke ind jeg jer jo kunne man mange med meget men mig min mine mit mod ned noget nogle nu når og også om op os over på selv sig sin sine sit skal skulle som sådan thi til ud under var vi vil ville vor være været".split(" ")),e.Pipeline.registerFunction(e.da.stopWordFilter,"stopWordFilter-da")}});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hi=function(){this.pipeline.reset(),this.pipeline.add(e.hi.trimmer,e.hi.stopWordFilter,e.hi.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.hi.stemmer))},e.hi.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿa-zA-Z--0-9-",e.hi.trimmer=e.trimmerSupport.generateTrimmer(e.hi.wordCharacters),e.Pipeline.registerFunction(e.hi.trimmer,"trimmer-hi"),e.hi.stopWordFilter=e.generateStopWordFilter("अत अपना अपनी अपने अभी अंदर आदि आप इत्यादि इन इनका इन्हीं इन्हें इन्हों इस इसका इसकी इसके इसमें इसी इसे उन उनका उनकी उनके उनको उन्हीं उन्हें उन्हों उस उसके उसी उसे एक एवं एस ऐसे और कई कर करता करते करना करने करें कहते कहा का काफ़ी कि कितना किन्हें किन्हों किया किर किस किसी किसे की कुछ कुल के को कोई कौन कौनसा गया घर जब जहाँ जा जितना जिन जिन्हें जिन्हों जिस जिसे जीधर जैसा जैसे जो तक तब तरह तिन तिन्हें तिन्हों तिस तिसे तो था थी थे दबारा दिया दुसरा दूसरे दो द्वारा न नके नहीं ना निहायत नीचे ने पर पहले पूरा पे फिर बनी बही बहुत बाद बाला बिलकुल भी भीतर मगर मानो मे में यदि यह यहाँ यही या यिह ये रखें रहा रहे ऱ्वासा लिए लिये लेकिन व वग़ैरह वर्ग वह वहाँ वहीं वाले वुह वे वो सकता सकते सबसे सभी साथ साबुत साभ सारा से सो संग ही हुआ हुई हुए है हैं हो होता होती होते होना होने".split(" ")),e.hi.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.hi.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var t=i.toString().toLowerCase().replace(/^\s+/,"");return r.cut(t).split("|")},e.Pipeline.registerFunction(e.hi.stemmer,"stemmer-hi"),e.Pipeline.registerFunction(e.hi.stopWordFilter,"stopWordFilter-hi")}});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hy=function(){this.pipeline.reset(),this.pipeline.add(e.hy.trimmer,e.hy.stopWordFilter)},e.hy.wordCharacters="[A-Za-z԰-֏ff-ﭏ]",e.hy.trimmer=e.trimmerSupport.generateTrimmer(e.hy.wordCharacters),e.Pipeline.registerFunction(e.hy.trimmer,"trimmer-hy"),e.hy.stopWordFilter=e.generateStopWordFilter("դու և եք էիր էիք հետո նաև նրանք որը վրա է որ պիտի են այս մեջ ն իր ու ի այդ որոնք այն կամ էր մի ես համար այլ իսկ էին ենք հետ ին թ էինք մենք նրա նա դուք եմ էի ըստ որպես ում".split(" ")),e.Pipeline.registerFunction(e.hy.stopWordFilter,"stopWordFilter-hy"),e.hy.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}(),e.Pipeline.registerFunction(e.hy.stemmer,"stemmer-hy")}});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.ja=function(){this.pipeline.reset(),this.pipeline.add(e.ja.trimmer,e.ja.stopWordFilter,e.ja.stemmer),r?this.tokenizer=e.ja.tokenizer:(e.tokenizer&&(e.tokenizer=e.ja.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.ja.tokenizer))};var t=new e.TinySegmenter;e.ja.tokenizer=function(i){var n,o,s,p,a,u,m,l,c,f;if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t.toLowerCase()):t.toLowerCase()});for(o=i.toString().toLowerCase().replace(/^\s+/,""),n=o.length-1;n>=0;n--)if(/\S/.test(o.charAt(n))){o=o.substring(0,n+1);break}for(a=[],s=o.length,c=0,l=0;c<=s;c++)if(u=o.charAt(c),m=c-l,u.match(/\s/)||c==s){if(m>0)for(p=t.segment(o.slice(l,c)).filter(function(e){return!!e}),f=l,n=0;n<p.length;n++)r?a.push(new e.Token(p[n],{position:[f,p[n].length],index:a.length})):a.push(p[n]),f+=p[n].length;l=c+1}return a},e.ja.stemmer=function(){return function(e){return e}}(),e.Pipeline.registerFunction(e.ja.stemmer,"stemmer-ja"),e.ja.wordCharacters="一二三四五六七八九十百千万億兆一-龠々〆ヵヶぁ-んァ-ヴーア-ン゙a-zA-Z--0-9-",e.ja.trimmer=e.trimmerSupport.generateTrimmer(e.ja.wordCharacters),e.Pipeline.registerFunction(e.ja.trimmer,"trimmer-ja"),e.ja.stopWordFilter=e.generateStopWordFilter("これ それ あれ この その あの ここ そこ あそこ こちら どこ だれ なに なん 何 私 貴方 貴方方 我々 私達 あの人 あのかた 彼女 彼 です あります おります います は が の に を で え から まで より も どの と し それで しかし".split(" ")),e.Pipeline.registerFunction(e.ja.stopWordFilter,"stopWordFilter-ja"),e.jp=e.ja,e.Pipeline.registerFunction(e.jp.stemmer,"stemmer-jp"),e.Pipeline.registerFunction(e.jp.trimmer,"trimmer-jp"),e.Pipeline.registerFunction(e.jp.stopWordFilter,"stopWordFilter-jp")}});

View File

@@ -0,0 +1 @@
module.exports=require("./lunr.ja");

View File

@@ -0,0 +1 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.kn=function(){this.pipeline.reset(),this.pipeline.add(e.kn.trimmer,e.kn.stopWordFilter,e.kn.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.kn.stemmer))},e.kn.wordCharacters="ಀ-಄ಅ-ಔಕ-ಹಾ-ೌ಼-ಽೕ-ೖೝ-ೞೠ-ೡೢ-ೣ೤೥೦-೯ೱ-ೳ",e.kn.trimmer=e.trimmerSupport.generateTrimmer(e.kn.wordCharacters),e.Pipeline.registerFunction(e.kn.trimmer,"trimmer-kn"),e.kn.stopWordFilter=e.generateStopWordFilter("ಮತ್ತು ಈ ಒಂದು ರಲ್ಲಿ ಹಾಗೂ ಎಂದು ಅಥವಾ ಇದು ರ ಅವರು ಎಂಬ ಮೇಲೆ ಅವರ ತನ್ನ ಆದರೆ ತಮ್ಮ ನಂತರ ಮೂಲಕ ಹೆಚ್ಚು ನ ಆ ಕೆಲವು ಅನೇಕ ಎರಡು ಹಾಗು ಪ್ರಮುಖ ಇದನ್ನು ಇದರ ಸುಮಾರು ಅದರ ಅದು ಮೊದಲ ಬಗ್ಗೆ ನಲ್ಲಿ ರಂದು ಇತರ ಅತ್ಯಂತ ಹೆಚ್ಚಿನ ಸಹ ಸಾಮಾನ್ಯವಾಗಿ ನೇ ಹಲವಾರು ಹೊಸ ದಿ ಕಡಿಮೆ ಯಾವುದೇ ಹೊಂದಿದೆ ದೊಡ್ಡ ಅನ್ನು ಇವರು ಪ್ರಕಾರ ಇದೆ ಮಾತ್ರ ಕೂಡ ಇಲ್ಲಿ ಎಲ್ಲಾ ವಿವಿಧ ಅದನ್ನು ಹಲವು ರಿಂದ ಕೇವಲ ದ ದಕ್ಷಿಣ ಗೆ ಅವನ ಅತಿ ನೆಯ ಬಹಳ ಕೆಲಸ ಎಲ್ಲ ಪ್ರತಿ ಇತ್ಯಾದಿ ಇವು ಬೇರೆ ಹೀಗೆ ನಡುವೆ ಇದಕ್ಕೆ ಎಸ್ ಇವರ ಮೊದಲು ಶ್ರೀ ಮಾಡುವ ಇದರಲ್ಲಿ ರೀತಿಯ ಮಾಡಿದ ಕಾಲ ಅಲ್ಲಿ ಮಾಡಲು ಅದೇ ಈಗ ಅವು ಗಳು ಎ ಎಂಬುದು ಅವನು ಅಂದರೆ ಅವರಿಗೆ ಇರುವ ವಿಶೇಷ ಮುಂದೆ ಅವುಗಳ ಮುಂತಾದ ಮೂಲ ಬಿ ಮೀ ಒಂದೇ ಇನ್ನೂ ಹೆಚ್ಚಾಗಿ ಮಾಡಿ ಅವರನ್ನು ಇದೇ ಯ ರೀತಿಯಲ್ಲಿ ಜೊತೆ ಅದರಲ್ಲಿ ಮಾಡಿದರು ನಡೆದ ಆಗ ಮತ್ತೆ ಪೂರ್ವ ಆತ ಬಂದ ಯಾವ ಒಟ್ಟು ಇತರೆ ಹಿಂದೆ ಪ್ರಮಾಣದ ಗಳನ್ನು ಕುರಿತು ಯು ಆದ್ದರಿಂದ ಅಲ್ಲದೆ ನಗರದ ಮೇಲಿನ ಏಕೆಂದರೆ ರಷ್ಟು ಎಂಬುದನ್ನು ಬಾರಿ ಎಂದರೆ ಹಿಂದಿನ ಆದರೂ ಆದ ಸಂಬಂಧಿಸಿದ ಮತ್ತೊಂದು ಸಿ ಆತನ ".split(" ")),e.kn.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.kn.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var n=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(n).split("|")},e.Pipeline.registerFunction(e.kn.stemmer,"stemmer-kn"),e.Pipeline.registerFunction(e.kn.stopWordFilter,"stopWordFilter-kn")}});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){e.multiLanguage=function(){for(var t=Array.prototype.slice.call(arguments),i=t.join("-"),r="",n=[],s=[],p=0;p<t.length;++p)"en"==t[p]?(r+="\\w",n.unshift(e.stopWordFilter),n.push(e.stemmer),s.push(e.stemmer)):(r+=e[t[p]].wordCharacters,e[t[p]].stopWordFilter&&n.unshift(e[t[p]].stopWordFilter),e[t[p]].stemmer&&(n.push(e[t[p]].stemmer),s.push(e[t[p]].stemmer)));var o=e.trimmerSupport.generateTrimmer(r);return e.Pipeline.registerFunction(o,"lunr-multi-trimmer-"+i),n.unshift(o),function(){this.pipeline.reset(),this.pipeline.add.apply(this.pipeline,n),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add.apply(this.searchPipeline,s))}}}});

Some files were not shown because too many files have changed in this diff Show More