diff --git a/admin-v2/.docker/build-ci-images.sh b/admin-v2/.docker/build-ci-images.sh new file mode 100755 index 0000000..1f55393 --- /dev/null +++ b/admin-v2/.docker/build-ci-images.sh @@ -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" diff --git a/admin-v2/.docker/python-ci.Dockerfile b/admin-v2/.docker/python-ci.Dockerfile new file mode 100644 index 0000000..ff8cd4a --- /dev/null +++ b/admin-v2/.docker/python-ci.Dockerfile @@ -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"] diff --git a/admin-v2/.env.example b/admin-v2/.env.example new file mode 100644 index 0000000..355c1da --- /dev/null +++ b/admin-v2/.env.example @@ -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 diff --git a/admin-v2/.github/dependabot.yml b/admin-v2/.github/dependabot.yml new file mode 100644 index 0000000..f318042 --- /dev/null +++ b/admin-v2/.github/dependabot.yml @@ -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):" diff --git a/admin-v2/.github/workflows/ci.yml b/admin-v2/.github/workflows/ci.yml new file mode 100644 index 0000000..a863ca2 --- /dev/null +++ b/admin-v2/.github/workflows/ci.yml @@ -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 diff --git a/admin-v2/.github/workflows/security.yml b/admin-v2/.github/workflows/security.yml new file mode 100644 index 0000000..5ced30c --- /dev/null +++ b/admin-v2/.github/workflows/security.yml @@ -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 diff --git a/admin-v2/.github/workflows/test.yml b/admin-v2/.github/workflows/test.yml new file mode 100644 index 0000000..dd65cf3 --- /dev/null +++ b/admin-v2/.github/workflows/test.yml @@ -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!" diff --git a/admin-v2/.gitignore b/admin-v2/.gitignore new file mode 100644 index 0000000..4ffadf8 --- /dev/null +++ b/admin-v2/.gitignore @@ -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 diff --git a/admin-v2/.gitleaks.toml b/admin-v2/.gitleaks.toml new file mode 100644 index 0000000..fbe6794 --- /dev/null +++ b/admin-v2/.gitleaks.toml @@ -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''', +] diff --git a/admin-v2/.pre-commit-config.yaml b/admin-v2/.pre-commit-config.yaml new file mode 100644 index 0000000..b5f5cd2 --- /dev/null +++ b/admin-v2/.pre-commit-config.yaml @@ -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 diff --git a/admin-v2/.semgrep.yml b/admin-v2/.semgrep.yml new file mode 100644 index 0000000..da09c10 --- /dev/null +++ b/admin-v2/.semgrep.yml @@ -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" diff --git a/admin-v2/.trivy.yaml b/admin-v2/.trivy.yaml new file mode 100644 index 0000000..7557037 --- /dev/null +++ b/admin-v2/.trivy.yaml @@ -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 diff --git a/admin-v2/.trivyignore b/admin-v2/.trivyignore new file mode 100644 index 0000000..17b0d74 --- /dev/null +++ b/admin-v2/.trivyignore @@ -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) diff --git a/admin-v2/.woodpecker/auto-fix.yml b/admin-v2/.woodpecker/auto-fix.yml new file mode 100644 index 0000000..c014928 --- /dev/null +++ b/admin-v2/.woodpecker/auto-fix.yml @@ -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] diff --git a/admin-v2/.woodpecker/build-ci-image.yml b/admin-v2/.woodpecker/build-ci-image.yml new file mode 100644 index 0000000..09c3172 --- /dev/null +++ b/admin-v2/.woodpecker/build-ci-image.yml @@ -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!" diff --git a/admin-v2/.woodpecker/integration.yml b/admin-v2/.woodpecker/integration.yml new file mode 100644 index 0000000..703989b --- /dev/null +++ b/admin-v2/.woodpecker/integration.yml @@ -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 diff --git a/admin-v2/.woodpecker/main.yml b/admin-v2/.woodpecker/main.yml new file mode 100644 index 0000000..d358bb0 --- /dev/null +++ b/admin-v2/.woodpecker/main.yml @@ -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 diff --git a/admin-v2/.woodpecker/security.yml b/admin-v2/.woodpecker/security.yml new file mode 100644 index 0000000..8f7892d --- /dev/null +++ b/admin-v2/.woodpecker/security.yml @@ -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 " || 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 diff --git a/admin-v2/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md b/admin-v2/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ee4c37a --- /dev/null +++ b/admin-v2/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md @@ -0,0 +1,2029 @@ +# AI Compliance SDK - Vollständige Implementierungsspezifikation + +> **Version:** 1.0.0 +> **Erstellt:** 2026-02-03 +> **Status:** In Planung +> **Projekt:** breakpilot-pwa + +--- + +## Inhaltsverzeichnis + +1. [Executive Summary](#1-executive-summary) +2. [SDK-Architektur Übersicht](#2-sdk-architektur-übersicht) +3. [Logische Navigationsstruktur](#3-logische-navigationsstruktur) +4. [Datenfluss & Abhängigkeiten](#4-datenfluss--abhängigkeiten) +5. [Checkpoint-System](#5-checkpoint-system) +6. [Unified Command Bar](#6-unified-command-bar) +7. [State Management](#7-state-management) +8. [API-Struktur](#8-api-struktur) +9. [UI-Komponenten](#9-ui-komponenten) +10. [TypeScript Interfaces](#10-typescript-interfaces) +11. [Implementierungs-Roadmap](#11-implementierungs-roadmap) +12. [Testplan](#12-testplan) +13. [Dokumentation](#13-dokumentation) +14. [Offene Fragen & Klärungsbedarf](#14-offene-fragen--klärungsbedarf) +15. [Akzeptanzkriterien](#15-akzeptanzkriterien) + +--- + +## 1. Executive Summary + +Der AI Compliance SDK ist ein **B2B SaaS-Produkt** für Compliance-Management von KI-Anwendungsfällen. Er besteht aus zwei Hauptmodulen: + +| Modul | Beschreibung | Hauptfunktionen | +|-------|--------------|-----------------| +| **Modul 1** | Automatisches Compliance Assessment | Use Case → Risiko → Controls | +| **Modul 2** | Dokumentengenerierung | DSFA, TOMs, Verträge, Cookie Banner | + +### Zielgruppen + +- **Datenschutzbeauftragte (DSB)**: Compliance-Überwachung, DSFA-Erstellung +- **IT-Sicherheitsbeauftragte**: Security Screening, TOMs +- **Entwickler**: Use Case Workshop, Technical Controls +- **Management**: Risk Matrix, Audit Reports +- **Auditoren**: Evidence, Checklists, Compliance Reports + +### Technologie-Stack + +| Komponente | Technologie | Version | +|------------|-------------|---------| +| Frontend | Next.js (App Router) | 15.1 | +| UI Framework | React + TypeScript | 18.3 / 5.7 | +| Styling | Tailwind CSS | 3.4.16 | +| Backend | Go + Gin | 1.24.0 / 1.10.1 | +| Datenbank | PostgreSQL | 15+ | +| Cache | Valkey/Redis | - | +| Vector DB | Qdrant | - | + +--- + +## 2. SDK-Architektur Übersicht + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AI COMPLIANCE SDK │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ UNIFIED COMMAND BAR │ │ +│ │ [🔍 Prompt: "Erstelle DSFA für Marketing-KI..." ] [⌘K] │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌─────────────────────────────────────────────────────┐ │ +│ │ │ │ │ │ +│ │ SIDEBAR │ │ MAIN CONTENT │ │ +│ │ (Guided │ │ │ │ +│ │ Flow) │ │ [Progress Bar: Phase 1 ████████░░ Phase 2] │ │ +│ │ │ │ │ │ +│ │ ┌─────────┐ │ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │Phase 1 │ │ │ │ │ │ │ +│ │ │━━━━━━━━━│ │ │ │ CURRENT STEP CONTENT │ │ │ +│ │ │1.Use Case│ │ │ │ │ │ │ +│ │ │2.Screening│ │ │ │ │ │ │ +│ │ │3.Compliance│ │ │ └─────────────────────────────────────────────┘ │ │ +│ │ │4.Controls│ │ │ │ │ +│ │ │5.Evidence│ │ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │6.Checklist│ │ │ │ CHECKPOINT: Qualitätsprüfung │ │ │ +│ │ │7.Risk │ │ │ │ ☑ Alle Pflichtfelder ausgefüllt │ │ │ +│ │ │━━━━━━━━━│ │ │ │ ☑ Keine kritischen Lücken │ │ │ +│ │ │Phase 2 │ │ │ │ ☐ DSB-Review erforderlich │ │ │ +│ │ │━━━━━━━━━│ │ │ └─────────────────────────────────────────────┘ │ │ +│ │ │8.AI Act │ │ │ │ │ +│ │ │9.DSFA │ │ │ [← Zurück] [Weiter →] [Überspringen] │ │ +│ │ │10.TOMs │ │ │ │ │ +│ │ │... │ │ │ │ │ +│ │ └─────────┘ │ │ │ │ +│ │ │ │ │ │ +│ └──────────────┘ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Komponenten-Hierarchie + +``` +SDKLayout +├── CommandBar (global, ⌘K aktiviert) +├── SDKSidebar +│ ├── PhaseIndicator +│ ├── StepList (Phase 1) +│ │ └── StepItem[] mit CheckpointBadge +│ └── StepList (Phase 2) +│ └── StepItem[] mit CheckpointBadge +├── MainContent +│ ├── ProgressBar +│ ├── PageContent (dynamisch je nach Route) +│ └── CheckpointCard +└── NavigationFooter + ├── BackButton + ├── NextButton + └── SkipButton +``` + +--- + +## 3. Logische Navigationsstruktur + +### Phase 1: Automatisches Compliance Assessment + +| # | Schritt | URL | Funktion | Voraussetzung | Checkpoint | +|---|---------|-----|----------|---------------|------------| +| 1.1 | **Use Case Workshop** | `/sdk/advisory-board` | 5-Schritte-Wizard für Use Case Erfassung | - | CP-UC | +| 1.2 | **System Screening** | `/sdk/screening` | SBOM + Security Check | Use Case erstellt | CP-SCAN | +| 1.3 | **Compliance Modules** | `/sdk/modules` | Abgleich welche Regulierungen gelten | Screening abgeschlossen | CP-MOD | +| 1.4 | **Requirements** | `/sdk/requirements` | Prüfaspekte aus Regulierungen ableiten | Module zugewiesen | CP-REQ | +| 1.5 | **Controls** | `/sdk/controls` | Erforderliche Maßnahmen ermitteln | Requirements definiert | CP-CTRL | +| 1.6 | **Evidence** | `/sdk/evidence` | Nachweise dokumentieren | Controls definiert | CP-EVI | +| 1.7 | **Audit Checklist** | `/sdk/audit-checklist` | Prüfliste generieren | Evidence vorhanden | CP-CHK | +| 1.8 | **Risk Matrix** | `/sdk/risks` | Risikobewertung & Residual Risk | Checklist abgeschlossen | CP-RISK | + +### Phase 2: Dokumentengenerierung + +| # | Schritt | URL | Funktion | Voraussetzung | Checkpoint | +|---|---------|-----|----------|---------------|------------| +| 2.1 | **AI Act Klassifizierung** | `/sdk/ai-act` | Risikostufe nach EU AI Act | Phase 1 abgeschlossen | CP-AI | +| 2.2 | **Pflichtenübersicht** | `/sdk/obligations` | NIS2, DSGVO, AI Act Pflichten | AI Act Klassifizierung | CP-OBL | +| 2.3 | **DSFA** | `/sdk/dsfa` | Datenschutz-Folgenabschätzung | Bei DSFA-Empfehlung | CP-DSFA | +| 2.4 | **TOMs** | `/sdk/tom` | Technische & Org. Maßnahmen | DSFA oder Controls | CP-TOM | +| 2.5 | **Löschfristen** | `/sdk/loeschfristen` | Aufbewahrungsrichtlinien | TOMs definiert | CP-RET | +| 2.6 | **Verarbeitungsverzeichnis** | `/sdk/vvt` | Art. 30 DSGVO Dokumentation | Löschfristen definiert | CP-VVT | +| 2.7 | **Rechtliche Vorlagen** | `/sdk/consent` | AGB, Datenschutz, Nutzungsbedingungen | VVT erstellt | CP-DOC | +| 2.8 | **Cookie Banner** | `/sdk/cookie-banner` | Cookie-Consent Generator | Rechtl. Vorlagen | CP-COOK | +| 2.9 | **Einwilligungen** | `/sdk/einwilligungen` | Consent-Tracking Setup | Cookie Banner | CP-CONS | +| 2.10 | **DSR Portal** | `/sdk/dsr` | Betroffenenrechte-Portal | Einwilligungen | CP-DSR | +| 2.11 | **Escalations** | `/sdk/escalations` | Management-Workflows | DSR Portal | CP-ESC | + +### Zusatzmodule (Nicht im Hauptflow) + +| Modul | URL | Funktion | Zugang | +|-------|-----|----------|--------| +| **Legal RAG** | `/sdk/rag` | Rechtliche Suche | Jederzeit | +| **AI Quality** | `/sdk/quality` | Qualitätsprüfung | Nach Phase 1 | +| **Security Backlog** | `/sdk/security-backlog` | Aus Screening generiert | Nach Screening | + +--- + +## 4. Datenfluss & Abhängigkeiten + +``` +┌─────────────────┐ +│ Use Case │ +│ Workshop │ +│ (Advisory) │ +└────────┬────────┘ + │ UseCaseIntake + AssessmentResult + ▼ +┌─────────────────┐ ┌─────────────────┐ +│ System │────▶│ Security │ +│ Screening │ │ Backlog │ +│ (SBOM+Sec) │ │ (Issues) │ +└────────┬────────┘ └─────────────────┘ + │ ServiceModules[] + Vulnerabilities[] + ▼ +┌─────────────────┐ +│ Compliance │ +│ Modules │ +│ (Regulations) │ +└────────┬────────┘ + │ ApplicableRegulations[] + ▼ +┌─────────────────┐ +│ Requirements │ +│ (558+ Rules) │ +└────────┬────────┘ + │ Requirements[] + Gaps[] + ▼ +┌─────────────────┐ +│ Controls │ +│ (44+ TOM) │ +└────────┬────────┘ + │ Controls[] + ImplementationStatus + ▼ +┌─────────────────┐ +│ Evidence │ +│ Management │ +└────────┬────────┘ + │ Evidence[] + Validity + ▼ +┌─────────────────┐ +│ Audit │ +│ Checklist │ +└────────┬────────┘ + │ ChecklistItems[] + ComplianceScore + ▼ +┌─────────────────┐ +│ Risk Matrix │──────────────────────────────────┐ +│ (5x5) │ │ +└────────┬────────┘ │ + │ Risks[] + ResidualRisks[] │ + ▼ │ +═════════════════════════════════════════════════════│═══════ + │ PHASE 2 START │ + ▼ │ +┌─────────────────┐ │ +│ AI Act │◀─────────────────────────────────┘ +│ Klassifizierung│ (Risikodaten aus Phase 1) +└────────┬────────┘ + │ RiskLevel + Obligations + ▼ +┌─────────────────┐ +│ Pflichten- │ +│ übersicht │ +└────────┬────────┘ + │ AllObligations[] grouped by Regulation + ▼ +┌─────────────────┐ +│ DSFA │ (Nur wenn dsfa_recommended=true) +│ Generator │ +└────────┬────────┘ + │ DSFA Document + Mitigations + ▼ +┌─────────────────┐ +│ TOMs │ +│ Katalog │ +└────────┬────────┘ + │ TOM[] + ImplementationPlan + ▼ +┌─────────────────┐ +│ Löschfristen │ +│ Definieren │ +└────────┬────────┘ + │ RetentionPolicies[] + ▼ +┌─────────────────┐ +│ Verarbeitungs- │ +│ verzeichnis │ +└────────┬────────┘ + │ ProcessingActivities[] (Art. 30) + ▼ +┌─────────────────┐ +│ Rechtliche │ +│ Vorlagen │ +└────────┬────────┘ + │ Documents[] (AGB, Privacy, Terms) + ▼ +┌─────────────────┐ +│ Cookie Banner │ +│ Generator │ +└────────┬────────┘ + │ CookieBannerConfig + Scripts + ▼ +┌─────────────────┐ +│ Einwilligungen │ +│ Tracking │ +└────────┬────────┘ + │ ConsentRecords[] + AuditTrail + ▼ +┌─────────────────┐ +│ DSR Portal │ +│ Setup │ +└────────┬────────┘ + │ DSRWorkflow + Templates + ▼ +┌─────────────────┐ +│ Escalations │ +│ Workflows │ +└─────────────────┘ +``` + +--- + +## 5. Checkpoint-System + +### 5.1 Checkpoint-Typen + +```typescript +interface Checkpoint { + id: string // z.B. "CP-UC" + step: string // z.B. "use-case-workshop" + name: string // z.B. "Use Case Checkpoint" + type: 'REQUIRED' | 'RECOMMENDED' | 'OPTIONAL' + validation: ValidationRule[] + blocksProgress: boolean + requiresReview: 'NONE' | 'TEAM_LEAD' | 'DSB' | 'LEGAL' + autoValidate: boolean // Automatische Validierung bei Änderungen +} + +interface ValidationRule { + id: string + field: string // Pfad zum Feld im State + condition: 'NOT_EMPTY' | 'MIN_COUNT' | 'MIN_VALUE' | 'CUSTOM' | 'REGEX' + value?: number | string | RegExp // Vergleichswert + customValidator?: (state: SDKState) => boolean + message: string // Fehlermeldung + severity: 'ERROR' | 'WARNING' | 'INFO' +} + +interface CheckpointStatus { + checkpointId: string + passed: boolean + validatedAt: Date | null + validatedBy: string | null // User ID oder "SYSTEM" + errors: ValidationError[] + warnings: ValidationError[] + overrideReason?: string // Falls manuell überschrieben + overriddenBy?: string + overriddenAt?: Date +} +``` + +### 5.2 Checkpoint-Matrix + +| Checkpoint | Schritt | Validierung | Blockiert | Review | +|------------|---------|-------------|-----------|--------| +| CP-UC | 1.1 Use Case | Min. 1 Use Case mit allen 5 Schritten | ✅ Ja | NONE | +| CP-SCAN | 1.2 Screening | SBOM generiert, Security Scan abgeschlossen | ✅ Ja | NONE | +| CP-MOD | 1.3 Modules | Min. 1 Regulierung zugewiesen | ✅ Ja | NONE | +| CP-REQ | 1.4 Requirements | Alle kritischen Requirements adressiert | ✅ Ja | NONE | +| CP-CTRL | 1.5 Controls | Alle BLOCK-Controls definiert | ✅ Ja | DSB | +| CP-EVI | 1.6 Evidence | Evidence für alle kritischen Controls | ❌ Nein | NONE | +| CP-CHK | 1.7 Checklist | Checklist generiert | ❌ Nein | NONE | +| CP-RISK | 1.8 Risk Matrix | Alle HIGH/CRITICAL Risiken mit Mitigation | ✅ Ja | DSB | +| CP-AI | 2.1 AI Act | Risikostufe bestätigt | ✅ Ja | LEGAL | +| CP-OBL | 2.2 Pflichten | Pflichten zugewiesen | ❌ Nein | NONE | +| CP-DSFA | 2.3 DSFA | DSFA abgeschlossen (wenn erforderlich) | ✅ Ja* | DSB | +| CP-TOM | 2.4 TOMs | Alle erforderlichen TOMs definiert | ✅ Ja | NONE | +| CP-RET | 2.5 Löschfristen | Fristen für alle Datenkategorien | ✅ Ja | NONE | +| CP-VVT | 2.6 VVT | Verzeichnis vollständig | ✅ Ja | DSB | +| CP-DOC | 2.7 Vorlagen | Dokumente erstellt | ❌ Nein | LEGAL | +| CP-COOK | 2.8 Cookie | Banner konfiguriert | ❌ Nein | NONE | +| CP-CONS | 2.9 Einwilligungen | Tracking aktiviert | ❌ Nein | NONE | +| CP-DSR | 2.10 DSR | Portal konfiguriert | ❌ Nein | NONE | +| CP-ESC | 2.11 Escalations | Workflows definiert | ❌ Nein | NONE | + +*Nur wenn DSFA empfohlen wurde + +### 5.3 Validierungsregeln (Beispiele) + +```typescript +const checkpointValidations: Record = { + 'CP-UC': [ + { + id: 'uc-min-count', + field: 'useCases', + condition: 'MIN_COUNT', + value: 1, + message: 'Mindestens ein Use Case muss erstellt werden', + severity: 'ERROR' + }, + { + id: 'uc-steps-complete', + field: 'useCases', + condition: 'CUSTOM', + customValidator: (state) => state.useCases.every(uc => uc.stepsCompleted === 5), + message: 'Alle Use Cases müssen alle 5 Schritte abgeschlossen haben', + severity: 'ERROR' + } + ], + 'CP-SCAN': [ + { + id: 'sbom-exists', + field: 'sbom', + condition: 'NOT_EMPTY', + message: 'SBOM muss generiert werden', + severity: 'ERROR' + }, + { + id: 'scan-complete', + field: 'screening.status', + condition: 'CUSTOM', + customValidator: (state) => state.screening?.status === 'completed', + message: 'Security Scan muss abgeschlossen sein', + severity: 'ERROR' + } + ], + 'CP-RISK': [ + { + id: 'critical-risks-mitigated', + field: 'risks', + condition: 'CUSTOM', + customValidator: (state) => { + const criticalRisks = state.risks.filter(r => + r.severity === 'CRITICAL' || r.severity === 'HIGH' + ); + return criticalRisks.every(r => r.mitigation && r.mitigation.length > 0); + }, + message: 'Alle kritischen und hohen Risiken müssen Mitigationsmaßnahmen haben', + severity: 'ERROR' + } + ] +}; +``` + +--- + +## 6. Unified Command Bar + +### 6.1 Architektur + +```typescript +interface CommandBarState { + isOpen: boolean + query: string + context: SDKContext + suggestions: CommandSuggestion[] + history: CommandHistory[] + isLoading: boolean + error: string | null +} + +interface SDKContext { + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + activeCheckpoint: Checkpoint | null + useCases: UseCaseAssessment[] + pendingActions: Action[] +} + +interface CommandSuggestion { + id: string + type: 'ACTION' | 'NAVIGATION' | 'SEARCH' | 'GENERATE' | 'HELP' + label: string + description: string + shortcut?: string + icon?: string + action: () => void | Promise + relevanceScore: number +} + +interface CommandHistory { + id: string + query: string + type: CommandSuggestion['type'] + timestamp: Date + success: boolean +} +``` + +### 6.2 Unterstützte Befehle + +| Kategorie | Befehl | Aktion | +|-----------|--------|--------| +| **Navigation** | "Gehe zu DSFA" | navigiert zu `/sdk/dsfa` | +| | "Öffne Risk Matrix" | navigiert zu `/sdk/risks` | +| | "Zurück" | vorheriger Schritt | +| | "Phase 2" | springt zu Phase 2 Start | +| **Aktionen** | "Erstelle neuen Use Case" | startet Wizard | +| | "Exportiere als PDF" | generiert Export | +| | "Starte Security Scan" | triggert Screening | +| | "Validiere Checkpoint" | führt Validierung durch | +| **Generierung** | "Erstelle DSFA für Marketing-KI" | RAG-basierte Generierung | +| | "Welche TOMs brauche ich für Gesundheitsdaten?" | Empfehlungen | +| | "Erkläre Art. 9 DSGVO" | Rechtliche Erklärung | +| **Suche** | "Suche DSGVO Artikel 17" | Rechtliche Suche | +| | "Finde Controls für Verschlüsselung" | Control-Suche | +| **Hilfe** | "Hilfe" | zeigt verfügbare Befehle | +| | "Was muss ich als nächstes tun?" | Kontextbezogene Hilfe | + +### 6.3 Keyboard Shortcuts + +| Shortcut | Aktion | +|----------|--------| +| `⌘K` / `Ctrl+K` | Command Bar öffnen | +| `Escape` | Command Bar schließen | +| `↑` / `↓` | Navigation in Suggestions | +| `Enter` | Suggestion ausführen | +| `⌘→` / `Ctrl+→` | Nächster Schritt | +| `⌘←` / `Ctrl+←` | Vorheriger Schritt | +| `⌘S` / `Ctrl+S` | State speichern | +| `⌘E` / `Ctrl+E` | Export-Dialog | + +--- + +## 7. State Management + +### 7.1 Globaler SDK-State + +```typescript +interface SDKState { + // Metadata + version: string // Schema-Version + lastModified: Date + + // Tenant & User + tenantId: string + userId: string + subscription: SubscriptionTier + + // Progress + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + checkpoints: Record + + // Phase 1 Data + useCases: UseCaseAssessment[] + activeUseCase: string | null + screening: ScreeningResult | null + modules: ServiceModule[] + requirements: Requirement[] + controls: Control[] + evidence: Evidence[] + checklist: ChecklistItem[] + risks: Risk[] + + // Phase 2 Data + aiActClassification: AIActResult | null + obligations: Obligation[] + dsfa: DSFA | null + toms: TOM[] + retentionPolicies: RetentionPolicy[] + vvt: ProcessingActivity[] + documents: LegalDocument[] + cookieBanner: CookieBannerConfig | null + consents: ConsentRecord[] + dsrConfig: DSRConfig | null + escalationWorkflows: EscalationWorkflow[] + + // Security + sbom: SBOM | null + securityIssues: SecurityIssue[] + securityBacklog: BacklogItem[] + + // UI State + commandBarHistory: CommandHistory[] + recentSearches: string[] + preferences: UserPreferences +} + +type SubscriptionTier = 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'; + +interface UserPreferences { + language: 'de' | 'en' + theme: 'light' | 'dark' | 'system' + compactMode: boolean + showHints: boolean + autoSave: boolean + autoValidate: boolean +} +``` + +### 7.2 Persistierung + +```typescript +// Auto-Save bei jeder Änderung +const autoSave = debounce(async (state: SDKState) => { + await api.post('/sdk/v1/state/save', { + tenantId: state.tenantId, + state: serializeState(state), + checksum: calculateChecksum(state) + }); +}, 2000); + +// Load bei App-Start +const loadState = async (tenantId: string): Promise => { + const saved = await api.get(`/sdk/v1/state/${tenantId}`); + return deserializeState(saved); +}; + +// Conflict Resolution +const mergeStates = (local: SDKState, remote: SDKState): SDKState => { + if (local.lastModified > remote.lastModified) { + return local; + } + return remote; +}; +``` + +### 7.3 React Context + +```typescript +interface SDKContextValue { + state: SDKState + dispatch: React.Dispatch + + // Navigation + goToStep: (step: string) => void + goToNextStep: () => void + goToPreviousStep: () => void + + // Checkpoints + validateCheckpoint: (checkpointId: string) => Promise + overrideCheckpoint: (checkpointId: string, reason: string) => Promise + + // State Updates + updateUseCase: (id: string, data: Partial) => void + addRisk: (risk: Risk) => void + updateControl: (id: string, data: Partial) => void + + // Export + exportState: (format: 'json' | 'pdf' | 'zip') => Promise +} + +const SDKContext = React.createContext(null); + +export const useSDK = () => { + const context = useContext(SDKContext); + if (!context) { + throw new Error('useSDK must be used within SDKProvider'); + } + return context; +}; +``` + +--- + +## 8. API-Struktur + +### 8.1 SDK-spezifische Endpoints + +``` +# State Management +GET /sdk/v1/state/{tenantId} → Kompletten State laden +POST /sdk/v1/state/save → State speichern +POST /sdk/v1/state/reset → State zurücksetzen +GET /sdk/v1/state/history → State-Historie (für Undo) + +# Screening +POST /sdk/v1/screening/start → SBOM + Security Scan starten +GET /sdk/v1/screening/status → Scan-Status abrufen +GET /sdk/v1/screening/sbom → SBOM abrufen +GET /sdk/v1/screening/security → Security Issues abrufen +POST /sdk/v1/screening/backlog → Backlog aus Issues generieren + +# Checkpoints +GET /sdk/v1/checkpoints → Alle Checkpoint-Status +GET /sdk/v1/checkpoints/{id} → Einzelner Checkpoint +POST /sdk/v1/checkpoints/{id}/validate → Checkpoint validieren +POST /sdk/v1/checkpoints/{id}/override → Checkpoint überschreiben (mit Review) +GET /sdk/v1/checkpoints/{id}/history → Validierungs-Historie + +# Wizard Flow +GET /sdk/v1/flow/current → Aktueller Schritt + Kontext +POST /sdk/v1/flow/next → Zum nächsten Schritt +POST /sdk/v1/flow/previous → Zum vorherigen Schritt +GET /sdk/v1/flow/suggestions → Kontextbezogene Vorschläge +GET /sdk/v1/flow/progress → Gesamtfortschritt + +# Document Generation +POST /sdk/v1/generate/dsfa → DSFA generieren +POST /sdk/v1/generate/tom → TOMs generieren +POST /sdk/v1/generate/vvt → VVT generieren +POST /sdk/v1/generate/documents → Rechtliche Vorlagen +POST /sdk/v1/generate/cookie-banner → Cookie Banner Code +POST /sdk/v1/generate/dsr-portal → DSR Portal Config + +# Export +GET /sdk/v1/export/full → Kompletter Export (ZIP) +GET /sdk/v1/export/phase1 → Phase 1 Export +GET /sdk/v1/export/phase2 → Phase 2 Export +GET /sdk/v1/export/{document} → Einzelnes Dokument +GET /sdk/v1/export/audit-report → Audit-Report (PDF) + +# Command Bar / RAG +POST /sdk/v1/command/execute → Befehl ausführen +POST /sdk/v1/command/search → Suche durchführen +POST /sdk/v1/command/generate → Content generieren (RAG) +GET /sdk/v1/command/suggestions → Vorschläge basierend auf Kontext +``` + +### 8.2 Request/Response Beispiele + +```typescript +// POST /sdk/v1/checkpoints/{id}/validate +// Request +{ + "checkpointId": "CP-RISK", + "context": { + "userId": "user-123", + "timestamp": "2026-02-03T10:30:00Z" + } +} + +// Response +{ + "checkpointId": "CP-RISK", + "passed": false, + "validatedAt": "2026-02-03T10:30:01Z", + "validatedBy": "SYSTEM", + "errors": [ + { + "ruleId": "critical-risks-mitigated", + "field": "risks[2]", + "message": "Risiko 'Datenverlust' hat keine Mitigationsmaßnahme", + "severity": "ERROR" + } + ], + "warnings": [ + { + "ruleId": "risk-owner-assigned", + "field": "risks[5]", + "message": "Risiko 'Performance-Degradation' hat keinen Owner", + "severity": "WARNING" + } + ], + "nextActions": [ + { + "type": "FIX_ERROR", + "targetField": "risks[2].mitigation", + "suggestion": "Fügen Sie eine Mitigationsmaßnahme hinzu" + } + ] +} +``` + +--- + +## 9. UI-Komponenten + +### 9.1 Komponentenstruktur + +``` +admin-v2/components/sdk/ +├── CommandBar/ +│ ├── CommandBar.tsx # Hauptkomponente (⌘K Modal) +│ ├── CommandInput.tsx # Eingabefeld mit Autocomplete +│ ├── SuggestionList.tsx # Vorschlagsliste +│ ├── SuggestionItem.tsx # Einzelner Vorschlag +│ ├── CommandHistory.tsx # Historie-Anzeige +│ ├── useCommandBar.ts # Hook für State & Logic +│ └── index.ts +│ +├── Sidebar/ +│ ├── SDKSidebar.tsx # Hauptnavigation +│ ├── PhaseIndicator.tsx # Phase 1/2 Anzeige mit Progress +│ ├── StepList.tsx # Liste der Schritte +│ ├── StepItem.tsx # Einzelner Schritt +│ ├── CheckpointBadge.tsx # Checkpoint-Status Badge +│ ├── CollapsibleSection.tsx # Einklappbare Sektion +│ └── index.ts +│ +├── Progress/ +│ ├── ProgressBar.tsx # Gesamtfortschritt +│ ├── StepProgress.tsx # Schritt-Fortschritt (circular) +│ ├── PhaseTransition.tsx # Übergang Phase 1→2 Animation +│ ├── CompletionCard.tsx # Abschluss-Karte +│ └── index.ts +│ +├── Checkpoint/ +│ ├── CheckpointCard.tsx # Checkpoint-Anzeige +│ ├── ValidationList.tsx # Validierungsfehler/-warnungen +│ ├── ValidationItem.tsx # Einzelner Validierungsfehler +│ ├── ReviewRequest.tsx # Review anfordern +│ ├── CheckpointOverride.tsx # Override mit Begründung +│ ├── CheckpointHistory.tsx # Validierungs-Historie +│ └── index.ts +│ +├── Wizard/ +│ ├── WizardContainer.tsx # Wizard-Rahmen +│ ├── WizardStep.tsx # Einzelner Schritt +│ ├── WizardNavigation.tsx # Vor/Zurück/Überspringen +│ ├── WizardSummary.tsx # Zusammenfassung +│ ├── WizardProgress.tsx # Progress Indicator +│ └── index.ts +│ +├── Screening/ +│ ├── ScreeningDashboard.tsx # Übersicht +│ ├── SBOMViewer.tsx # SBOM Anzeige (Tabelle) +│ ├── SBOMGraph.tsx # SBOM als Dependency Graph +│ ├── SecurityIssues.tsx # Issues Liste +│ ├── SecurityIssueCard.tsx # Einzelne Issue +│ ├── BacklogGenerator.tsx # Backlog erstellen +│ ├── ScanProgress.tsx # Scan-Fortschritt +│ └── index.ts +│ +├── Generation/ +│ ├── DocumentGenerator.tsx # Dokument-Generierung +│ ├── GenerationProgress.tsx # Fortschritt (Streaming) +│ ├── DocumentPreview.tsx # Vorschau (Markdown/PDF) +│ ├── DocumentEditor.tsx # Inline-Editor +│ ├── ExportDialog.tsx # Export-Optionen +│ └── index.ts +│ +├── Risk/ +│ ├── RiskMatrix.tsx # 5x5 Risk Matrix +│ ├── RiskCard.tsx # Einzelnes Risiko +│ ├── RiskForm.tsx # Risiko hinzufügen/bearbeiten +│ ├── MitigationForm.tsx # Mitigation hinzufügen +│ └── index.ts +│ +├── Layout/ +│ ├── SDKLayout.tsx # SDK-spezifisches Layout +│ ├── SDKHeader.tsx # Header mit Aktionen +│ ├── NavigationFooter.tsx # Vor/Zurück Footer +│ └── index.ts +│ +└── common/ + ├── StatusBadge.tsx # Status-Anzeige + ├── ActionButton.tsx # Primäre Aktionen + ├── InfoTooltip.tsx # Hilfe-Tooltips + ├── EmptyState.tsx # Leerer Zustand + ├── LoadingState.tsx # Ladezustand + ├── ErrorBoundary.tsx # Fehlerbehandlung + └── index.ts +``` + +### 9.2 Beispiel-Komponenten + +```typescript +// SDKSidebar.tsx +interface SDKSidebarProps { + currentStep: string + completedSteps: string[] + checkpoints: Record + onStepClick: (step: string) => void +} + +// CheckpointCard.tsx +interface CheckpointCardProps { + checkpoint: Checkpoint + status: CheckpointStatus + onValidate: () => Promise + onOverride: (reason: string) => Promise + onRequestReview: (reviewerType: string) => Promise +} + +// CommandBar.tsx +interface CommandBarProps { + isOpen: boolean + onClose: () => void + context: SDKContext + onExecute: (command: CommandSuggestion) => Promise +} +``` + +--- + +## 10. TypeScript Interfaces + +### 10.1 Core Models + +```typescript +// Use Case Assessment +interface UseCaseAssessment { + id: string + name: string + description: string + category: string + stepsCompleted: number + steps: UseCaseStep[] + assessmentResult: AssessmentResult | null + createdAt: Date + updatedAt: Date +} + +interface UseCaseStep { + id: string + name: string + completed: boolean + data: Record +} + +interface AssessmentResult { + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + applicableRegulations: string[] + recommendedControls: string[] + dsfaRequired: boolean + aiActClassification: string +} + +// Screening +interface ScreeningResult { + id: string + status: 'pending' | 'running' | 'completed' | 'failed' + startedAt: Date + completedAt: Date | null + sbom: SBOM | null + securityScan: SecurityScanResult | null + error: string | null +} + +interface SBOM { + format: 'CycloneDX' | 'SPDX' + version: string + components: SBOMComponent[] + dependencies: SBOMDependency[] + generatedAt: Date +} + +interface SBOMComponent { + name: string + version: string + type: 'library' | 'framework' | 'application' | 'container' + purl: string + licenses: string[] + vulnerabilities: Vulnerability[] +} + +interface SecurityScanResult { + totalIssues: number + critical: number + high: number + medium: number + low: number + issues: SecurityIssue[] +} + +interface SecurityIssue { + id: string + severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' + title: string + description: string + cve: string | null + cvss: number | null + affectedComponent: string + remediation: string + status: 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'ACCEPTED' +} + +// Compliance +interface ServiceModule { + id: string + name: string + description: string + regulations: string[] + criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + processesPersonalData: boolean + hasAIComponents: boolean +} + +interface Requirement { + id: string + regulation: string + article: string + title: string + description: string + criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + applicableModules: string[] + status: 'NOT_STARTED' | 'IN_PROGRESS' | 'IMPLEMENTED' | 'VERIFIED' + controls: string[] +} + +interface Control { + id: string + name: string + description: string + type: 'TECHNICAL' | 'ORGANIZATIONAL' | 'PHYSICAL' + category: string + implementationStatus: 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED' + effectiveness: 'LOW' | 'MEDIUM' | 'HIGH' + evidence: string[] + owner: string | null + dueDate: Date | null +} + +interface Evidence { + id: string + controlId: string + type: 'DOCUMENT' | 'SCREENSHOT' | 'LOG' | 'CERTIFICATE' | 'AUDIT_REPORT' + name: string + description: string + fileUrl: string | null + validFrom: Date + validUntil: Date | null + uploadedBy: string + uploadedAt: Date +} + +// Risk +interface Risk { + id: string + title: string + description: string + category: string + likelihood: 1 | 2 | 3 | 4 | 5 + impact: 1 | 2 | 3 | 4 | 5 + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + inherentRiskScore: number + residualRiskScore: number + status: 'IDENTIFIED' | 'ASSESSED' | 'MITIGATED' | 'ACCEPTED' | 'CLOSED' + mitigation: RiskMitigation[] + owner: string | null + relatedControls: string[] + relatedRequirements: string[] +} + +interface RiskMitigation { + id: string + description: string + type: 'AVOID' | 'TRANSFER' | 'MITIGATE' | 'ACCEPT' + status: 'PLANNED' | 'IN_PROGRESS' | 'COMPLETED' + effectiveness: number // 0-100 + controlId: string | null +} + +// Phase 2 Models +interface AIActResult { + riskCategory: 'MINIMAL' | 'LIMITED' | 'HIGH' | 'UNACCEPTABLE' + systemType: string + obligations: AIActObligation[] + assessmentDate: Date + assessedBy: string + justification: string +} + +interface DSFA { + id: string + status: 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' + version: number + sections: DSFASection[] + approvals: DSFAApproval[] + createdAt: Date + updatedAt: Date +} + +interface TOM { + id: string + category: string + name: string + description: string + type: 'TECHNICAL' | 'ORGANIZATIONAL' + implementationStatus: 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED' + priority: 'LOW' | 'MEDIUM' | 'HIGH' + responsiblePerson: string | null + implementationDate: Date | null + reviewDate: Date | null + evidence: string[] +} + +interface RetentionPolicy { + id: string + dataCategory: string + description: string + legalBasis: string + retentionPeriod: string // ISO 8601 Duration + deletionMethod: string + exceptions: string[] +} + +interface ProcessingActivity { + id: string + name: string + purpose: string + legalBasis: string + dataCategories: string[] + dataSubjects: string[] + recipients: string[] + thirdCountryTransfers: boolean + retentionPeriod: string + technicalMeasures: string[] + organizationalMeasures: string[] +} + +interface CookieBannerConfig { + id: string + style: 'BANNER' | 'MODAL' | 'FLOATING' + position: 'TOP' | 'BOTTOM' | 'CENTER' + theme: 'LIGHT' | 'DARK' | 'CUSTOM' + texts: { + title: string + description: string + acceptAll: string + rejectAll: string + settings: string + save: string + } + categories: CookieCategory[] + generatedCode: { + html: string + css: string + js: string + } +} + +interface CookieCategory { + id: string + name: string + description: string + required: boolean + cookies: Cookie[] +} +``` + +--- + +## 11. Implementierungs-Roadmap + +### Sprint 1: Foundation (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| SDK-Routing | `/sdk/*` Routen einrichten | KRITISCH | - | +| SDKProvider | State Management Context | KRITISCH | - | +| SDKLayout | Layout mit Sidebar | KRITISCH | SDKProvider | +| SDKSidebar | Navigation mit Phasen | HOCH | SDKLayout | +| Checkpoint-Types | TypeScript Interfaces | HOCH | - | +| Checkpoint-Service | Validierungs-Logik | HOCH | Checkpoint-Types | +| API: State | GET/POST /sdk/v1/state/* | HOCH | - | +| API: Checkpoints | GET/POST /sdk/v1/checkpoints/* | HOCH | Checkpoint-Service | + +**Deliverables Sprint 1:** +- Funktionierendes SDK-Routing +- State-Persistierung +- Basis-Navigation zwischen Schritten + +### Sprint 2: Phase 1 Flow (3 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Use Case Workshop | Integration bestehender Advisory Board | KRITISCH | Sprint 1 | +| Screening-UI | Dashboard + SBOM Viewer | KRITISCH | Sprint 1 | +| Screening-Backend | SBOM Generation + Security Scan | KRITISCH | - | +| SecurityBacklog | Backlog aus Issues generieren | HOCH | Screening | +| Modules-Integration | Compliance Module Verknüpfung | HOCH | Screening | +| Requirements-Flow | Requirements aus Modulen ableiten | HOCH | Modules | +| Controls-Flow | Controls aus Requirements | HOCH | Requirements | +| Evidence-Management | Evidence Upload + Validierung | MITTEL | Controls | +| Audit-Checklist | Checklist generieren | MITTEL | Evidence | +| Risk-Matrix | 5x5 Matrix + Mitigation | KRITISCH | Checklist | + +**Deliverables Sprint 2:** +- Vollständiger Phase 1 Flow +- SBOM-Generierung +- Security Scanning +- Risk Assessment + +### Sprint 3: Phase 2 Flow (3 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| AI Act Klassifizierung | Wizard + Empfehlungen | KRITISCH | Phase 1 | +| Pflichtenübersicht | Gruppierte Pflichten-Ansicht | HOCH | AI Act | +| DSFA Generator | RAG-basierte Generierung | KRITISCH | Pflichtenübersicht | +| TOMs Katalog | TOM-Auswahl + Priorisierung | HOCH | DSFA | +| Löschfristen | Fristen-Management | MITTEL | TOMs | +| VVT Generator | Art. 30 Export | HOCH | Löschfristen | +| Rechtliche Vorlagen | Template-System | MITTEL | VVT | + +**Deliverables Sprint 3:** +- AI Act Compliance +- DSFA-Generierung +- TOM-Management +- VVT-Export + +### Sprint 4: Consent & DSR (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Cookie Banner Generator | Konfigurator + Code-Export | HOCH | Sprint 3 | +| Cookie Kategorien | Kategorie-Management | HOCH | Cookie Banner | +| Einwilligungen Tracking | Consent Records | MITTEL | Cookie Banner | +| DSR Portal Config | Portal-Einrichtung | MITTEL | Einwilligungen | +| DSR Workflows | Bearbeitungs-Workflows | MITTEL | DSR Portal | +| Escalations | Management-Workflows | NIEDRIG | DSR | + +**Deliverables Sprint 4:** +- Cookie Consent System +- DSR Management +- Escalation Workflows + +### Sprint 5: Command Bar & Polish (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| CommandBar UI | Modal + Input | HOCH | Sprint 4 | +| Command Parser | Befehlserkennung | HOCH | CommandBar UI | +| RAG Integration | Suche + Generierung | HOCH | Command Parser | +| Suggestion Engine | Kontextbezogene Vorschläge | MITTEL | RAG | +| Keyboard Shortcuts | Global Shortcuts | MITTEL | CommandBar | +| Export-Funktionen | PDF/ZIP/JSON Export | HOCH | - | +| Progress Animations | Übergangs-Animationen | NIEDRIG | - | +| Responsive Design | Mobile Optimierung | MITTEL | - | + +**Deliverables Sprint 5:** +- Unified Command Bar +- Export-System +- Polish & UX + +### Sprint 6: Testing & QA (1 Woche) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Unit Tests | Komponenten-Tests | KRITISCH | Sprint 5 | +| Integration Tests | API-Tests | KRITISCH | Sprint 5 | +| E2E Tests | Flow-Tests | KRITISCH | Sprint 5 | +| Performance Tests | Load Testing | HOCH | Sprint 5 | +| Security Audit | Sicherheitsprüfung | KRITISCH | Sprint 5 | +| Dokumentation | User Guide + API Docs | HOCH | Sprint 5 | +| Bug Fixes | Fehlerbehebung | KRITISCH | Tests | + +**Deliverables Sprint 6:** +- Vollständige Testabdeckung +- Dokumentation +- Production-Ready Release + +--- + +## 12. Testplan + +### 12.1 Unit Tests + +```typescript +// Beispiel: Checkpoint Validation Tests +describe('CheckpointService', () => { + describe('validateCheckpoint', () => { + it('should return passed=true when all validations pass', async () => { + const state = createMockState({ + useCases: [{ id: '1', stepsCompleted: 5 }] + }); + + const result = await checkpointService.validate('CP-UC', state); + + expect(result.passed).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return errors when use case steps incomplete', async () => { + const state = createMockState({ + useCases: [{ id: '1', stepsCompleted: 3 }] + }); + + const result = await checkpointService.validate('CP-UC', state); + + expect(result.passed).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ ruleId: 'uc-steps-complete' }) + ); + }); + + it('should allow override with valid reason', async () => { + const result = await checkpointService.override( + 'CP-UC', + 'Genehmigt durch DSB am 2026-02-03', + 'user-123' + ); + + expect(result.overrideReason).toBeTruthy(); + expect(result.passed).toBe(true); + }); + }); +}); + +// Beispiel: Risk Matrix Tests +describe('RiskMatrix', () => { + describe('calculateRiskScore', () => { + it('should calculate correct risk score', () => { + const risk: Risk = { + likelihood: 4, + impact: 5 + }; + + const score = calculateRiskScore(risk); + + expect(score).toBe(20); + expect(getRiskSeverity(score)).toBe('CRITICAL'); + }); + + it('should calculate residual risk after mitigation', () => { + const risk: Risk = { + likelihood: 4, + impact: 5, + mitigation: [{ effectiveness: 60 }] + }; + + const residualScore = calculateResidualRisk(risk); + + expect(residualScore).toBe(8); // 20 * 0.4 + }); + }); +}); + +// Beispiel: Command Bar Tests +describe('CommandParser', () => { + it('should parse navigation commands', () => { + const result = parseCommand('Gehe zu DSFA'); + + expect(result.type).toBe('NAVIGATION'); + expect(result.target).toBe('/sdk/dsfa'); + }); + + it('should parse generation commands', () => { + const result = parseCommand('Erstelle DSFA für Marketing-KI'); + + expect(result.type).toBe('GENERATE'); + expect(result.documentType).toBe('dsfa'); + expect(result.context).toContain('Marketing-KI'); + }); + + it('should return suggestions for partial input', () => { + const suggestions = getSuggestions('Erst', mockContext); + + expect(suggestions).toContainEqual( + expect.objectContaining({ label: 'Erstelle neuen Use Case' }) + ); + }); +}); +``` + +### 12.2 Integration Tests + +```typescript +// API Integration Tests +describe('SDK API', () => { + describe('POST /sdk/v1/state/save', () => { + it('should save state successfully', async () => { + const state = createMockState(); + + const response = await request(app) + .post('/sdk/v1/state/save') + .send({ tenantId: 'tenant-1', state }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.savedAt).toBeTruthy(); + }); + + it('should reject invalid state schema', async () => { + const invalidState = { invalid: 'data' }; + + await request(app) + .post('/sdk/v1/state/save') + .send({ tenantId: 'tenant-1', state: invalidState }) + .expect(400); + }); + }); + + describe('POST /sdk/v1/screening/start', () => { + it('should start SBOM generation', async () => { + const response = await request(app) + .post('/sdk/v1/screening/start') + .send({ + repositoryUrl: 'https://github.com/example/repo', + branch: 'main' + }) + .expect(202); + + expect(response.body.scanId).toBeTruthy(); + expect(response.body.status).toBe('pending'); + }); + }); + + describe('POST /sdk/v1/generate/dsfa', () => { + it('should generate DSFA document', async () => { + const response = await request(app) + .post('/sdk/v1/generate/dsfa') + .send({ + useCaseId: 'uc-1', + includeRiskAssessment: true + }) + .expect(200); + + expect(response.body.dsfa).toBeTruthy(); + expect(response.body.dsfa.sections).toHaveLength(8); + }); + }); +}); +``` + +### 12.3 E2E Tests + +```typescript +// Playwright E2E Tests +import { test, expect } from '@playwright/test'; + +test.describe('SDK Complete Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/sdk'); + await page.waitForSelector('[data-testid="sdk-sidebar"]'); + }); + + test('should complete Phase 1 workflow', async ({ page }) => { + // Step 1.1: Use Case Workshop + await page.click('[data-testid="step-use-case"]'); + await page.fill('[data-testid="use-case-name"]', 'Marketing AI'); + await page.click('[data-testid="wizard-next"]'); + // ... complete all 5 steps + await expect(page.locator('[data-testid="checkpoint-CP-UC"]')).toHaveAttribute( + 'data-passed', + 'true' + ); + + // Step 1.2: Screening + await page.click('[data-testid="step-screening"]'); + await page.fill('[data-testid="repository-url"]', 'https://github.com/example/repo'); + await page.click('[data-testid="start-scan"]'); + await page.waitForSelector('[data-testid="scan-complete"]', { timeout: 60000 }); + + // ... continue through Phase 1 + }); + + test('should block progress when checkpoint fails', async ({ page }) => { + await page.click('[data-testid="step-requirements"]'); + await page.click('[data-testid="nav-next"]'); + + // Should show checkpoint error + await expect(page.locator('[data-testid="checkpoint-error"]')).toBeVisible(); + await expect(page.locator('[data-testid="checkpoint-error"]')).toContainText( + 'Vorherige Schritte nicht abgeschlossen' + ); + }); + + test('should open command bar with Cmd+K', async ({ page }) => { + await page.keyboard.press('Meta+k'); + + await expect(page.locator('[data-testid="command-bar"]')).toBeVisible(); + + await page.fill('[data-testid="command-input"]', 'Gehe zu DSFA'); + await page.keyboard.press('Enter'); + + await expect(page).toHaveURL('/sdk/dsfa'); + }); + + test('should export complete documentation', async ({ page }) => { + // Navigate to completed state + await page.goto('/sdk/escalations'); + + // Open command bar and export + await page.keyboard.press('Meta+k'); + await page.fill('[data-testid="command-input"]', 'Exportiere als PDF'); + await page.keyboard.press('Enter'); + + // Wait for download + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('[data-testid="confirm-export"]') + ]); + + expect(download.suggestedFilename()).toMatch(/compliance-report.*\.pdf/); + }); +}); +``` + +### 12.4 Performance Tests + +```typescript +// k6 Load Tests +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // Ramp up + { duration: '1m', target: 20 }, // Stay at 20 users + { duration: '30s', target: 50 }, // Ramp up more + { duration: '1m', target: 50 }, // Stay at 50 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests < 500ms + http_req_failed: ['rate<0.01'], // Error rate < 1% + }, +}; + +export default function () { + // State Load Test + const stateRes = http.get('http://localhost:3002/api/sdk/v1/state/tenant-1'); + check(stateRes, { + 'state load status 200': (r) => r.status === 200, + 'state load time < 200ms': (r) => r.timings.duration < 200, + }); + + // Checkpoint Validation Test + const checkpointRes = http.post( + 'http://localhost:3002/api/sdk/v1/checkpoints/CP-UC/validate', + JSON.stringify({ context: { userId: 'user-1' } }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(checkpointRes, { + 'checkpoint validation status 200': (r) => r.status === 200, + 'checkpoint validation time < 300ms': (r) => r.timings.duration < 300, + }); + + sleep(1); +} +``` + +### 12.5 Test Coverage Requirements + +| Bereich | Minimum Coverage | Ziel Coverage | +|---------|------------------|---------------| +| Komponenten | 80% | 90% | +| Hooks | 85% | 95% | +| Services | 90% | 95% | +| API Endpoints | 90% | 95% | +| State Management | 85% | 95% | +| Checkpoint Logic | 95% | 100% | +| **Gesamt** | **85%** | **92%** | + +--- + +## 13. Dokumentation + +### 13.1 Benutzerhandbuch + +#### Inhaltsverzeichnis + +1. **Erste Schritte** + - SDK aktivieren + - Dashboard Übersicht + - Navigation verstehen + +2. **Phase 1: Compliance Assessment** + - 1.1 Use Case Workshop durchführen + - 1.2 System Screening starten + - 1.3 Compliance Module zuweisen + - 1.4 Requirements prüfen + - 1.5 Controls definieren + - 1.6 Evidence hochladen + - 1.7 Audit Checklist erstellen + - 1.8 Risk Matrix ausfüllen + +3. **Phase 2: Dokumentengenerierung** + - 2.1 AI Act Klassifizierung + - 2.2 Pflichtenübersicht verstehen + - 2.3 DSFA erstellen + - 2.4 TOMs auswählen + - 2.5 Löschfristen festlegen + - 2.6 Verarbeitungsverzeichnis pflegen + - 2.7 Rechtliche Vorlagen nutzen + - 2.8 Cookie Banner konfigurieren + - 2.9 Einwilligungen tracken + - 2.10 DSR Portal einrichten + - 2.11 Escalations konfigurieren + +4. **Command Bar** + - Befehle verwenden + - Tastaturkürzel + - Suche und RAG + +5. **Export & Berichte** + - PDF Export + - ZIP Export + - Audit-Berichte + +6. **FAQ & Troubleshooting** + +### 13.2 API-Dokumentation + +#### OpenAPI Specification (Auszug) + +```yaml +openapi: 3.1.0 +info: + title: AI Compliance SDK API + version: 1.0.0 + description: API für das AI Compliance SDK + +servers: + - url: /api/sdk/v1 + description: SDK API v1 + +paths: + /state/{tenantId}: + get: + summary: Kompletten State laden + tags: [State Management] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + responses: + '200': + description: State erfolgreich geladen + content: + application/json: + schema: + $ref: '#/components/schemas/SDKState' + '404': + description: Tenant nicht gefunden + + /state/save: + post: + summary: State speichern + tags: [State Management] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tenantId: + type: string + state: + $ref: '#/components/schemas/SDKState' + responses: + '200': + description: State erfolgreich gespeichert + + /checkpoints/{id}/validate: + post: + summary: Checkpoint validieren + tags: [Checkpoints] + parameters: + - name: id + in: path + required: true + schema: + type: string + enum: [CP-UC, CP-SCAN, CP-MOD, CP-REQ, CP-CTRL, CP-EVI, CP-CHK, CP-RISK, CP-AI, CP-OBL, CP-DSFA, CP-TOM, CP-RET, CP-VVT, CP-DOC, CP-COOK, CP-CONS, CP-DSR, CP-ESC] + responses: + '200': + description: Validierungsergebnis + content: + application/json: + schema: + $ref: '#/components/schemas/CheckpointStatus' + + /screening/start: + post: + summary: SBOM + Security Scan starten + tags: [Screening] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + repositoryUrl: + type: string + format: uri + branch: + type: string + default: main + scanTypes: + type: array + items: + type: string + enum: [sbom, security, license] + responses: + '202': + description: Scan gestartet + content: + application/json: + schema: + type: object + properties: + scanId: + type: string + status: + type: string + enum: [pending, running] + + /generate/dsfa: + post: + summary: DSFA generieren + tags: [Document Generation] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + useCaseId: + type: string + includeRiskAssessment: + type: boolean + language: + type: string + enum: [de, en] + default: de + responses: + '200': + description: DSFA erfolgreich generiert + content: + application/json: + schema: + $ref: '#/components/schemas/DSFA' + +components: + schemas: + SDKState: + type: object + properties: + version: + type: string + tenantId: + type: string + currentPhase: + type: integer + enum: [1, 2] + currentStep: + type: string + completedSteps: + type: array + items: + type: string + checkpoints: + type: object + additionalProperties: + $ref: '#/components/schemas/CheckpointStatus' + useCases: + type: array + items: + $ref: '#/components/schemas/UseCaseAssessment' + # ... weitere Felder + + CheckpointStatus: + type: object + properties: + checkpointId: + type: string + passed: + type: boolean + validatedAt: + type: string + format: date-time + errors: + type: array + items: + $ref: '#/components/schemas/ValidationError' + warnings: + type: array + items: + $ref: '#/components/schemas/ValidationError' + + ValidationError: + type: object + properties: + ruleId: + type: string + field: + type: string + message: + type: string + severity: + type: string + enum: [ERROR, WARNING, INFO] +``` + +### 13.3 Entwickler-Dokumentation + +#### Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND (Next.js) │ +├─────────────────────────────────────────────────────────────────┤ +│ Pages (/sdk/*) │ Components │ Hooks │ State (Context) │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ HTTP/REST + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API Layer (Next.js Route Handlers) │ +├─────────────────────────────────────────────────────────────────┤ +│ /api/sdk/v1/* │ Authentication │ Rate Limiting │ CORS │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ Internal HTTP + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ BACKEND (Go/Gin) │ +├─────────────────────────────────────────────────────────────────┤ +│ Handlers │ Services │ UCCA Framework │ LLM Integration │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ SQL/Cache + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +├─────────────────────────────────────────────────────────────────┤ +│ PostgreSQL │ Valkey/Redis │ MinIO │ Qdrant (Vector DB) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Neue Komponente erstellen + +```typescript +// 1. Erstelle die Komponente in components/sdk/ +// components/sdk/MyComponent/MyComponent.tsx + +import { useSDK } from '@/lib/sdk/context'; + +interface MyComponentProps { + title: string; + onAction: () => void; +} + +export function MyComponent({ title, onAction }: MyComponentProps) { + const { state, dispatch } = useSDK(); + + return ( +
+

{title}

+

Current Step: {state.currentStep}

+ +
+ ); +} + +// 2. Exportiere in index.ts +// components/sdk/MyComponent/index.ts +export { MyComponent } from './MyComponent'; + +// 3. Verwende in einer Page +// app/(admin)/sdk/my-page/page.tsx +import { MyComponent } from '@/components/sdk/MyComponent'; + +export default function MyPage() { + return ( + console.log('clicked')} + /> + ); +} +``` + +#### Neuen API-Endpoint hinzufügen + +```typescript +// app/api/sdk/v1/my-endpoint/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const tenantId = request.headers.get('x-tenant-id'); + + // Backend aufrufen + const backendUrl = process.env.SDK_BACKEND_URL; + const response = await fetch(`${backendUrl}/my-endpoint`, { + headers: { + 'X-Tenant-ID': tenantId, + }, + }); + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validierung + if (!body.requiredField) { + return NextResponse.json( + { error: 'requiredField is required' }, + { status: 400 } + ); + } + + // Backend aufrufen + const backendUrl = process.env.SDK_BACKEND_URL; + const response = await fetch(`${backendUrl}/my-endpoint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} +``` + +--- + +## 14. Offene Fragen & Klärungsbedarf + +### Fragen die VOR Sprint 1 geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 1 | **Backend-Verbindung für Screening**: Wie verbindet sich der SDK mit dem Kunden-Backend? Welche Authentifizierung wird verwendet? | Architektur | Blockiert Screening-Implementierung | Tech Lead + Security | +| 2 | **Welche Systeme sollen gescannt werden können?** (Git Repos, Container Registries, Cloud APIs) | Screening | Bestimmt SBOM-Generierungs-Strategie | Product Owner | +| 3 | **Multi-Use-Case Handling**: Sollen alle Use Cases denselben Compliance-Flow durchlaufen? Oder gibt es einen übergeordneten "Projekt"-Kontext? | Datenmodell | Beeinflusst State-Struktur | Product Owner | + +### Fragen die VOR Sprint 4 geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 4 | **Cookie Banner Integration**: Welche Plattformen sollen unterstützt werden? (Web, iOS, Android) | Cookie Banner | Bestimmt Umfang der Implementierung | Product Owner | +| 5 | **Soll ein fertiger Code-Snippet generiert werden?** Oder nur Konfiguration? | Cookie Banner | Frontend-Aufwand | Tech Lead | + +### Fragen die VOR Go-Live geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 6 | **Subscription Tiers**: Welche Features sind in welchem Tier verfügbar? | Business | Feature Flags Implementierung | Product Owner + Business | +| 7 | **Gibt es Nutzungslimits?** (z.B. max. Use Cases, Scans pro Monat) | Business | Rate Limiting Implementierung | Product Owner + Business | + +### Zusätzliche technische Fragen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 8 | **LLM Provider für RAG/Generierung**: Ollama, Anthropic, oder OpenAI als Standard? | Infrastruktur | Kosten, Performance, Datenschutz | Tech Lead + DSB | +| 9 | **Datenhoheit**: Wo werden generierte Dokumente gespeichert? On-Premise Option? | Infrastruktur | Speicher-Architektur | Tech Lead + Security | +| 10 | **Audit Trail**: Wie granular soll die Änderungsverfolgung sein? | Compliance | Datenbankschema | DSB + Tech Lead | + +### Empfohlene Klärungsreihenfolge + +``` +Woche 0 (vor Projektstart): +├── Frage 1-3 klären (Architektur-Entscheidungen) +├── Frage 8 klären (LLM Provider) +└── Frage 9 klären (Datenhoheit) + +Sprint 1-2: +├── Frage 10 klären (Audit Trail) +└── Dokumentation der Entscheidungen + +Sprint 3 (vor Phase 2): +├── Frage 4-5 klären (Cookie Banner) +└── Feature-Scope finalisieren + +Sprint 5 (vor Go-Live): +├── Frage 6-7 klären (Subscription Tiers) +└── Pricing-Modell integrieren +``` + +--- + +## 15. Akzeptanzkriterien + +### Funktionale Anforderungen + +| # | Kriterium | Testmethode | Status | +|---|-----------|-------------|--------| +| F1 | Nutzer kann kompletten Flow in einer Session durchlaufen | E2E Test | ⬜ | +| F2 | Checkpoints blockieren korrekt bei fehlenden Daten | Integration Test | ⬜ | +| F3 | Command Bar funktioniert in jedem Schritt | E2E Test | ⬜ | +| F4 | Alle 11 Dokument-Typen werden korrekt generiert | Unit + Integration | ⬜ | +| F5 | Export enthält alle relevanten Daten (PDF, ZIP, JSON) | Integration Test | ⬜ | +| F6 | SBOM-Generierung funktioniert für Git Repositories | Integration Test | ⬜ | +| F7 | Security Scan identifiziert bekannte CVEs | Integration Test | ⬜ | +| F8 | Risk Matrix berechnet korrekte Scores | Unit Test | ⬜ | +| F9 | Cookie Banner generiert funktionierenden Code | Manual + E2E | ⬜ | +| F10 | DSR Portal kann Anfragen entgegennehmen | E2E Test | ⬜ | + +### Nicht-funktionale Anforderungen + +| # | Kriterium | Zielwert | Testmethode | Status | +|---|-----------|----------|-------------|--------| +| NF1 | Page Load Time | < 2s | Lighthouse | ⬜ | +| NF2 | State Save Latency | < 500ms | Performance Test | ⬜ | +| NF3 | Checkpoint Validation | < 300ms | Performance Test | ⬜ | +| NF4 | Document Generation | < 30s | Performance Test | ⬜ | +| NF5 | Concurrent Users | 50+ | Load Test | ⬜ | +| NF6 | Error Rate | < 1% | Monitoring | ⬜ | +| NF7 | Test Coverage | > 85% | Jest/Vitest | ⬜ | +| NF8 | Accessibility | WCAG 2.1 AA | axe-core | ⬜ | +| NF9 | Mobile Responsive | iOS/Android | Manual Test | ⬜ | +| NF10 | Browser Support | Chrome, Firefox, Safari, Edge | E2E Test | ⬜ | + +### Sicherheitsanforderungen + +| # | Kriterium | Standard | Status | +|---|-----------|----------|--------| +| S1 | Authentifizierung | OAuth 2.0 / OIDC | ⬜ | +| S2 | Autorisierung | RBAC mit 4 Rollen | ⬜ | +| S3 | Datenverschlüsselung at Rest | AES-256 | ⬜ | +| S4 | Datenverschlüsselung in Transit | TLS 1.3 | ⬜ | +| S5 | Input Validation | OWASP Guidelines | ⬜ | +| S6 | Audit Logging | Alle Schreiboperationen | ⬜ | +| S7 | Rate Limiting | 100 req/min pro User | ⬜ | +| S8 | CSRF Protection | Token-basiert | ⬜ | +| S9 | XSS Prevention | CSP Headers | ⬜ | +| S10 | SQL Injection Prevention | Parameterized Queries | ⬜ | + +--- + +## Anhang + +### A. Glossar + +| Begriff | Definition | +|---------|------------| +| **DSFA** | Datenschutz-Folgenabschätzung (Art. 35 DSGVO) | +| **TOM** | Technische und Organisatorische Maßnahmen | +| **VVT** | Verarbeitungsverzeichnis (Art. 30 DSGVO) | +| **DSR** | Data Subject Request (Betroffenenrechte) | +| **SBOM** | Software Bill of Materials | +| **UCCA** | Unified Compliance Control Architecture | +| **RAG** | Retrieval-Augmented Generation | +| **CVE** | Common Vulnerabilities and Exposures | +| **CVSS** | Common Vulnerability Scoring System | + +### B. Referenzen + +- [EU AI Act](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:52021PC0206) +- [DSGVO](https://eur-lex.europa.eu/eli/reg/2016/679/oj) +- [NIS2 Directive](https://eur-lex.europa.eu/eli/dir/2022/2555) +- [CycloneDX SBOM Standard](https://cyclonedx.org/) +- [SPDX Standard](https://spdx.dev/) + +### C. Änderungshistorie + +| Version | Datum | Autor | Änderungen | +|---------|-------|-------|------------| +| 1.0.0 | 2026-02-03 | AI Compliance Team | Initiale Version | + +--- + +*Dieses Dokument wurde erstellt für das AI Compliance SDK Projekt.* diff --git a/admin-v2/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md b/admin-v2/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md new file mode 100644 index 0000000..7cbd71f --- /dev/null +++ b/admin-v2/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md @@ -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* diff --git a/admin-v2/CONTENT_SERVICE_SETUP.md b/admin-v2/CONTENT_SERVICE_SETUP.md new file mode 100644 index 0000000..65a2aba --- /dev/null +++ b/admin-v2/CONTENT_SERVICE_SETUP.md @@ -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: + +# 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 diff --git a/admin-v2/IMPLEMENTATION_SUMMARY.md b/admin-v2/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..00d0361 --- /dev/null +++ b/admin-v2/IMPLEMENTATION_SUMMARY.md @@ -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: +} +→ 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!** diff --git a/admin-v2/MAC_MINI_SETUP.md b/admin-v2/MAC_MINI_SETUP.md new file mode 100644 index 0000000..faab752 --- /dev/null +++ b/admin-v2/MAC_MINI_SETUP.md @@ -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 diff --git a/admin-v2/Makefile b/admin-v2/Makefile new file mode 100644 index 0000000..2c95009 --- /dev/null +++ b/admin-v2/Makefile @@ -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" diff --git a/admin-v2/POLICY_VAULT_OVERVIEW.md b/admin-v2/POLICY_VAULT_OVERVIEW.md new file mode 100644 index 0000000..c7c3580 --- /dev/null +++ b/admin-v2/POLICY_VAULT_OVERVIEW.md @@ -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 ` + +### 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 diff --git a/admin-v2/SBOM.md b/admin-v2/SBOM.md new file mode 100644 index 0000000..6302a25 --- /dev/null +++ b/admin-v2/SBOM.md @@ -0,0 +1,1204 @@ +# Software Bill of Materials (SBOM) +## BreakPilot PWA + +**Version:** 1.5.0 +**Letzte Aktualisierung:** 2026-02-08 +**Verantwortlicher:** BreakPilot Development Team + +--- + +## Übersicht + +Diese SBOM dokumentiert alle Open-Source-Komponenten, die in BreakPilot verwendet werden, einschließlich ihrer Lizenzen und Nutzungsbedingungen für den kommerziellen Einsatz. + +--- + +## 1. LibreChat (Chat-Interface) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | LibreChat | +| **Version** | latest (dev) | +| **Repository** | https://github.com/danny-avila/LibreChat | +| **Lizenz** | MIT License | +| **Copyright** | Copyright (c) 2025 LibreChat | +| **Kommerzielle Nutzung** | Erlaubt | +| **Attribution erforderlich** | Ja (Lizenztext beibehalten) | +| **Verwendungszweck** | Chat/Prompt-Oberfläche für Nutzer | + +### LibreChat Abhängigkeiten (Container) + +| Komponente | Version | Lizenz | Kommerzielle Nutzung | +|------------|---------|--------|----------------------| +| MongoDB | latest | SSPL-1.0 | Ja (mit Einschränkungen*) | +| Meilisearch | v1.12.3 | MIT | Ja | +| PostgreSQL (pgvector) | 0.8.0-pg15 | PostgreSQL License | Ja | +| LibreChat RAG API | latest (dev-lite) | MIT | Ja | + +> *MongoDB SSPL: Erlaubt kommerzielle Nutzung, solange MongoDB nicht als Service angeboten wird. + +--- + +## 2. BreakPilot Backend (Python/FastAPI) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | FastAPI | +| **Lizenz** | MIT License | +| **Repository** | https://github.com/tiangolo/fastapi | +| **Kommerzielle Nutzung** | Erlaubt | + +### Python Dependencies + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| FastAPI | 0.123.9 | MIT | Ja | +| Uvicorn | 0.38.0 | BSD-3-Clause | Ja | +| Starlette | 0.49.3 | BSD-3-Clause | Ja | +| Pydantic | 2.12.5 | MIT | Ja | +| httpx | 0.28.1 | BSD-3-Clause | Ja | +| requests | 2.32.5 | Apache-2.0 | Ja | +| PyJWT | 2.10.1 | MIT | Ja | +| python-multipart | 0.0.20 | Apache-2.0 | Ja | +| Jinja2 | 3.1.6 | BSD-3-Clause | Ja | +| WeasyPrint | 66.0 | BSD-3-Clause | Ja | +| python-dateutil | 2.9.0 | Apache-2.0/BSD | Ja | +| python-docx | 1.2.0 | MIT | Ja | +| mammoth | 1.11.0 | BSD-2-Clause | Ja | +| Markdown | 3.9 | BSD-3-Clause | Ja | +| Pillow | 11.3.0 | HPND | Ja | +| opencv-python | 4.12.0 | Apache-2.0 | Ja | +| numpy | 2.0.2 | BSD-3-Clause | Ja | +| anthropic | 0.75.0 | MIT | Ja | +| email-validator | 2.3.0 | CC0-1.0 | Ja | +| PyJWKClient (PyJWT) | 2.10.1 | MIT | Ja | Keycloak JWKS Validierung | +| pytest | 8.4.2 | MIT | Ja | +| pytest-asyncio | 1.2.0 | Apache-2.0 | Ja | +| beautifulsoup4 | 4.12.3 | MIT | Ja | + +--- + +## 3. Consent Service (Go) + +| Eigenschaft | Wert | +|-------------|------| +| **Sprache** | Go 1.23+ | +| **Lizenz** | Proprietär (BreakPilot) | + +### Go Dependencies (Direkt) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| gin-gonic/gin | 1.11.0 | MIT | Ja | +| golang-jwt/jwt/v5 | 5.3.0 | MIT | Ja | +| google/uuid | 1.6.0 | BSD-3-Clause | Ja | +| jackc/pgx/v5 | 5.7.6 | MIT | Ja | +| joho/godotenv | 1.5.1 | MIT | Ja | +| skip2/go-qrcode | 0.0.0 | MIT | Ja | +| golang.org/x/crypto | 0.40.0 | BSD-3-Clause | Ja | + +### Go Dependencies (Indirekt/Transitiv) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| bytedance/sonic | 1.14.0 | Apache-2.0 | Ja | +| go-playground/validator/v10 | 10.27.0 | MIT | Ja | +| goccy/go-json | 0.10.2 | MIT | Ja | +| jackc/pgpassfile | 1.0.0 | MIT | Ja | +| jackc/pgservicefile | 1.0.0 | MIT | Ja | +| jackc/puddle/v2 | 2.2.2 | MIT | Ja | +| ugorji/go/codec | 1.3.0 | MIT | Ja | +| google.golang.org/protobuf | 1.36.9 | BSD-3-Clause | Ja | + +--- + +## 4. Matrix Synapse (Schulkommunikation) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Matrix Synapse | +| **Version** | latest | +| **Repository** | https://github.com/element-hq/synapse | +| **Docker Image** | matrixdotorg/synapse:latest | +| **Lizenz** | AGPL-3.0 | +| **Copyright** | Copyright (c) 2014-2025 Element (formerly New Vector Ltd) | +| **Kommerzielle Nutzung** | Erlaubt (mit Bedingungen*) | +| **Attribution erforderlich** | Ja | +| **Verwendungszweck** | E2EE Messenger für Eltern-Lehrer-Kommunikation | + +> *AGPL-3.0: Kommerzielle Nutzung erlaubt. Wenn Sie Synapse modifizieren und als Service anbieten, müssen die Änderungen unter AGPL veröffentlicht werden. Bei reiner Nutzung ohne Modifikation keine zusätzlichen Pflichten. + +### Matrix Protocol + +| Komponente | Lizenz | Beschreibung | +|------------|--------|--------------| +| Matrix Protocol | Apache-2.0 | Offenes Kommunikationsprotokoll | +| Megolm (E2EE) | Apache-2.0 | Ende-zu-Ende-Verschlüsselung | + +### Matrix Service Dependencies (Go) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| google/uuid | 1.6.0 | BSD-3-Clause | Ja | +| net/http (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | +| encoding/json (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | + +--- + +## 5. Jitsi Meet (Videokonferenzen) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Jitsi Meet | +| **Version** | stable-9823 | +| **Repository** | https://github.com/jitsi/jitsi-meet | +| **Docker Images** | jitsi/web, jitsi/prosody, jitsi/jicofo, jitsi/jvb | +| **Lizenz** | Apache-2.0 | +| **Copyright** | Copyright (c) 2013-2025 Atlassian Pty Ltd & Contributors | +| **Kommerzielle Nutzung** | Erlaubt | +| **Attribution erforderlich** | Ja (NOTICE-Datei beibehalten) | +| **Verwendungszweck** | Videokonferenzen für Schulungen, Elterngespräche | + +### Jitsi Komponenten + +| Komponente | Image | Lizenz | Beschreibung | +|------------|-------|--------|--------------| +| Jitsi Web | jitsi/web:stable-9823 | Apache-2.0 | Web-Frontend | +| Prosody | jitsi/prosody:stable-9823 | MIT | XMPP-Server | +| Jicofo | jitsi/jicofo:stable-9823 | Apache-2.0 | Conference Focus | +| Jitsi Videobridge | jitsi/jvb:stable-9823 | Apache-2.0 | WebRTC SFU | + +### Jitsi Abhängigkeiten + +| Komponente | Lizenz | Beschreibung | +|------------|--------|--------------| +| WebRTC | BSD-3-Clause | Echtzeit-Kommunikation | +| Olibs (Olm) | Apache-2.0 | Verschlüsselung | +| Ogg/Opus | BSD-3-Clause | Audio-Codec | +| VP8/VP9 | BSD-3-Clause | Video-Codec | + +### Jitsi Service Dependencies (Go) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| google/uuid | 1.6.0 | BSD-3-Clause | Ja | +| crypto/hmac (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | +| encoding/base64 (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | + +--- + +## 6. Datenbanken + +| Datenbank | Version | Lizenz | Kommerzielle Nutzung | Verwendung | +|-----------|---------|--------|----------------------|------------| +| PostgreSQL | 16-alpine | PostgreSQL License | Ja | Hauptdatenbank | +| PostgreSQL (Synapse) | 16-alpine | PostgreSQL License | Ja | Matrix Datenbank | +| pgvector Extension | 0.8.0 | PostgreSQL License | Ja | LibreChat RAG | +| MongoDB | latest | SSPL-1.0 | Ja* | LibreChat | +| Meilisearch | 1.12.3 | MIT | Ja | LibreChat Suche | + +--- + +## 7. Frontend (PWA) + +| Komponente | Lizenz | Kommerzielle Nutzung | +|------------|--------|----------------------| +| HTML5/CSS3/JS | N/A | N/A | +| Service Worker API | N/A | N/A | + +--- + +## 7a. Typografie & Schriftarten + +Diese Sektion dokumentiert alle verwendeten Schriftarten für Website, Marketing und E-Mails, um sicherzustellen, dass nur lizenzfreie Schriften verwendet werden. + +### Primäre Schriftart: Inter + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Inter | +| **Designer** | Rasmus Andersson | +| **Version** | Variable Font | +| **Repository** | https://github.com/rsms/inter | +| **Lizenz** | SIL Open Font License 1.1 (OFL-1.1) | +| **Kommerzielle Nutzung** | Ja, uneingeschränkt | +| **Attribution erforderlich** | Nein | +| **Modifikation erlaubt** | Ja | + +### Verwendete Font-Weights + +| Weight | Name | Verwendung | +|--------|------|------------| +| 400 | Regular | Fließtext, Beschreibungen | +| 500 | Medium | Labels, Buttons | +| 600 | Semi-Bold | Überschriften H3-H6, Hervorhebungen | +| 700 | Bold | Überschriften H1-H2, CTAs | + +### Einbindungsmethode + +| Plattform | Methode | Datei | +|-----------|---------|-------| +| Website (Next.js) | Google Fonts CDN | `website/app/globals.css` | +| Admin Panel | Google Fonts CDN | `backend/frontend/static/css/studio.css` | +| Kundenportal | Google Fonts CDN | `backend/frontend/static/css/customer.css` | +| E-Mail Templates | System Font Fallback | Inline CSS | + +### Google Fonts Import + +```css +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +``` + +### Font Stack (Fallback) + +```css +font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif; +``` + +### System-Fallback-Schriften + +| Betriebssystem | Fallback Font | Lizenz | +|----------------|---------------|--------| +| macOS/iOS | SF Pro Text | Apple Proprietary (vorinstalliert) | +| Windows | Segoe UI | Microsoft Proprietary (vorinstalliert) | +| Android | Roboto | Apache-2.0 (vorinstalliert) | +| Linux | Sans-Serif Default | Varies (system font) | + +### E-Mail-sichere Schriftarten + +Für E-Mail-Templates werden nur websichere Schriften verwendet: + +| Font | Fallback für | Lizenz | +|------|--------------|--------| +| Arial | Sans-Serif Fließtext | Monotype (lizenzfrei für E-Mail) | +| Helvetica | macOS/iOS | Apple (vorinstalliert) | +| Georgia | Serif (optional) | Microsoft (lizenzfrei für E-Mail) | + +### Icon-Fonts & Symbol-Sets + +| Name | Quelle | Lizenz | Verwendung | +|------|--------|--------|------------| +| Heroicons | heroicons.com | MIT | Admin Panel SVG Icons | +| System Emojis | OS-nativ | N/A | Feature-Icons, Bundesland-Marker | + +### Nicht verwendete / Ausgeschlossene Schriften + +| Schriftart | Grund für Ausschluss | +|------------|----------------------| +| Adobe Fonts (Typekit) | Erfordert Adobe-Lizenz | +| Google Fonts (proprietäre) | Nur OFL/Apache-lizenzierte | +| MyFonts/Linotype | Kommerzielle Lizenz erforderlich | +| Custom Brand Fonts | Lizenzkosten, keine Notwendigkeit | + +### Compliance-Checkliste (Typografie) + +- [x] Inter ist OFL-1.1 lizenziert (vollständig frei für kommerzielle Nutzung) +- [x] Keine proprietären Schriften eingebunden +- [x] System-Fallbacks sind auf allen Plattformen lizenzfrei +- [x] E-Mail-Templates verwenden nur websichere Schriften +- [x] SVG-Icons (Heroicons) sind MIT-lizenziert +- [x] Keine Schrift-Dateien (.woff/.ttf) mit unklarer Lizenz im Repository + +### Performance-Hinweis + +Google Fonts werden mit `display=swap` geladen, um FOUT (Flash of Unstyled Text) zu minimieren. Für Produktion kann eine lokale Einbindung über `/public/fonts/` erwogen werden. + +--- + +## Lizenz-Zusammenfassung + +### Erlaubte Lizenzen für kommerzielle Nutzung: + +| Lizenz | Bedingungen | +|--------|-------------| +| **MIT** | Copyright-Vermerk beibehalten | +| **BSD-2-Clause** | Copyright-Vermerk beibehalten | +| **BSD-3-Clause** | Copyright-Vermerk beibehalten, keine Endorsement-Nutzung | +| **Apache-2.0** | NOTICE-Datei beibehalten, Patent-Klausel beachten | +| **PostgreSQL License** | Copyright-Vermerk beibehalten | +| **SSPL-1.0** | Kein MongoDB-as-a-Service anbieten | +| **AGPL-3.0** | Bei Modifikation + Service: Quellcode veröffentlichen | + +--- + +## Compliance-Checkliste + +- [x] Alle Open-Source-Lizenzen erlauben kommerzielle Nutzung +- [x] MIT-Lizenztexte werden mit dem Produkt ausgeliefert +- [x] Copyright-Hinweise sind dokumentiert +- [x] SSPL-Einschränkungen für MongoDB werden eingehalten (kein DBaaS) +- [x] AGPL-3.0-Einschränkungen für Matrix Synapse beachtet (keine Modifikationen) +- [x] SBOM wird regelmäßig aktualisiert + +--- + +## 8. LLM Platform (geplant) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot LLM Platform | +| **Status** | In Planung | +| **Dokumentation** | [/docs/llm-platform/README.md](/docs/llm-platform/README.md) | + +### LLM Inference Stack + +| Komponente | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|------------|---------|--------|----------------------|--------------| +| vLLM | latest | Apache-2.0 | Ja | OpenAI-kompatible Inference Engine | +| Ollama | latest | MIT | Ja | Lokale Entwicklung & Fallback | + +### LLM Modelle + +| Modell | Lizenz | Kommerzielle Nutzung | Einschränkungen | +|--------|--------|----------------------|-----------------| +| Llama 3.1 (8B/70B) | Llama 3.1 Community License | Ja | >700M MAU erfordert Meta-Lizenzvereinbarung | +| Mistral 7B | Apache-2.0 | Ja | Keine | +| Mixtral 8x7B | Apache-2.0 | Ja | Keine | +| Claude API | Proprietär (Anthropic) | Ja | Pay-per-use, API Terms | + +> **Llama 3.1 License:** Die Llama 3.1 Community License erlaubt kommerzielle Nutzung. Bei mehr als 700 Millionen monatlich aktiven Nutzern ist eine separate Lizenzvereinbarung mit Meta erforderlich. Für BreakPilot (Schulkontext) ist dies nicht relevant. + +### LLM Gateway Dependencies (Python) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| sse-starlette | 2.x | BSD-3-Clause | Ja | +| feedparser | 6.x | BSD-2-Clause | Ja | +| simhash | 2.x | MIT | Ja | +| tavily-python | latest | MIT | Ja | + +### Hosting (Phase 1) + +| Anbieter | Dienst | Datenschutz | +|----------|--------|-------------| +| vast.ai | GPU-Mietung | US-basiert, nur für Inference (keine PII) | +| Bestehende Infra | Gateway, DB | DE-hosted | + +--- + +## 9. Education Search Service (edu-search-service) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Education Search Service | +| **Status** | In Entwicklung (Phase 0 PoC) | +| **Sprache** | Go 1.21+ | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | EU-gehostete Bildungsquellen-Suche als Tavily-Alternative | + +### Go Dependencies (Direkt) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| gin-gonic/gin | 1.9.1 | MIT | Ja | HTTP Web Framework | +| opensearch-project/opensearch-go/v2 | 2.3.0 | Apache-2.0 | Ja | OpenSearch Client | +| google/uuid | 1.5.0 | BSD-3-Clause | Ja | UUID Generierung | +| gopkg.in/yaml.v3 | 3.0.1 | MIT | Ja | YAML Parser | +| PuerkitoBio/goquery | 1.8.1 | BSD-3-Clause | Ja | HTML Parsing/Scraping | +| ledongthuc/pdf | 0.0.0 | MIT | Ja | PDF Text Extraction | +| golang.org/x/net | 0.19.0 | BSD-3-Clause | Ja | Netzwerk-Utilities | +| golang.org/x/text | 0.14.0 | BSD-3-Clause | Ja | Text/Encoding | + +### Infrastructure Dependencies + +| Komponente | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|------------|---------|--------|----------------------|--------------| +| OpenSearch | 2.x | Apache-2.0 | Ja | Such-Engine & Index | + +> **Hinweis:** OpenSearch ist ein Fork von Elasticsearch unter Apache-2.0 Lizenz und damit uneingeschränkt kommerziell nutzbar. + +--- + +## 10. Legal Crawler (Backend Module) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Legal Crawler | +| **Status** | Aktiv | +| **Sprache** | Python 3.10+ | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | Crawlt Schulgesetze und rechtliche Inhalte aller 16 Bundesländer | + +### Python Dependencies (Legal Crawler spezifisch) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| beautifulsoup4 | 4.12.3 | MIT | Ja | HTML Parsing | +| httpx | 0.28.1 | BSD-3-Clause | Ja | Async HTTP Client | + +### Gecrawlte Quellen (Legal Seeds) + +| Bundesland | Quelle | Typ | +|------------|--------|-----| +| Baden-Württemberg | landesrecht-bw.de | Schulgesetz | +| Bayern | gesetze-bayern.de | BayEUG | +| Berlin | gesetze.berlin.de | Schulgesetz | +| Brandenburg | bravors.brandenburg.de | BbgSchulG | +| Bremen | transparenz.bremen.de | BremSchulG | +| Hamburg | landesrecht-hamburg.de | HmbSG | +| Hessen | rv.hessenrecht.hessen.de | HSchG | +| Mecklenburg-Vorpommern | landesrecht-mv.de | SchulG M-V | +| Niedersachsen | voris.niedersachsen.de | NSchG | +| Nordrhein-Westfalen | bass.schule.nrw | SchulG NRW | +| Rheinland-Pfalz | landesrecht.rlp.de | SchulG | +| Saarland | sl.juris.de | SchoG | +| Sachsen | revosax.sachsen.de | SächsSchulG | +| Sachsen-Anhalt | landesrecht.sachsen-anhalt.de | SchulG LSA | +| Schleswig-Holstein | gesetze-rechtsprechung.sh.juris.de | SchulG SH | +| Thüringen | landesrecht.thueringen.de | ThürSchulG | + +> **Hinweis:** Alle gecrawlten Inhalte sind öffentlich zugängliche Rechtstexte. Der Crawler respektiert robots.txt und Rate-Limiting. + +--- + +## 11. ERPNext (Enterprise Resource Planning) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | ERPNext | +| **Version** | latest (v15+) | +| **Framework** | Frappe Framework | +| **Repository** | https://github.com/frappe/erpnext | +| **Docker Image** | frappe/erpnext:latest | +| **Lizenz** | GNU General Public License v3.0 (GPLv3) | +| **Copyright** | Copyright (c) 2013-2025 Frappe Technologies Pvt. Ltd. | +| **Kommerzielle Nutzung** | Erlaubt (mit Bedingungen*) | +| **Attribution erforderlich** | Ja | +| **Verwendungszweck** | Vollständiges ERP-System für Buchhaltung, HR, Billing & Projekte | + +> *GPLv3: Kommerzielle Nutzung erlaubt. Wenn Sie ERPNext modifizieren und verteilen, müssen die Änderungen unter GPLv3 veröffentlicht werden. Bei reiner Nutzung ohne Modifikation keine zusätzlichen Pflichten. + +### ERPNext Features + +| Feature | Beschreibung | Verwendung in BreakPilot | +|---------|--------------|--------------------------| +| **Accounting** | Double-Entry Buchhaltung | Finanzmanagement für Schulen | +| **Invoicing** | Rechnungsstellung + Abos | Automatische Billing für Services | +| **HR & Payroll** | Personalverwaltung + Lohnabrechnung | Lehrerverträge (zukünftig) | +| **Project Management** | Projekte & Dienstleistungen | Bildungsprojekte verwalten | +| **Banking** | Payables, Receivables | Zahlungsverkehr | +| **Expenses** | Spesen & Reisekosten | Kostenverwaltung | + +### Frappe Framework (ERPNext Basis) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | Frappe Framework | +| **Sprache** | Python 3.10+ | +| **Repository** | https://github.com/frappe/frappe | +| **Lizenz** | MIT License | +| **Kommerzielle Nutzung** | Ja | + +### ERPNext Service Stack + +| Service | Image/Version | Lizenz | Beschreibung | +|---------|--------------|--------|--------------| +| ERPNext Application | frappe/erpnext:latest | GPLv3 | Hauptanwendung | +| Frappe Framework | frappe/erpnext:latest | MIT | Python Framework | +| Nginx | (embedded) | BSD-2-Clause | Web Server | +| Node.js | (embedded) | MIT | WebSocket Server | +| MariaDB | 10.6 | GPL-2.0 | Datenbank | +| Redis | alpine | BSD-3-Clause | Cache & Queue | + +### Python Dependencies (ERPNext/Frappe) + +| Paket | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|--------|----------------------|--------------| +| Werkzeug | BSD-3-Clause | Ja | WSGI Utilities | +| Jinja2 | BSD-3-Clause | Ja | Template Engine | +| SQLAlchemy | MIT | Ja | ORM | +| Babel | BSD-3-Clause | Ja | Internationalisierung | +| Pillow | HPND | Ja | Image Processing | +| PyMySQL | MIT | Ja | MySQL Client | +| gevent | MIT | Ja | Async Networking | +| gunicorn | MIT | Ja | WSGI Server | + +### JavaScript Dependencies (ERPNext Frontend) + +| Paket | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|--------|----------------------|--------------| +| Vue.js | MIT | Ja | Frontend Framework | +| Chart.js | MIT | Ja | Visualisierung | +| Socket.IO | MIT | Ja | Real-time Communication | +| Frappe UI | MIT | Ja | UI Components | + +### ERPNext Deployment + +| Komponente | Container | Port | Beschreibung | +|------------|-----------|------|--------------| +| Frontend | breakpilot-pwa-erpnext-frontend | 8090 | Nginx Reverse Proxy | +| Backend | breakpilot-pwa-erpnext-backend | 8000 (intern) | Python/Frappe App | +| WebSocket | breakpilot-pwa-erpnext-websocket | 9000 (intern) | Real-time Updates | +| Scheduler | breakpilot-pwa-erpnext-scheduler | - | Background Jobs | +| Worker (Long) | breakpilot-pwa-erpnext-worker-long | - | Long-running Tasks | +| Worker (Short) | breakpilot-pwa-erpnext-worker-short | - | Short Tasks | +| MariaDB | breakpilot-pwa-erpnext-db | 3306 (intern) | Database | +| Redis Queue | breakpilot-pwa-erpnext-redis-queue | 6379 (intern) | Job Queue | +| Redis Cache | breakpilot-pwa-erpnext-redis-cache | 6379 (intern) | App Cache | + +> **Lizenz-Compliance:** ERPNext steht unter GPLv3. BreakPilot nutzt ERPNext als eigenständigen Service ohne Modifikationen. Die GPLv3-Lizenz erlaubt dies ohne zusätzliche Verpflichtungen, solange ERPNext nicht modifiziert und redistribuiert wird. + +--- + +## 12. Keycloak Integration (Authentifizierung) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Keycloak (Optional) | +| **Version** | 24.x+ | +| **Repository** | https://github.com/keycloak/keycloak | +| **Lizenz** | Apache-2.0 | +| **Copyright** | Copyright (c) Red Hat, Inc. | +| **Kommerzielle Nutzung** | Erlaubt | +| **Verwendungszweck** | Identity & Access Management (IAM) fuer Produktion | +| **Status** | Optional (Fallback: Lokales JWT) | + +### Architektur: Hybrid-Authentifizierung + +BreakPilot verwendet einen Hybrid-Ansatz: + +| Modus | Beschreibung | Empfohlen fuer | +|-------|--------------|----------------| +| **Lokales JWT** | Eigene JWT-Tokens mit HS256 | Entwicklung, Tests | +| **Keycloak** | JWKS-validierte RS256-Tokens | Produktion | + +### Keycloak Komponenten (wenn aktiviert) + +| Komponente | Beschreibung | Lizenz | +|------------|--------------|--------| +| Keycloak Server | IAM Server | Apache-2.0 | +| JWKS Endpoint | Public Key Distribution | Apache-2.0 | +| Realm Configuration | Multi-Tenant Support | Apache-2.0 | + +### Python Dependencies (Keycloak) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| PyJWT | 2.10.1 | MIT | Ja | JWT Decoding & Validation | +| httpx | 0.28.1 | BSD-3-Clause | Ja | Async HTTP Client fuer JWKS | +| cryptography | latest | Apache-2.0/BSD | Ja | RSA Key Verification | + +### Rollentrennung + +| Schicht | Verantwortung | Implementierung | +|---------|---------------|-----------------| +| **Authentifizierung** | "Wer bist du?" | Keycloak (Produktion) oder lokales JWT | +| **Autorisierung** | "Was darfst du?" | Eigenes rbac.py (domaenenspezifisch) | + +> **Compliance Note:** Keycloak steht unter Apache-2.0 Lizenz und ist vollstaendig fuer kommerzielle Nutzung freigegeben. Die Integration ist optional - ohne Keycloak-Konfiguration verwendet BreakPilot automatisch lokale JWT-Authentifizierung. + +--- + +## 13. Tool-Integrationen (geplant) + +| Tool | Anbieter | Lizenz/API | Kommerzielle Nutzung | Beschreibung | +|------|----------|------------|----------------------|--------------| +| Tavily Search | Tavily AI | Proprietär (API) | Ja (Pay-per-use) | Web Search für LLM Tool-Calling | + +> **Datenschutz-Hinweis:** Alle Anfragen an externe Tools (Tavily) durchlaufen einen PII-Redaction-Filter. Keine personenbezogenen Daten werden an externe Dienste übermittelt. + +--- + +## 13. Content Service (Educational Content Platform) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Content Service | +| **Version** | 1.0.0 | +| **Status** | Produktionsbereit | +| **Sprache** | Python 3.11 + Node.js 20 | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | Educational Content Management mit Creative Commons Licensing | + +### Content Service Stack + +| Service | Port | Framework | Beschreibung | +|---------|------|-----------|--------------| +| Content Service API | 8002 | FastAPI | Content CRUD, Rating, Analytics | +| H5P Service | 8003 | Express | Interactive Content Editor & Player | +| AI Content Generator | 8004 | FastAPI | KI-gestützte H5P Content-Generierung | +| MinIO Storage | 9000-9001 | MinIO | S3-compatible Object Storage | +| Content Database | 5433 | PostgreSQL 16 | Metadata Storage | + +### 13.1 Content Service API (FastAPI) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | FastAPI | +| **Base Image** | python:3.11-slim | +| **Lizenz** | MIT License | +| **Port** | 8002 | + +#### Python Dependencies (Content Service) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| fastapi | ^0.109.0 | MIT | Ja | Web Framework | +| uvicorn[standard] | ^0.27.0 | BSD-3-Clause | Ja | ASGI Server | +| sqlalchemy | ^2.0.25 | MIT | Ja | Database ORM | +| psycopg2-binary | ^2.9.9 | LGPL-3.0 | Ja | PostgreSQL Driver | +| pydantic | ^2.5.3 | MIT | Ja | Data Validation | +| minio | ^7.2.3 | Apache-2.0 | Ja | S3 Storage Client | +| python-multipart | ^0.0.6 | Apache-2.0 | Ja | Form Data Parsing | +| python-jose[cryptography] | ^3.3.0 | MIT | Ja | JWT Handling | +| passlib[bcrypt] | ^1.7.4 | BSD | Ja | Password Hashing | +| matrix-nio | ^0.24.0 | ISC | Ja | Matrix Client for Feed Publishing | + +### 13.2 H5P Service (Node.js) - Simplified Implementation + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | Express (Simplified) | +| **Base Image** | node:20-alpine | +| **Lizenz** | MIT (Proprietäre Editoren) | +| **Port** | 8003 (8080 internal) | +| **Status** | Produktionsbereit (8 Content-Typen) | + +#### Node.js Dependencies (H5P Service) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| express | ^4.18.2 | MIT | Ja | Web Framework | +| cors | ^2.8.5 | MIT | Ja | CORS Middleware | + +> **Implementation Note:** Vereinfachter H5P Service ohne externe GPL-3.0 Libraries. Alle Editoren und Player sind proprietäre HTML/CSS/JS Implementierungen unter MIT-kompatibler Lizenz. + +#### H5P Content Types (Proprietäre Implementation) + +| Content Type | Status | Beschreibung | +|--------------|--------|--------------| +| Quiz (Question Set) | ✅ | Multiple-Choice Tests mit Feedback | +| Interactive Video | ✅ | Videos mit zeitbasierten Interaktionen (YouTube, Vimeo, MP4) | +| Course Presentation | ✅ | Multi-Slide Präsentationen mit Navigation | +| Flashcards | ✅ | Lernkarten zum Wiederholen | +| Timeline | ✅ | Chronologische Zeitstrahle | +| Drag and Drop | ✅ | Zuordnungsaufgaben mit HTML5 Drag API | +| Fill in the Blanks | ✅ | Lückentexte mit automatischer Korrektur | +| Memory Game | ✅ | Klassisches Memory-Spiel | + +#### H5P Service Architecture + +| Komponente | Technologie | Lizenz | +|------------|-------------|--------| +| Server | Express.js | MIT | +| Editors | HTML5/CSS3/Vanilla JS | Proprietär (BreakPilot) | +| Players | HTML5/CSS3/Vanilla JS | Proprietär (BreakPilot) | +| Storage | LocalStorage (Browser) | N/A | +| Video Integration | YouTube/Vimeo iFrame API | Proprietär (API Terms) | + +> **Compliance Note:** Keine GPL-3.0 Dependencies. Vollständig kommerziell nutzbar ohne Copyleft-Verpflichtungen. + +### 13.3 AI Content Generator (Python/FastAPI) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | FastAPI | +| **Base Image** | python:3.11-slim | +| **Lizenz** | MIT License (Dependencies) / Proprietär (Code) | +| **Port** | 8004 | +| **Status** | Produktionsbereit | + +#### Hauptfunktion +Automatische Generierung aller 8 H5P Content-Typen aus hochgeladenen Lernmaterialien mittels Claude AI und YouTube-Integration. + +#### Python Dependencies (AI Content Generator) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| fastapi | ^0.115.6 | MIT | Ja | Web Framework | +| uvicorn | ^0.34.0 | BSD-3-Clause | Ja | ASGI Server | +| anthropic | ^0.42.0 | MIT | Ja | Claude API Client | +| youtube-transcript-api | ^0.6.3 | MIT | Ja | YouTube Transkript-Extraktion | +| PyPDF2 | ^3.0.1 | BSD-3-Clause | Ja | PDF Text-Extraktion | +| Pillow | ^11.0.0 | HPND | Ja | Image Processing | +| pytesseract | ^0.3.14 | Apache-2.0 | Ja | OCR Text Recognition | +| python-docx | ^1.1.2 | MIT | Ja | Word Document Processing | +| mammoth | ^1.8.0 | BSD-2-Clause | Ja | DOCX to Text Conversion | +| python-multipart | ^0.0.20 | Apache-2.0 | Ja | File Upload Handling | + +#### System Dependencies + +| Komponente | Lizenz | Kommerzielle Nutzung | Beschreibung | +|------------|--------|----------------------|--------------| +| Tesseract OCR | Apache-2.0 | Ja | Optical Character Recognition Engine | +| Poppler Utils | GPL-2.0 | Ja (nicht modifiziert) | PDF Rendering Library | + +#### Externe APIs (Cloud Services) + +| Service | Verwendung | Kosten | Lizenz/Terms | +|---------|------------|--------|--------------| +| Anthropic Claude API | Content-Generierung | Pay-per-use | [Anthropic Terms](https://www.anthropic.com/legal/commercial-terms) | +| YouTube Data API (optional) | Video-Suche | Kostenlos (Quota) | [YouTube API Terms](https://developers.google.com/youtube/terms) | +| YouTube Transcript API | Transkript-Abruf | Kostenlos | Public API | + +#### Generierte Content-Typen + +| Content Type | Input | AI-Technologie | +|--------------|-------|----------------| +| Quiz | Lernmaterialien | Claude (Sonnet 4.5) | +| Interactive Video | YouTube URL + Transkript | Claude + Transcript API | +| Course Presentation | Lernmaterialien | Claude (Sonnet 4.5) | +| Flashcards | Lernmaterialien | Claude (Sonnet 4.5) | +| Timeline | Lernmaterialien | Claude (Sonnet 4.5) | +| Drag and Drop | Lernmaterialien | Claude (Sonnet 4.5) | +| Fill in the Blanks | Lernmaterialien | Claude (Sonnet 4.5) | +| Memory Game | Lernmaterialien | Claude (Sonnet 4.5) | + +#### Material-Analyse Capabilities + +| Dateityp | Processing Library | Extraction Method | +|----------|-------------------|-------------------| +| PDF | PyPDF2 | Text Extraction | +| PNG/JPG | Pillow + Tesseract | OCR (Optical Character Recognition) | +| DOCX | python-docx / mammoth | Document Parsing | +| TXT | Python stdlib | Plain Text Reading | + +> **Compliance Note:** Alle Dependencies sind MIT, BSD oder Apache-2.0 lizenziert. Tesseract und Poppler werden als unveränderter Binary verwendet (GPL-Compliance gegeben). Anthropic Claude API erfordert kommerzielle Lizenz. + +> **Cost Note:** Anthropic Claude API ist kostenpflichtig (Pay-per-token). YouTube Data API hat kostenlose Quota-Limits. + +### 13.4 MinIO S3 Storage + +| Eigenschaft | Wert | +|-------------|------| +| **Image** | minio/minio:latest | +| **Ports** | 9000 (API), 9001 (Console) | +| **Lizenz** | AGPL-3.0 | +| **Verwendung** | Media File Storage (Videos, PDFs, Images) | + +> **AGPL-3.0**: Kommerzielle Nutzung erlaubt. Wenn MinIO modifiziert und als Service angeboten wird, müssen Änderungen unter AGPL veröffentlicht werden. + +### 13.5 Content Database (PostgreSQL) + +| Eigenschaft | Wert | +|-------------|------| +| **Image** | postgres:16-alpine | +| **Port** | 5433 (external), 5432 (internal) | +| **Lizenz** | PostgreSQL License | +| **Database** | breakpilot_content | + +### Content Platform Features + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **Content CRUD** | ✅ | Create, Read, Update, Delete für Educational Content | +| **Creative Commons Licensing** | ✅ | CC-BY, CC-BY-SA, CC-BY-NC, CC-BY-NC-SA, CC0 | +| **H5P Interactive Content** | ✅ | Self-hosted H5P Editor & Player | +| **Matrix Feed Integration** | ✅ | Automatisches Publishing zu Matrix Spaces | +| **Rating System** | ✅ | 5-Star Ratings mit Kommentaren | +| **Download Tracking** | ✅ | Analytics & Impact Scoring | +| **DSGVO Compliance** | ✅ | Data Minimization, IP Anonymization | +| **S3 File Storage** | ✅ | MinIO für Videos, PDFs, Bilder | + +### Supported Content Types + +| Content Type | Format | Beschreibung | +|--------------|--------|--------------| +| VIDEO | MP4, WebM | Video Content | +| PDF | PDF | PDF Documents | +| IMAGE_GALLERY | JPG, PNG, WebP | Image Collections | +| MARKDOWN | .md | Markdown Documents | +| AUDIO | MP3, OGG, WebM | Audio Files | +| H5P | .h5p | Interactive H5P Packages | + +### Creative Commons Licenses + +| License | Beschreibung | Commercial Use | +|---------|--------------|----------------| +| CC-BY-4.0 | Attribution | ✅ | +| CC-BY-SA-4.0 | Attribution + ShareAlike | ✅ (Recommended) | +| CC-BY-NC-4.0 | Attribution + NonCommercial | ⚠️ | +| CC-BY-NC-SA-4.0 | Attribution + NonCommercial + ShareAlike | ⚠️ | +| CC0-1.0 | Public Domain | ✅ | + +### Content Categories + +| Category | Beschreibung | +|----------|--------------| +| MOVEMENT | Bewegungspausen & Physical Activities | +| MATH | Mathematik-Übungen | +| STEAM | Science, Technology, Engineering, Arts, Math | +| LANGUAGE | Sprachlernen | +| ARTS | Kreative Künste | +| SOCIAL | Social-Emotional Learning | +| MINDFULNESS | Achtsamkeit & Meditation | + +### Security & Privacy (Content Service) + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **Data Minimization** | ✅ | Nur notwendige Metadaten gespeichert | +| **IP Anonymization** | ✅ | IP-Adressen nach 7 Tagen anonymisiert | +| **User Data Export** | ✅ | DSGVO-konformer Datenexport | +| **Account Deletion** | ✅ | Vollständige Datenlöschung möglich | +| **No Student Data** | ✅ | Keine Schülerdaten erfasst | +| **JWT Authentication** | 🔜 | OAuth2 via consent-service (pending) | +| **HTTPS/TLS** | 🔜 | Production requirement | + +### Docker Volumes + +| Volume | Verwendung | +|--------|------------| +| minio_data | Object Storage für Media Files | +| content_db_data | PostgreSQL Database | +| h5p_content | H5P Content Files | + +> **Lizenz-Compliance:** Die Content Service Plattform nutzt hauptsächlich MIT/Apache-2.0 lizenzierte Komponenten. H5P Libraries sind unter GPL-3.0, was kommerzielle Nutzung erlaubt. + +--- + +## Aktualisierungsprotokoll + +| Datum | Änderung | Verantwortlich | +|-------|----------|----------------| +| 2025-12-14 | Initiale SBOM erstellt | Claude Code | +| 2025-12-14 | LibreChat hinzugefügt | Claude Code | +| 2025-12-15 | Matrix Synapse hinzugefügt (AGPL-3.0) | Claude Code | +| 2025-12-15 | PostgreSQL für Synapse-DB hinzugefügt | Claude Code | +| 2025-12-15 | Jitsi Meet hinzugefügt (Apache-2.0) | Claude Code | +| 2025-12-15 | Go Dependencies aktualisiert (gin-gonic/gin, jackc/pgx) | Claude Code | +| 2025-12-15 | Python Dependencies aktualisiert (mammoth, python-docx für DSR) | Claude Code | +| 2025-12-15 | LLM Platform Komponenten hinzugefügt (vLLM, Ollama, Llama 3.1, Mistral) | Claude Code | +| 2025-12-15 | Tool-Integrationen Sektion hinzugefügt (Tavily) | Claude Code | +| 2025-12-16 | Education Search Service hinzugefügt (OpenSearch, goquery, pdf) | Claude Code | +| 2025-12-17 | Legal Crawler Modul hinzugefügt (beautifulsoup4) | Claude Code | +| 2025-12-17 | Alle 16 Bundesländer Schulgesetz-Quellen dokumentiert | Claude Code | +| 2025-12-29 | ERPNext hinzugefügt (GPLv3, Frappe Framework) | Claude Code | +| 2025-12-29 | MariaDB 10.6 für ERPNext hinzugefügt (GPL-2.0) | Claude Code | +| 2025-12-29 | Redis Cache & Queue für ERPNext hinzugefügt | Claude Code | +| 2025-12-30 | Content Service hinzugefügt (FastAPI, H5P, MinIO) | Claude Code | +| 2025-12-30 | H5P Service vereinfacht: Proprietäre Editoren/Players ohne GPL-3.0 | Claude Code | +| 2025-12-30 | Alle 8 H5P Content-Typen implementiert (Quiz, Video, Presentation, etc.) | Claude Code | +| 2025-12-30 | Creative Commons Licensing System dokumentiert | Claude Code | +| 2025-12-30 | Matrix Feed Integration für Content Publishing | Claude Code | +| 2025-12-30 | AI Content Generator Service hinzugefügt (Claude API, YouTube Transcript) | Claude Code | +| 2025-12-30 | Material-Analyse implementiert (PDF, DOCX, Images mit OCR) | Claude Code | +| 2025-12-30 | Automatische H5P-Generierung für alle 8 Content-Typen | Claude Code | +| 2026-01-09 | Keycloak-Integration (Hybrid-Auth) hinzugefügt | Claude Code | +| 2026-01-09 | PyJWKClient für JWKS-Validierung dokumentiert | Claude Code | +| 2026-01-09 | HashiCorp Vault für Secrets-Management hinzugefügt | Claude Code | +| 2026-01-09 | Sicherheitsaudit: Hardcodierte Secrets entfernt | Claude Code | +| 2026-01-09 | DevSecOps-Stack integriert (Gitleaks, Semgrep, Trivy, Syft/Grype) | Claude Code | +| 2026-01-09 | Pre-Commit Security Hooks aktualisiert (Bandit, Semgrep) | Claude Code | +| 2026-01-10 | Sektion 7a: Typografie & Schriftarten hinzugefügt | Claude Code | +| 2026-01-10 | Inter Font (OFL-1.1) als primäre Schrift dokumentiert | Claude Code | +| 2026-01-10 | E-Mail-sichere Schriften und Icon-Fonts dokumentiert | Claude Code | +| 2026-01-10 | Compliance-Checkliste für Typografie erstellt | Claude Code | +| 2026-02-08 | Sektion 15: Klausur-Service RAG System hinzugefügt | Claude Code | +| 2026-02-08 | Model Cards für BAAI/bge-m3 und bge-reranker-v2-m3 dokumentiert | Claude Code | +| 2026-02-08 | MS MARCO Modelle als nicht empfohlen markiert (Lizenzproblem) | Claude Code | +| 2026-02-08 | PyMuPDF aus Default-Build entfernt (AGPL-3.0) | Claude Code | +| 2026-02-08 | HyDE/Self-RAG Datenschutz-Warnungen dokumentiert | Claude Code | +| 2026-02-08 | RAG Feature-Status-Tabelle hinzugefügt (implementiert vs. aktiv vs. extern) | Claude Code | + +--- + +## 13. HashiCorp Vault (Secrets Management) + +### Vault Server + +| Komponente | Version | Lizenz | Verwendung | +|------------|---------|--------|------------| +| **HashiCorp Vault** | 1.15 | [BSL 1.1](https://github.com/hashicorp/vault/blob/main/LICENSE) | Secrets-Management | +| **hvac** (Python Client) | 2.1.0 | [Apache-2.0](https://github.com/hvac/hvac/blob/main/LICENSE.txt) | Python Vault Client | + +### Lizenz-Hinweis + +HashiCorp Vault ist unter der **Business Source License 1.1 (BSL 1.1)** lizenziert: +- **Kostenlose Nutzung** für alle Zwecke (einschließlich kommerzielle) erlaubt +- **Einzige Einschränkung**: Vault darf nicht als Hosted/Managed Service angeboten werden +- Nach 4 Jahren wechselt die Lizenz automatisch zu Open Source (MPL 2.0) + +Für BreakPilot ist die Nutzung vollständig konform, da Vault nur intern verwendet wird. + +### Konfiguration + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **KV v2 Engine** | ✅ | Key-Value Secrets mit Versionierung | +| **AppRole Auth** | ✅ | Service-to-Service Authentication | +| **Token Auth** | ✅ | Development Mode | +| **Kubernetes Auth** | 🔜 | Für K8s Deployments | +| **TLS/mTLS** | 🔜 | Production Requirement | + +### Gespeicherte Secrets + +| Pfad | Typ | Beschreibung | +|------|-----|--------------| +| `secret/breakpilot/api_keys/anthropic` | API Key | Anthropic Claude API | +| `secret/breakpilot/api_keys/vast` | API Key | vast.ai GPU | +| `secret/breakpilot/api_keys/stripe` | API Key | Stripe Payments | +| `secret/breakpilot/database/postgres` | Credentials | PostgreSQL | +| `secret/breakpilot/auth/jwt` | Secret | JWT Signing Keys | +| `secret/breakpilot/auth/keycloak` | Secret | Keycloak Client | +| `secret/breakpilot/communication/matrix` | Token | Matrix Access Token | +| `secret/breakpilot/communication/jitsi` | Secret | Jitsi Auth Secrets | +| `secret/breakpilot/storage/minio` | Credentials | MinIO Object Storage | + +### Docker Volumes + +| Volume | Verwendung | +|--------|------------| +| vault_data | Vault Storage Backend | +| vault_logs | Audit Logs | + +### Sicherheitsfeatures + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **Audit Logging** | ✅ | Alle Zugriffe werden geloggt | +| **Secret Rotation** | 🔜 | Automatische Key-Rotation | +| **Dynamic Secrets** | 🔜 | Kurzlebige Database Credentials | +| **Seal/Unseal** | ✅ | Verschlüsselung im Ruhezustand | + +> **Wichtig**: In Produktion müssen alle Placeholder-Secrets durch echte Werte ersetzt werden! +> Secrets niemals in Git committen! + +--- + +## 14. DevSecOps Tools (Security Scanning) + +BreakPilot verwendet einen umfassenden DevSecOps-Stack fuer kontinuierliche Security-Pruefungen. + +### Secrets Detection + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Gitleaks** | 8.18.x | [MIT](https://github.com/gitleaks/gitleaks/blob/master/LICENSE) | Pre-commit, CI/CD Secrets Detection | +| **detect-secrets** | 1.4.x | [Apache-2.0](https://github.com/Yelp/detect-secrets/blob/master/LICENSE) | Baseline Secrets Detection | + +### Static Application Security Testing (SAST) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Semgrep** | 1.52.x | [LGPL-2.1](https://github.com/returntocorp/semgrep/blob/develop/LICENSE) | Multi-Language SAST | +| **Bandit** | 1.7.x | [Apache-2.0](https://github.com/PyCQA/bandit/blob/main/LICENSE) | Python Security Linting | + +### Software Composition Analysis (SCA) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Trivy** | 0.48.x | [Apache-2.0](https://github.com/aquasecurity/trivy/blob/main/LICENSE) | Vulnerability & Misconfiguration Scanning | +| **Grype** | 0.74.x | [Apache-2.0](https://github.com/anchore/grype/blob/main/LICENSE) | Vulnerability Scanner | +| **OWASP Dependency-Check** | 9.x | [Apache-2.0](https://github.com/jeremylong/DependencyCheck/blob/main/LICENSE.txt) | CVE/NVD Dependency Check | + +### SBOM Generation + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Syft** | 0.100.x | [Apache-2.0](https://github.com/anchore/syft/blob/main/LICENSE) | SBOM Generation (CycloneDX, SPDX) | + +### Dynamic Application Security Testing (DAST) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **OWASP ZAP** | 2.14.x | [Apache-2.0](https://github.com/zaproxy/zaproxy/blob/main/LICENSE) | Web Application Scanner | + +### Lizenz-Compliance + +Alle DevSecOps-Tools sind unter permissiven Open-Source-Lizenzen veroeffentlicht: + +| Lizenz | Tools | Verpflichtungen | +|--------|-------|-----------------| +| **MIT** | Gitleaks | Copyright-Hinweis beibehalten | +| **Apache-2.0** | Trivy, Grype, Syft, Bandit, ZAP, Dependency-Check, detect-secrets | Lizenz/Copyright beibehalten, Aenderungen dokumentieren | +| **LGPL-2.1** | Semgrep | Bei Aenderungen am Semgrep-Code: Source bereitstellen | + +### Konfigurationsdateien + +| Datei | Beschreibung | +|-------|--------------| +| `.gitleaks.toml` | Gitleaks Regeln & Allowlists | +| `.semgrep.yml` | Custom SAST Rules | +| `.trivy.yaml` | Trivy Scan-Konfiguration | +| `.trivyignore` | Akzeptierte Vulnerabilities | +| `.pre-commit-config.yaml` | Pre-Commit Hook Definitionen | +| `scripts/security-scan.sh` | Manuelles Security-Scan Script | + +### Dokumentation + +Siehe: [docs/architecture/devsecops.md](docs/architecture/devsecops.md) + +--- + +## 15. Klausur-Service RAG System (Educational Document Retrieval) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Klausur-Service RAG | +| **Version** | 2.1.0 | +| **Status** | Produktionsbereit | +| **Sprache** | Python 3.11+ | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | RAG für Abitur-Erwartungshorizonte (Niedersachsen) | + +### 15.1 ML Models (Model Cards) + +> **Wichtig:** Alle Standard-ML-Modelle sind für kommerzielle Nutzung freigegeben. + +#### Embedding Model + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BAAI/bge-m3 | +| **Repository** | https://huggingface.co/BAAI/bge-m3 | +| **Lizenz** | MIT | +| **Dimensionen** | 1024 | +| **Max Token** | 8192 | +| **Sprachen** | Multilingual (inkl. Deutsch) | +| **Kommerzielle Nutzung** | ✅ Ja | +| **Status** | Default (aktiv) | + +#### Re-Ranking Model + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BAAI/bge-reranker-v2-m3 | +| **Repository** | https://huggingface.co/BAAI/bge-reranker-v2-m3 | +| **Lizenz** | Apache-2.0 | +| **Kommerzielle Nutzung** | ✅ Ja | +| **Status** | Default (aktiv) | + +#### Alternatve Embedding Models (Optional) + +| Modell | Lizenz | Dimensionen | Empfehlung | +|--------|--------|-------------|------------| +| all-MiniLM-L6-v2 | Apache-2.0 | 384 | ✅ Fallback/Development | +| deepset/mxbai-embed-de-large-v1 | Apache-2.0 | 1024 | ✅ German-only deployments | +| jinaai/jina-embeddings-v2-base-de | Apache-2.0 | 768 | ✅ German/English | +| intfloat/multilingual-e5-large | MIT | 1024 | ✅ Multilingual | + +#### Nicht empfohlene Modelle (Lizenzprobleme) + +| Modell | Lizenz | Problem | +|--------|--------|---------| +| cross-encoder/ms-marco-* | Apache-2.0 | ⚠️ MS MARCO Trainingsdaten sind nur für nicht-kommerzielle Nutzung freigegeben | +| PyMuPDF/fitz | AGPL-3.0 | ⚠️ AGPL erfordert Open-Source-Veröffentlichung oder kommerzielle Lizenz | + +### 15.2 Python Dependencies (Klausur-Service) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| sentence-transformers | >=2.2.0 | Apache-2.0 | Ja | Embedding & Re-Ranking | +| torch | >=2.0.0 | BSD-3-Clause | Ja | ML Framework (CPU-only) | +| pypdf | >=4.0.0 | BSD-3-Clause | Ja | PDF-Extraktion (Default) | +| unstructured | >=0.12.0 | Apache-2.0 | Ja | PDF-Extraktion (Tabellen) | +| qdrant-client | >=1.7.0 | Apache-2.0 | Ja | Vector Database Client | +| httpx | >=0.26.0 | BSD-3-Clause | Ja | Async HTTP Client | +| FastAPI | >=0.109.0 | MIT | Ja | Web Framework | +| cryptography | >=41.0.0 | Apache-2.0/BSD | Ja | AES-256-GCM Verschlüsselung | + +> **Hinweis:** PyMuPDF ist NICHT im Default-Build enthalten (AGPL-3.0). Falls benötigt, separat installieren und AGPL-Compliance sicherstellen. + +### 15.3 RAG Feature Status + +| Feature | Implementiert | Standardmäßig Aktiv | Sendet Daten extern | +|---------|--------------|---------------------|---------------------| +| **Local Embeddings** | ✅ | ✅ | ❌ Nein | +| **Local Re-Ranking** | ✅ | ✅ | ❌ Nein | +| **Semantic Chunking** | ✅ | ✅ | ❌ Nein | +| **Hybrid Search (BM25)** | ✅ | ✅ | ❌ Nein | +| **HyDE** | ✅ | ❌ | ⚠️ Ja (LLM APIs) | +| **Self-RAG** | ✅ | ❌ | ⚠️ Ja (OpenAI API) | +| **RAG Evaluation** | ✅ | ⚠️ Teilweise | ⚠️ Optional (LLM) | + +### 15.4 Datenschutz-Hinweise (Privacy Notes) + +| Kategorie | Status | Beschreibung | +|-----------|--------|--------------| +| **Embeddings** | ✅ Lokal | Keine Daten werden an externe Server gesendet | +| **Re-Ranking** | ✅ Lokal | Cross-Encoder läuft komplett lokal | +| **PDF-Extraktion** | ✅ Lokal | pypdf/unstructured laufen lokal | +| **Hybrid Search** | ✅ Lokal | BM25 läuft komplett lokal | +| **HyDE** | ⚠️ Opt-in | Bei Aktivierung: Queries an LLM APIs (OpenAI/Anthropic) | +| **Self-RAG** | ⚠️ Opt-in | Bei Aktivierung: Dokumente + Queries an OpenAI | +| **Indexierte Daten** | ✅ | Nur Erwartungshorizonte, keine Schülerdaten | + +### 15.5 Konfiguration (Environment Variables) + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `EMBEDDING_BACKEND` | `local` | Embedding-Backend (local/openai) | +| `LOCAL_EMBEDDING_MODEL` | `BAAI/bge-m3` | Lokales Embedding-Modell | +| `RERANKER_BACKEND` | `local` | Re-Ranking Backend (local/cohere) | +| `LOCAL_RERANKER_MODEL` | `BAAI/bge-reranker-v2-m3` | Lokales Re-Ranking Modell | +| `PDF_EXTRACTION_BACKEND` | `auto` | PDF-Backend (auto/unstructured/pypdf) | +| `HYDE_ENABLED` | `false` | HyDE aktivieren (⚠️ sendet Daten extern) | +| `SELF_RAG_ENABLED` | `false` | Self-RAG aktivieren (⚠️ sendet Daten extern) | +| `HYBRID_SEARCH_ENABLED` | `true` | Hybrid Search aktivieren | +| `CHUNKING_STRATEGY` | `semantic` | Chunking-Strategie (semantic/recursive) | + +### 15.6 API Endpoints + +| Endpoint | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/v1/admin/rag/system-info` | GET | System-Info mit Lizenzen und Feature-Status | +| `/api/v1/admin/nibis/search` | POST | RAG-Suche mit optionalem Re-Ranking | +| `/api/v1/admin/rag/collections` | GET | Liste aller RAG Collections | +| `/api/v1/admin/nibis/ingest` | POST | Dokument-Indexierung starten | +| `/api/v1/admin/rag/metrics` | GET | RAG-Qualitätsmetriken | + +### 15.7 Compliance-Checkliste (Klausur-Service RAG) + +- [x] Alle Standard-ML-Modelle sind MIT/Apache-2.0 lizenziert +- [x] MS MARCO-basierte Modelle wurden durch bge-reranker-v2-m3 ersetzt +- [x] PyMuPDF (AGPL) ist nicht im Default-Build enthalten +- [x] HyDE/Self-RAG sind standardmäßig deaktiviert (Datenschutz) +- [x] Alle Embedding-/Re-Ranking-Operationen laufen lokal +- [x] System-Info Endpoint dokumentiert Lizenzen und Feature-Status +- [x] Keine Schülerdaten werden indexiert + +--- + +## Kontakt + +Bei Fragen zur Lizenz-Compliance: +- E-Mail: legal@breakpilot.app +- Repository: https://github.com/breakpilot/breakpilot-pwa + +--- + +## 16. OCR Grid Detection System (klausur-service) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot OCR Grid Detection | +| **Version** | 4.0 | +| **Status** | Produktiv | +| **Sprache** | Python 3.11+ / TypeScript | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | OCR-Analyse von Vokabeltabellen mit mm-Koordinaten | + +### Python Dependencies (Grid Detection) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| NumPy | ≥2.0.0 | BSD-3-Clause | Ja | Deskew-Berechnung (polyfit/lineare Regression) | +| OpenCV | ≥4.8.0 | Apache-2.0 | Ja | Bildverarbeitung (optional) | + +### Frontend Dependencies (Worksheet Editor Integration) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| Fabric.js | 6.x | MIT | Ja | Canvas-Rendering für Wortpositionierung | + +### Features + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **mm-Koordinatensystem** | ✅ | A4-Format (210x297mm) | +| **Deskew-Korrektur** | ✅ | Automatische Ausrichtung schiefer Scans | +| **1mm Column Margin** | ✅ | Spalten beginnen 1mm vor erstem Wort | +| **Spalten-Erkennung** | ✅ | Englisch/Deutsch/Beispiel automatisch erkannt | +| **Editor-Integration** | ✅ | Export/Import via localStorage | + +### Lizenz-Compliance + +- [x] NumPy ist BSD-3-Clause lizenziert (kommerziell nutzbar) +- [x] OpenCV ist Apache-2.0 lizenziert (kommerziell nutzbar) +- [x] Fabric.js ist MIT lizenziert (kommerziell nutzbar) +- [x] Alle Verarbeitung erfolgt lokal (keine Daten an externe Server) + +| 2026-02-08 | OCR Grid Detection System (v4) hinzugefügt | Claude Code | +| 2026-02-08 | Fabric.js für Worksheet Editor Integration dokumentiert | Claude Code | +| 2026-02-08 | Deskew-Korrektur und mm-Koordinatensystem dokumentiert | Claude Code | diff --git a/admin-v2/SOURCE_POLICY_IMPLEMENTATION_PLAN.md b/admin-v2/SOURCE_POLICY_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6bd6632 --- /dev/null +++ b/admin-v2/SOURCE_POLICY_IMPLEMENTATION_PLAN.md @@ -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) +``` diff --git a/admin-v2/app/(sdk)/sdk/academy/page.tsx b/admin-v2/app/(sdk)/sdk/academy/page.tsx new file mode 100644 index 0000000..acbc0c8 --- /dev/null +++ b/admin-v2/app/(sdk)/sdk/academy/page.tsx @@ -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 ( +
+ +
+ ) +} + +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 ( +
+
+
+
+ {label} +
+
+ {value} +
+ {trend && ( +
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {trend.value >= 0 ? '+' : ''}{trend.value} {trend.label} +
+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ) +} + +function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) { + const categoryInfo = COURSE_CATEGORY_INFO[course.category] + + return ( + +
+
+
+ {/* Header Badges */} +
+ + {categoryInfo.label} + +
+ + {/* Course Title */} +

+ {course.title} +

+

+ {course.description} +

+ + {/* Course Meta */} +
+ + + + + {course.lessons.length} Lektionen + + + + + + {course.durationMinutes} Min. + + + + + + {enrollmentCount} Teilnehmer + +
+
+ + {/* Right Side - Roles */} +
+
+ {course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`} +
+
+ {new Date(course.updatedAt).toLocaleDateString('de-DE')} +
+
+
+ + {/* Footer */} +
+
+ Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')} +
+
+ + Details + +
+
+
+ + ) +} + +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 ( +
+
+
+ {/* Status Badge */} +
+ + {statusInfo.label} + + {overdue && ( + + + + + Ueberfaellig + + )} +
+ + {/* User Info */} +

+ {enrollment.userName} +

+

{enrollment.userEmail}

+

{courseName}

+ + {/* Progress Bar */} +
+
+ Fortschritt + {enrollment.progress}% +
+
+
+
+
+
+ + {/* Right Side - Deadline */} +
+
+ {enrollment.status === 'completed' + ? 'Abgeschlossen' + : overdue + ? `${Math.abs(daysUntil)} Tage ueberfaellig` + : `${daysUntil} Tage verbleibend` + } +
+
+ Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')} +
+
+
+ + {/* Footer */} +
+
+ Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')} +
+ {enrollment.completedAt && ( +
+ Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')} +
+ )} +
+
+ ) +} + +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 ( +
+ Filter: + + {/* Category Filter */} + + + {/* Enrollment Status Filter */} + + + {/* Clear Filters */} + {hasFilters && ( + + )} +
+ ) +} + +// ============================================================================= +// MAIN PAGE +// ============================================================================= + +export default function AcademyPage() { + const { state } = useSDK() + const [activeTab, setActiveTab] = useState('overview') + const [courses, setCourses] = useState([]) + const [enrollments, setEnrollments] = useState([]) + const [statistics, setStatistics] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Filters + const [selectedCategory, setSelectedCategory] = useState('all') + const [selectedStatus, setSelectedStatus] = useState('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 = {} + enrollments.forEach(e => { + counts[e.courseId] = (counts[e.courseId] || 0) + 1 + }) + return counts + }, [enrollments]) + + // Course name lookup + const courseNameById = useMemo(() => { + const map: Record = {} + 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 ( +
+ {/* Step Header */} + + + + + + Kurs erstellen + + + + {/* Tab Navigation */} + + + {/* Loading State */} + {isLoading ? ( +
+ + + + +
+ ) : activeTab === 'settings' ? ( + /* Settings Tab */ +
+
+ + + + +
+

Einstellungen

+

+ Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen + werden in einer spaeteren Version verfuegbar sein. +

+
+ ) : activeTab === 'certificates' ? ( + /* Certificates Tab Placeholder */ +
+
+ + + +
+

Zertifikate

+

+ Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. + Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein. +

+ {tabCounts.certificates > 0 && ( +

+ {tabCounts.certificates} Zertifikat(e) vorhanden +

+ )} +
+ ) : ( + <> + {/* Statistics (Overview Tab) */} + {activeTab === 'overview' && statistics && ( +
+ + + + 0 ? 'red' : 'green'} + /> +
+ )} + + {/* Overdue Alert */} + {tabCounts.overdue > 0 && ( +
+
+ + + +
+
+

+ Achtung: {tabCounts.overdue} ueberfaellige Schulung(en) +

+

+ Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend. +

+
+ +
+ )} + + {/* Info Box (Overview Tab) */} + {activeTab === 'overview' && ( +
+
+ + + +
+

Schulungspflicht nach Art. 39 DSGVO

+

+ 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. +

+
+
+
+ )} + + {/* Filters */} + + + {/* Courses Tab */} + {(activeTab === 'overview' || activeTab === 'courses') && ( +
+ {activeTab === 'courses' && ( +

Kurse ({filteredCourses.length})

+ )} + {filteredCourses.map(course => ( + + ))} +
+ )} + + {/* Enrollments Tab */} + {activeTab === 'enrollments' && ( +
+

Einschreibungen ({filteredEnrollments.length})

+ {filteredEnrollments.map(enrollment => ( + + ))} +
+ )} + + {/* Empty States */} + {activeTab === 'courses' && filteredCourses.length === 0 && ( +
+
+ + + +
+

Keine Kurse gefunden

+

+ {selectedCategory !== 'all' + ? 'Passen Sie die Filter an oder' + : 'Es sind noch keine Kurse vorhanden.' + } +

+ {selectedCategory !== 'all' ? ( + + ) : ( + + + + + Ersten Kurs erstellen + + )} +
+ )} + + {activeTab === 'enrollments' && filteredEnrollments.length === 0 && ( +
+
+ + + +
+

Keine Einschreibungen gefunden

+

+ {selectedStatus !== 'all' + ? 'Passen Sie die Filter an.' + : 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.' + } +

+ {selectedStatus !== 'all' && ( + + )} +
+ )} + + )} +
+ ) +} diff --git a/admin-v2/app/(sdk)/sdk/incidents/page.tsx b/admin-v2/app/(sdk)/sdk/incidents/page.tsx new file mode 100644 index 0000000..ca2354c --- /dev/null +++ b/admin-v2/app/(sdk)/sdk/incidents/page.tsx @@ -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 ( +
+ +
+ ) +} + +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 = { + 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 ( +
+
+
+
+ {label} +
+
+ {value} +
+ {trend && ( +
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {trend.value >= 0 ? '+' : ''}{trend.value} {trend.label} +
+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ) +} + +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 ( +
+ Filter: + + {/* Severity Filter */} + + + {/* Status Filter */} + + + {/* Category Filter */} + + + {/* Clear Filters */} + {hasFilters && ( + + )} +
+ ) +} + +/** + * 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 ( + + + + + Gemeldet + + ) + } + + // Keine Meldepflicht festgestellt + if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) { + return ( + + Keine Meldepflicht + + ) + } + + // Abgelaufen + if (expired) { + const overdueHours = Math.abs(hoursRemaining) + return ( + + + + + {overdueHours.toFixed(0)}h ueberfaellig + + ) + } + + // 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 ( + + + + + {hoursRemaining.toFixed(0)}h verbleibend + + ) +} + +function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) { + return {label} +} + +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 = { + 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 ( + +
+
+
+ {/* Header Badges */} +
+ + {incident.referenceNumber} + + + + +
+ + {/* Title */} +

+ {incident.title} +

+

+ {incident.description} +

+ + {/* 72h Countdown - prominent */} +
+ +
+
+ + {/* Right Side - Key Numbers */} +
+
+ Betroffene +
+
+ {incident.estimatedAffectedPersons.toLocaleString('de-DE')} +
+
+ {new Date(incident.detectedAt).toLocaleDateString('de-DE')} +
+
+
+ + {/* Footer */} +
+
+ + + + + {completedMeasures}/{measuresCount} Massnahmen + + + + + + {incident.timeline.length} Eintraege + +
+
+ + {incident.assignedTo + ? `Zugewiesen: ${incident.assignedTo}` + : 'Nicht zugewiesen' + } + + {incident.status !== 'closed' ? ( + + Bearbeiten + + ) : ( + + Details + + )} +
+
+
+ + ) +} + +// ============================================================================= +// MAIN PAGE +// ============================================================================= + +export default function IncidentsPage() { + const { state } = useSDK() + const [activeTab, setActiveTab] = useState('overview') + const [incidents, setIncidents] = useState([]) + const [statistics, setStatistics] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Filters + const [selectedSeverity, setSelectedSeverity] = useState('all') + const [selectedStatus, setSelectedStatus] = useState('all') + const [selectedCategory, setSelectedCategory] = useState('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 = { 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 ( +
+ {/* Step Header */} + + + + + + Vorfall melden + + + + {/* Tab Navigation */} + + + {/* Loading State */} + {isLoading ? ( +
+ + + + +
+ ) : activeTab === 'settings' ? ( + /* Settings Tab */ +
+
+ + + + +
+

Einstellungen

+

+ Incident-Management-Einstellungen, Eskalationswege und Meldevorlagen + werden in einer spaeteren Version verfuegbar sein. +

+
+ ) : ( + <> + {/* Statistics (Overview Tab) */} + {activeTab === 'overview' && statistics && ( +
+ + + 0 ? 'red' : 'green'} + /> + +
+ )} + + {/* Critical Alert: 72h deadline approaching or expired */} + {(tabCounts.deadlineExpired > 0 || tabCounts.deadlineApproaching > 0) && ( +
+
+ + + +
+
+

+ {tabCounts.deadlineExpired > 0 + ? `Achtung: ${tabCounts.deadlineExpired} ueberfaellige Meldung(en) - 72-Stunden-Frist ueberschritten!` + : `Warnung: ${tabCounts.deadlineApproaching} Meldung(en) mit ablaufender 72-Stunden-Frist` + } +

+

+ {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.' + } +

+
+ +
+ )} + + {/* Info Box (Overview Tab) */} + {activeTab === 'overview' && ( +
+
+ + + +
+

Art. 33/34 DSGVO - 72-Stunden-Meldepflicht

+

+ 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). +

+
+
+
+ )} + + {/* Filters */} + + + {/* Incidents List */} +
+ {filteredIncidents.map(incident => ( + + ))} +
+ + {/* Empty State */} + {filteredIncidents.length === 0 && ( +
+
+ + + +
+

Keine Vorfaelle gefunden

+

+ {selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all' + ? 'Passen Sie die Filter an oder' + : 'Es sind noch keine Vorfaelle erfasst worden.' + } +

+ {(selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all') ? ( + + ) : ( + + + + + Ersten Vorfall erfassen + + )} +
+ )} + + )} +
+ ) +} diff --git a/admin-v2/app/(sdk)/sdk/whistleblower/page.tsx b/admin-v2/app/(sdk)/sdk/whistleblower/page.tsx new file mode 100644 index 0000000..6a6a836 --- /dev/null +++ b/admin-v2/app/(sdk)/sdk/whistleblower/page.tsx @@ -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 ( +
+ +
+ ) +} + +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 ( +
+
+
+
+ {label} +
+
+ {value} +
+ {trend && ( +
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {trend.value >= 0 ? '+' : ''}{trend.value} {trend.label} +
+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ) +} + +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 ( +
+ Filter: + + {/* Category Filter */} + + + {/* Status Filter */} + + + {/* Priority Filter */} + + + {/* Clear Filters */} + {hasFilters && ( + + )} +
+ ) +} + +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 = { + low: 'Niedrig', + normal: 'Normal', + high: 'Hoch', + critical: 'Kritisch' + } + + return ( +
+
+
+ {/* Header Badges */} +
+ + {report.referenceNumber} + + + {categoryInfo.label} + + + {statusInfo.label} + + {report.isAnonymous && ( + + + + + Anonym + + )} + {report.priority === 'critical' && ( + + + + + Kritisch + + )} + {report.priority === 'high' && ( + + Hoch + + )} +
+ + {/* Title */} +

+ {report.title} +

+ + {/* Description Preview */} + {report.description && ( +

+ {report.description} +

+ )} + + {/* Deadline Info */} + {!isClosed && ( +
+ {report.status === 'new' && ( + + + + + {ackOverdue + ? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig` + : `Bestaetigung in ${daysAck} Tagen` + } + + )} + + + + + {fbOverdue + ? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig` + : `Rueckmeldung in ${daysFb} Tagen` + } + +
+ )} +
+ + {/* Right Side - Date & Priority */} +
+
+ {isClosed + ? statusInfo.label + : ackOverdue + ? 'Ueberfaellig' + : priorityLabels[report.priority] + } +
+
+ {new Date(report.receivedAt).toLocaleDateString('de-DE')} +
+
+
+ + {/* Footer */} +
+
+
+ {report.assignedTo + ? `Zugewiesen: ${report.assignedTo}` + : 'Nicht zugewiesen' + } +
+ {report.attachments.length > 0 && ( + + + + + {report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''} + + )} + {totalMeasures > 0 && ( + + + + + {completedMeasures}/{totalMeasures} Massnahmen + + )} + {report.messages.length > 0 && ( + + + + + {report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''} + + )} +
+
+ {!isClosed && ( + + Bearbeiten + + )} + {isClosed && ( + + Details + + )} +
+
+
+ ) +} + +// ============================================================================= +// MAIN PAGE +// ============================================================================= + +export default function WhistleblowerPage() { + const { state } = useSDK() + const [activeTab, setActiveTab] = useState('overview') + const [reports, setReports] = useState([]) + const [statistics, setStatistics] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Filters + const [selectedCategory, setSelectedCategory] = useState('all') + const [selectedStatus, setSelectedStatus] = useState('all') + const [selectedPriority, setSelectedPriority] = useState('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 ( +
+ {/* Step Header - NO "create report" button (reports come from the public form) */} + + + {/* Tab Navigation */} + + + {/* Loading State */} + {isLoading ? ( +
+ + + + +
+ ) : activeTab === 'settings' ? ( + /* Settings Tab */ +
+
+ + + + +
+

Einstellungen

+

+ Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung + und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein. +

+
+ ) : ( + <> + {/* Statistics (Overview Tab) */} + {activeTab === 'overview' && statistics && ( +
+ + + + 0 ? 'red' : 'green'} + /> +
+ )} + + {/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */} + {(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && ( +
+
+ + + +
+
+

+ Achtung: Gesetzliche Fristen ueberschritten +

+

+ {overdueCounts.overdueAck > 0 && ( + {overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1). + )} + {overdueCounts.overdueFb > 0 && ( + {overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2). + )} + Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden. +

+
+ +
+ )} + + {/* Info Box about HinSchG Deadlines (Overview Tab) */} + {activeTab === 'overview' && ( +
+
+ + + +
+

HinSchG-Fristen

+

+ Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen: + Die Eingangsbestaetigung muss innerhalb von 7 Tagen an den + Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2). + Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von 3 Monaten nach + Eingangsbestaetigung erfolgen (ss 17 Abs. 2). + Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36). +

+
+
+
+ )} + + {/* Filters */} + + + {/* Report List */} +
+ {filteredReports.map(report => ( + + ))} +
+ + {/* Empty State */} + {filteredReports.length === 0 && ( +
+
+ + + +
+

Keine Meldungen gefunden

+

+ {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.' + } +

+ {(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && ( + + )} +
+ )} + + )} +
+ ) +} diff --git a/admin-v2/app/api/sdk/v1/academy/[[...path]]/route.ts b/admin-v2/app/api/sdk/v1/academy/[[...path]]/route.ts new file mode 100644 index 0000000..6af67ab --- /dev/null +++ b/admin-v2/app/api/sdk/v1/academy/[[...path]]/route.ts @@ -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') +} diff --git a/admin-v2/app/api/sdk/v1/incidents/[[...path]]/route.ts b/admin-v2/app/api/sdk/v1/incidents/[[...path]]/route.ts new file mode 100644 index 0000000..5c364f5 --- /dev/null +++ b/admin-v2/app/api/sdk/v1/incidents/[[...path]]/route.ts @@ -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') +} diff --git a/admin-v2/app/api/sdk/v1/vendors/[[...path]]/route.ts b/admin-v2/app/api/sdk/v1/vendors/[[...path]]/route.ts new file mode 100644 index 0000000..e25393a --- /dev/null +++ b/admin-v2/app/api/sdk/v1/vendors/[[...path]]/route.ts @@ -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') +} diff --git a/admin-v2/app/api/sdk/v1/whistleblower/[[...path]]/route.ts b/admin-v2/app/api/sdk/v1/whistleblower/[[...path]]/route.ts new file mode 100644 index 0000000..f5f96e2 --- /dev/null +++ b/admin-v2/app/api/sdk/v1/whistleblower/[[...path]]/route.ts @@ -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') +} diff --git a/admin-v2/components/sdk/StepHeader/StepHeader.tsx b/admin-v2/components/sdk/StepHeader/StepHeader.tsx index ef24084..9c13b7d 100644 --- a/admin-v2/components/sdk/StepHeader/StepHeader.tsx +++ b/admin-v2/components/sdk/StepHeader/StepHeader.tsx @@ -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 diff --git a/admin-v2/deploy-and-ingest.sh b/admin-v2/deploy-and-ingest.sh new file mode 100755 index 0000000..0146513 --- /dev/null +++ b/admin-v2/deploy-and-ingest.sh @@ -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 "============================================================" diff --git a/admin-v2/docker-compose.content.yml b/admin-v2/docker-compose.content.yml new file mode 100644 index 0000000..3598569 --- /dev/null +++ b/admin-v2/docker-compose.content.yml @@ -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 diff --git a/admin-v2/docker-compose.dev.yml b/admin-v2/docker-compose.dev.yml new file mode 100644 index 0000000..0bb6678 --- /dev/null +++ b/admin-v2/docker-compose.dev.yml @@ -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 diff --git a/admin-v2/docker-compose.override.yml b/admin-v2/docker-compose.override.yml new file mode 100644 index 0000000..0fb0cb2 --- /dev/null +++ b/admin-v2/docker-compose.override.yml @@ -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 diff --git a/admin-v2/docker-compose.staging.yml b/admin-v2/docker-compose.staging.yml new file mode 100644 index 0000000..efd77f2 --- /dev/null +++ b/admin-v2/docker-compose.staging.yml @@ -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 diff --git a/admin-v2/docker-compose.test.yml b/admin-v2/docker-compose.test.yml new file mode 100644 index 0000000..87ea225 --- /dev/null +++ b/admin-v2/docker-compose.test.yml @@ -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: diff --git a/admin-v2/docker-compose.vault.yml b/admin-v2/docker-compose.vault.yml new file mode 100644 index 0000000..e6da8ce --- /dev/null +++ b/admin-v2/docker-compose.vault.yml @@ -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 diff --git a/admin-v2/docker-compose.yml b/admin-v2/docker-compose.yml new file mode 100644 index 0000000..a6db7bb --- /dev/null +++ b/admin-v2/docker-compose.yml @@ -0,0 +1,1832 @@ +services: + # ============================================ + # Nginx HTTPS Reverse Proxy + # Enables secure context for microphone/crypto + # Access via HTTPS on same ports as HTTP was before + # ============================================ + nginx: + image: nginx:alpine + container_name: breakpilot-pwa-nginx + ports: + - "443:443" # HTTPS Studio v2 (https://macmini/) + - "80:80" # HTTP -> HTTPS redirect + - "3000:3000" # HTTPS Admin Website (https://macmini:3000/) + - "3002:3002" # HTTPS Admin v2 (https://macmini:3002/) + - "8091:8091" # HTTPS Voice Service (wss://macmini:8091/) + - "8000:8000" # HTTPS Backend API + - "8086:8086" # HTTPS Klausur Service + - "8089:8089" # HTTPS Edu-Search proxy (edu-search runs on 8088) + - "8093:8093" # HTTPS AI Compliance SDK + - "8443:8443" # HTTPS Jitsi Meet (https://macmini:8443/) + - "3006:3006" # HTTPS Developer Portal (https://macmini:3006/) + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - vault_certs:/etc/nginx/certs:ro + depends_on: + vault-agent: + condition: service_started + studio-v2: + condition: service_started + voice-service: + condition: service_started + backend: + condition: service_started + klausur-service: + condition: service_started + website: + condition: service_started + ai-compliance-sdk: + condition: service_started + admin-v2: + condition: service_started + jitsi-web: + condition: service_started + developer-portal: + condition: service_started + extra_hosts: + - "breakpilot-edu-search:host-gateway" + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # HashiCorp Vault - Secrets Management + # Web UI: http://localhost:8200/ui + # ============================================ + vault: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault + cap_add: + - IPC_LOCK + ports: + - "8200:8200" + environment: + - 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 + volumes: + - vault_data:/vault/data + healthcheck: + test: ["CMD", "vault", "status"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault PKI Initialization - runs once to set up PKI and issue initial certs + vault-init: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-init + environment: + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + volumes: + - ./vault/init-pki.sh:/init-pki.sh:ro + - vault_agent_config:/vault/agent/data + - vault_certs:/vault/certs + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Waiting for Vault to be ready..." + until vault status > /dev/null 2>&1; do sleep 1; done + echo "Vault is ready. Running PKI initialization..." + chmod +x /init-pki.sh + /init-pki.sh + echo "PKI initialization complete. Exiting." + depends_on: + vault: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: "no" + + # Vault Agent - manages certificate renewal for nginx + vault-agent: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-agent + environment: + - VAULT_ADDR=http://vault:8200 + volumes: + - ./vault/agent/config.hcl:/vault/agent/config.hcl:ro + - ./vault/agent/templates:/vault/agent/templates:ro + - ./vault/agent/split-certs.sh:/vault/agent/split-certs.sh:ro + - vault_agent_config:/vault/agent/data + - vault_certs:/vault/certs + entrypoint: ["vault", "agent", "-config=/vault/agent/config.hcl"] + depends_on: + vault: + condition: service_healthy + vault-init: + condition: service_completed_successfully + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # PostgreSQL Database with PostGIS Extension + # PostGIS is required for geo-service (OSM/Terrain features) + postgres: + image: postgis/postgis:16-3.4-alpine + container_name: breakpilot-pwa-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_db + volumes: + - breakpilot_pwa_data:/var/lib/postgresql/data + - ./geo-service/scripts/init_postgis.sql:/docker-entrypoint-initdb.d/10-init-postgis.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_db"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Valkey - Session Cache (Redis-Fork, BSD-3) + # Redis-compatible, 100% Open Source + # ============================================ + valkey: + image: valkey/valkey:8-alpine + container_name: breakpilot-pwa-valkey + ports: + - "6379:6379" + volumes: + - valkey_data:/data + command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go Consent Service + consent-service: + build: + context: ./consent-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-consent-service + ports: + - "8081:8081" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-your-refresh-secret-key-change-in-production} + - PORT=8081 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # E-Mail Konfiguration (Mailpit für Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000} + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + mailpit: + condition: service_started + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Python Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-backend + expose: + - "8000" + environment: + - CONSENT_SERVICE_URL=http://consent-service:8081 + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - TROCR_SERVICE_URL=${TROCR_SERVICE_URL:-http://host.docker.internal:18084} + - CAMUNDA_URL=http://camunda:8080/engine-rest + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - ENVIRONMENT=${ENVIRONMENT:-development} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - DEBUG=${DEBUG:-false} + # Alerts Agent (Google Alerts Monitoring) + - ALERTS_AGENT_ENABLED=${ALERTS_AGENT_ENABLED:-true} + # HashiCorp Vault - Secrets Management + - VAULT_ADDR=${VAULT_ADDR:-http://vault:8200} + - VAULT_TOKEN=${VAULT_TOKEN:-breakpilot-dev-token} + - VAULT_SECRETS_PATH=${VAULT_SECRETS_PATH:-breakpilot} + - USE_VAULT_SECRETS=${USE_VAULT_SECRETS:-true} + # Keycloak Authentication (optional - wenn nicht gesetzt wird lokales JWT verwendet) + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - KEYCLOAK_VERIFY_SSL=${KEYCLOAK_VERIFY_SSL:-true} + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # vast.ai GPU Infrastructure + - VAST_API_KEY=${VAST_API_KEY:-} + - VAST_INSTANCE_ID=${VAST_INSTANCE_ID:-} + - CONTROL_API_KEY=${CONTROL_API_KEY:-} + - VAST_AUTO_SHUTDOWN=${VAST_AUTO_SHUTDOWN:-true} + - VAST_AUTO_SHUTDOWN_MINUTES=${VAST_AUTO_SHUTDOWN_MINUTES:-30} + # vLLM Backend + - VLLM_BASE_URL=${VLLM_BASE_URL:-} + - VLLM_ENABLED=${VLLM_ENABLED:-false} + # Ollama Backend (lokal auf Mac Mini - DSGVO-konform) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + - OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-180} + # Breakpilot Drive Game API + - GAME_USE_DATABASE=${GAME_USE_DATABASE:-true} + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_REQUIRE_BILLING=${GAME_REQUIRE_BILLING:-false} + - GAME_LLM_MODEL=${GAME_LLM_MODEL:-} + # Compliance LLM Provider Configuration + # Options: "anthropic" (cloud) or "self_hosted" (Ollama/local) + - COMPLIANCE_LLM_PROVIDER=${COMPLIANCE_LLM_PROVIDER:-self_hosted} + - SELF_HOSTED_LLM_URL=${SELF_HOSTED_LLM_URL:-http://host.docker.internal:11434} + - SELF_HOSTED_LLM_MODEL=${SELF_HOSTED_LLM_MODEL:-llama3.1:70b} + - COMPLIANCE_LLM_MAX_TOKENS=${COMPLIANCE_LLM_MAX_TOKENS:-4096} + - COMPLIANCE_LLM_TEMPERATURE=${COMPLIANCE_LLM_TEMPERATURE:-0.3} + - COMPLIANCE_LLM_TIMEOUT=${COMPLIANCE_LLM_TIMEOUT:-120} + # E-Mail Konfiguration (Mailpit fuer Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + extra_hosts: + - "host.docker.internal:host-gateway" + - "mac-mini:192.168.178.163" + volumes: + # Mount Docker socket for container monitoring (Mac Mini Control) + - /var/run/docker.sock:/var/run/docker.sock:ro + # Mount SBOM files for Security Dashboard + - ./docs/sbom:/app/docs/sbom:ro + # Mount Projekt-Verzeichnis fuer Test-Dashboard (echte Test-Discovery) + - /Users/benjaminadmin/Projekte/breakpilot-pwa:/app/project:ro + depends_on: + - consent-service + - valkey + - mailpit + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Automatic Database Backup (runs daily at 2 AM) + backup: + image: postgres:16-alpine + container_name: breakpilot-pwa-backup + volumes: + - ./backups:/backups + - postgres_data:/var/lib/postgresql/data:ro + environment: + - PGHOST=postgres + - PGUSER=breakpilot + - PGPASSWORD=breakpilot123 + - PGDATABASE=breakpilot_db + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Backup service started. Running backup every day at 2 AM..." + while true; do + # Berechne Sekunden bis 2 Uhr + current_hour=$$(date +%H) + current_min=$$(date +%M) + current_sec=$$(date +%S) + + if [ $$current_hour -lt 2 ]; then + sleep_hours=$$((2 - current_hour - 1)) + else + sleep_hours=$$((26 - current_hour - 1)) + fi + sleep_mins=$$((60 - current_min - 1)) + sleep_secs=$$((60 - current_sec)) + total_sleep=$$((sleep_hours * 3600 + sleep_mins * 60 + sleep_secs)) + + echo "Next backup in $$total_sleep seconds (at 2:00 AM)" + sleep $$total_sleep + + # Backup erstellen + TIMESTAMP=$$(date +"%Y%m%d_%H%M%S") + BACKUP_FILE="/backups/breakpilot_$${TIMESTAMP}.sql.gz" + + echo "Creating backup: $$BACKUP_FILE" + pg_dump | gzip > "$$BACKUP_FILE" + + if [ $$? -eq 0 ]; then + echo "✓ Backup completed: $$BACKUP_FILE" + # Alte Backups löschen (älter als 30 Tage) + find /backups -name "breakpilot_*.sql.gz" -mtime +30 -delete + echo "✓ Old backups cleaned up" + else + echo "✗ Backup failed!" + fi + done + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + profiles: + - backup + + # Mailpit - Lokaler E-Mail-Server für Entwicklung + # Web UI: http://localhost:8025 + # SMTP: localhost:1025 + mailpit: + image: axllent/mailpit:latest + container_name: breakpilot-pwa-mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP Server + environment: + - MP_SMTP_AUTH_ACCEPT_ANY=true + - MP_SMTP_AUTH_ALLOW_INSECURE=true + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Matrix Synapse - Schulkommunikation (E2EE Messenger) + # Admin: http://localhost:8008/_synapse/admin + synapse: + image: matrixdotorg/synapse:latest + container_name: breakpilot-pwa-synapse + ports: + - "8008:8008" # Client-Server API + - "8448:8448" # Federation (optional für später) + volumes: + - synapse_data:/data + environment: + - SYNAPSE_SERVER_NAME=${MATRIX_SERVER_NAME:-breakpilot.local} + - SYNAPSE_REPORT_STATS=no + - SYNAPSE_NO_TLS=1 + - SYNAPSE_ENABLE_REGISTRATION=no + - SYNAPSE_LOG_LEVEL=INFO + - UID=1000 + - GID=1000 + healthcheck: + test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # PostgreSQL für Matrix Synapse (separate DB) + synapse-db: + image: postgres:16-alpine + container_name: breakpilot-pwa-synapse-db + environment: + POSTGRES_USER: synapse + POSTGRES_PASSWORD: ${SYNAPSE_DB_PASSWORD:-synapse_secret_123} + POSTGRES_DB: synapse + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - synapse_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go School Service (Klausuren, Noten, Zeugnisse) + school-service: + build: + context: ./school-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-school-service + ports: + - "8084:8084" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - PORT=8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + - LLM_GATEWAY_URL=http://backend:8000/llm + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Embedding Service (ML-heavy operations) + # Handles: embeddings, re-ranking, PDF extraction + # Separated for faster klausur-service builds + embedding-service: + build: + context: ./klausur-service/embedding-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-embedding-service + ports: + - "8087:8087" + environment: + - EMBEDDING_BACKEND=${EMBEDDING_BACKEND:-local} + - LOCAL_EMBEDDING_MODEL=${LOCAL_EMBEDDING_MODEL:-BAAI/bge-m3} + - LOCAL_RERANKER_MODEL=${LOCAL_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + - PDF_EXTRACTION_BACKEND=${PDF_EXTRACTION_BACKEND:-auto} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - COHERE_API_KEY=${COHERE_API_KEY:-} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + volumes: + - embedding_models:/root/.cache/huggingface + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8087/health').raise_for_status()"] + interval: 30s + timeout: 10s + start_period: 120s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Klausur-Service (Abitur/Vorabitur Klausurkorrektur) + # React + FastAPI Microservice + # Web UI: http://localhost:8086 + klausur-service: + build: + context: ./klausur-service + dockerfile: Dockerfile + platform: linux/arm64 # Native ARM64 - PaddlePaddle 3.3.0 unterstützt ARM64 + container_name: breakpilot-pwa-klausur-service + expose: + - "8086" + environment: + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - BACKEND_URL=http://backend:8000 + - SCHOOL_SERVICE_URL=http://school-service:8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + # PostgreSQL for OCR Labeling & Metrics + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # Embedding Service (ML operations) + - EMBEDDING_SERVICE_URL=http://embedding-service:8087 + # BYOEH Configuration + - QDRANT_URL=http://qdrant:6333 + - BYOEH_ENCRYPTION_ENABLED=true + - BYOEH_CHUNK_SIZE=1000 + - BYOEH_CHUNK_OVERLAP=200 + # MinIO Configuration (RAG Document Storage) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-rag + - MINIO_SECURE=false + # Ollama LLM Configuration + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + # PaddleOCR Service (x86_64 via Rosetta) + - PADDLEOCR_SERVICE_URL=http://paddleocr-service:8095 + # HashiCorp Vault - Anthropic API Key for Loesung E + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + volumes: + - klausur_uploads:/app/uploads + - eh_uploads:/app/eh-uploads + - ocr_labeling:/app/ocr-labeling + - paddle_models:/root/.paddlex # Persist PaddleOCR models across restarts + - ./docs:/app/docs # NIBIS Abitur-Dateien + depends_on: + - backend + - school-service + - embedding-service + - postgres + - qdrant + - minio + - paddleocr-service + - vault + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8086/health"] + interval: 30s + timeout: 30s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ============================================ + # PaddleOCR Service - x86_64 via Rosetta + # Runs in emulation to avoid ARM64 crashes + # ============================================ + paddleocr-service: + build: + context: ./paddleocr-service + dockerfile: Dockerfile + platform: linux/amd64 # Force x86_64 emulation via Rosetta + container_name: breakpilot-pwa-paddleocr + expose: + - "8095" + environment: + - PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True + volumes: + - paddleocr_models:/root/.paddlex # Cache PaddleX models + - paddleocr_models:/root/.paddleocr # Cache PaddleOCR 3.x models + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8095/health"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 180s # Models need time to load in emulation + restart: unless-stopped + + # Qdrant Vector Database (BYOEH - Erwartungshorizont RAG) + # REST API: http://localhost:6333 + # gRPC: localhost:6334 + qdrant: + image: qdrant/qdrant:v1.12.1 + container_name: breakpilot-pwa-qdrant + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + environment: + - QDRANT__SERVICE__GRPC_PORT=6334 + healthcheck: + test: ["CMD-SHELL", "bash -c ' /dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + + # DSMS Gateway - REST API für DSMS + dsms-gateway: + build: + context: ./dsms-gateway + dockerfile: Dockerfile + container_name: breakpilot-pwa-dsms-gateway + ports: + - "8082:8082" + environment: + - IPFS_API_URL=http://dsms-node:5001 + - IPFS_GATEWAY_URL=http://dsms-node:8080 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + depends_on: + dsms-node: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Jitsi Meet - Videokonferenzen für Schulungen + # Web UI: http://localhost:8443 + # ============================================ + + # Jitsi Web Frontend + jitsi-web: + image: jitsi/web:stable-9823 + container_name: breakpilot-pwa-jitsi-web + expose: + - "80" + environment: + - ENABLE_XMPP_WEBSOCKET=1 + - ENABLE_COLIBRI_WEBSOCKET=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_BOSH_URL_BASE=http://jitsi-xmpp:5280 + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - JICOFO_AUTH_USER=focus + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + - ENABLE_RECORDING=${JITSI_ENABLE_RECORDING:-1} + - ENABLE_LIVESTREAMING=0 + - DISABLE_HTTPS=1 + # Branding + - APP_NAME=BreakPilot Meet + - NATIVE_APP_NAME=BreakPilot Meet + - PROVIDER_NAME=BreakPilot + volumes: + - jitsi_web_config:/config + - jitsi_web_crontabs:/var/spool/cron/crontabs + - jitsi_transcripts:/usr/share/jitsi-meet/transcripts + networks: + breakpilot-pwa-network: + aliases: + - meet.jitsi + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # Prosody XMPP Server + jitsi-xmpp: + image: jitsi/prosody:stable-9823 + container_name: breakpilot-pwa-jitsi-xmpp + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_CROSS_DOMAIN=true + - TZ=Europe/Berlin + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JIGASI_XMPP_USER=jigasi + - JIGASI_XMPP_PASSWORD=${JITSI_JIGASI_XMPP_PASSWORD:-jigasi_secret_123} + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - LOG_LEVEL=info + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + volumes: + - jitsi_prosody_config:/config + - jitsi_prosody_plugins:/prosody-plugins-custom + networks: + breakpilot-pwa-network: + aliases: + - xmpp.meet.jitsi + restart: unless-stopped + + # Jicofo - Jitsi Conference Focus + jitsi-jicofo: + image: jitsi/jicofo:stable-9823 + container_name: breakpilot-pwa-jitsi-jicofo + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - TZ=Europe/Berlin + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - AUTH_TYPE=internal + - ENABLE_AUTO_OWNER=${JITSI_ENABLE_AUTO_OWNER:-1} + volumes: + - jitsi_jicofo_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # JVB - Jitsi Videobridge (WebRTC SFU) + jitsi-jvb: + image: jitsi/jvb:stable-9823 + container_name: breakpilot-pwa-jitsi-jvb + ports: + - "10000:10000/udp" # Video/Audio RTP + - "8080:8080" # Colibri REST API (internal) + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JVB_PORT=10000 + - JVB_STUN_SERVERS=meet-jit-si-turnrelay.jitsi.net:443 + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - COLIBRI_REST_ENABLED=true + - ENABLE_COLIBRI_WEBSOCKET=1 + volumes: + - jitsi_jvb_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # ============================================ + # Jibri - Jitsi Recording Service + # Recordings werden zu MinIO hochgeladen + # ============================================ + jibri: + build: + context: ./docker/jibri + dockerfile: Dockerfile + container_name: breakpilot-pwa-jibri + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - JIBRI_BREWERY_MUC=jibribrewery + - JIBRI_RECORDING_DIR=/recordings + - JIBRI_FINALIZE_SCRIPT=/config/finalize.sh + - TZ=Europe/Berlin + # X11 Display Konfiguration (Xvfb) + - DISPLAY=:0 + - RESOLUTION=1920x1080x24 + # Optional: VNC fuer Debugging (Port 5900) + # - VNC_PASSWORD=debug123 + # MinIO Upload Konfiguration + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD} + - MINIO_BUCKET=breakpilot-recordings + # Backend Webhook (wird nach Upload aufgerufen) + - BACKEND_WEBHOOK_URL=http://backend:8000/api/recordings/webhook + volumes: + - jibri_recordings:/recordings + - /dev/shm:/dev/shm + shm_size: '2gb' + cap_add: + - SYS_ADMIN + - NET_BIND_SERVICE + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + - minio + profiles: + - recording + + # ============================================ + # Transcription Worker - Whisper + pyannote + # Verarbeitet Recordings asynchron + # ============================================ + transcription-worker: + build: + context: ./backend + dockerfile: Dockerfile.worker + container_name: breakpilot-pwa-transcription-worker + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - REDIS_URL=redis://valkey:6379/1 + - WHISPER_MODEL=${WHISPER_MODEL:-large-v3} + - WHISPER_DEVICE=${WHISPER_DEVICE:-cpu} + - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-int8} + # pyannote.audio Token (HuggingFace) + - PYANNOTE_AUTH_TOKEN=${PYANNOTE_AUTH_TOKEN:-} + # MinIO Storage + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-minio:9000} + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-recordings + - MINIO_SECURE=false + - TZ=Europe/Berlin + volumes: + - transcription_models:/root/.cache/huggingface + - transcription_temp:/tmp/transcriptions + deploy: + resources: + limits: + memory: 8G + reservations: + memory: 4G + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + minio: + condition: service_started + profiles: + - recording + + # ============================================ + # ERPNext - Open Source ERP System + # Web UI: http://localhost:8090 + # Default: Administrator / admin + # ============================================ + + # MariaDB for ERPNext + erpnext-db: + image: mariadb:10.6 + container_name: breakpilot-pwa-erpnext-db + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --skip-character-set-client-handshake --skip-innodb-read-only-compressed + environment: + - MYSQL_ROOT_PASSWORD=${ERPNEXT_DB_ROOT_PASSWORD:-changeit123} + - MYSQL_DATABASE=erpnext + - MYSQL_USER=erpnext + - MYSQL_PASSWORD=${ERPNEXT_DB_PASSWORD:-erpnext123} + volumes: + - erpnext_db_data:/var/lib/mysql + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${ERPNEXT_DB_ROOT_PASSWORD:-changeit123}"] + interval: 5s + timeout: 5s + retries: 10 + + # Redis Queue for ERPNext + erpnext-redis-queue: + image: redis:alpine + container_name: breakpilot-pwa-erpnext-redis-queue + volumes: + - erpnext_redis_queue_data:/data + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Redis Cache for ERPNext + erpnext-redis-cache: + image: redis:alpine + container_name: breakpilot-pwa-erpnext-redis-cache + volumes: + - erpnext_redis_cache_data:/data + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ERPNext Site Creator (runs once) + erpnext-create-site: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-create-site + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + if [[ ! -f sites/erpnext.local/site_config.json ]]; then + echo "Creating ERPNext site..."; + bench new-site erpnext.local --db-host=erpnext-db --db-port=3306 --admin-password=admin --db-root-password=${ERPNEXT_DB_ROOT_PASSWORD:-changeit123} --install-app erpnext --set-default; + echo "Site created successfully!"; + else + echo "Site already exists, skipping creation."; + fi; + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + depends_on: + erpnext-db: + condition: service_healthy + erpnext-redis-cache: + condition: service_started + erpnext-redis-queue: + condition: service_started + + # ERPNext Backend + erpnext-backend: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-backend + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench serve --port 8000 + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext WebSocket + erpnext-websocket: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-websocket + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench watch + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Scheduler + erpnext-scheduler: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-scheduler + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench schedule + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Worker (Long) + erpnext-worker-long: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-worker-long + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench worker --queue long,default,short + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Worker (Short) + erpnext-worker-short: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-worker-short + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench worker --queue short,default + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Frontend (Nginx) + erpnext-frontend: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-frontend + command: + - nginx-entrypoint.sh + ports: + - "8090:8080" + environment: + - BACKEND=erpnext-backend:8000 + - SOCKETIO=erpnext-websocket:9000 + - UPSTREAM_REAL_IP_ADDRESS=127.0.0.1 + - UPSTREAM_REAL_IP_HEADER=X-Forwarded-For + - UPSTREAM_REAL_IP_RECURSIVE=off + - FRAPPE_SITE_NAME_HEADER=erpnext.local + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-backend: + condition: service_started + erpnext-websocket: + condition: service_started + + # ============================================ + # Breakpilot Drive - Lernspiel (Unity WebGL) + # Web UI: http://localhost:3001 + # ============================================ + breakpilot-drive: + build: + context: ./breakpilot-drive + dockerfile: Dockerfile + container_name: breakpilot-pwa-drive + ports: + - "3001:80" + environment: + # API Configuration (injected into Unity WebGL) + - API_BASE_URL=${GAME_API_URL:-http://localhost:8000/api/game} + # Feature Flags + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_ENABLE_LEADERBOARDS=${GAME_ENABLE_LEADERBOARDS:-true} + networks: + - breakpilot-pwa-network + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/health.json"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + profiles: + - game + + # ============================================ + # Camunda 7 - BPMN Workflow Engine + # Web UI: http://localhost:8089/camunda + # REST API: http://localhost:8089/engine-rest + # License: Apache 2.0 (kommerziell nutzbar) + # ============================================ + camunda: + image: camunda/camunda-bpm-platform:7.21.0 + container_name: breakpilot-pwa-camunda + ports: + - "8089:8080" + environment: + - DB_DRIVER=org.postgresql.Driver + - DB_URL=jdbc:postgresql://postgres:5432/breakpilot_db + - DB_USERNAME=breakpilot + - DB_PASSWORD=${POSTGRES_PASSWORD:-breakpilot123} + - DB_VALIDATE_ON_BORROW=true + - WAIT_FOR=postgres:5432 + - CAMUNDA_BPM_ADMIN_USER_ID=admin + - CAMUNDA_BPM_ADMIN_USER_PASSWORD=${CAMUNDA_ADMIN_PASSWORD:-admin123} + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/camunda/api/engine || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + profiles: + - bpmn + + # ============================================ + # GeoEdu Service - Self-Hosted OSM + Terrain + # DSGVO-konforme Erdkunde-Lernplattform + # Web UI: http://localhost:8088 + # ============================================ + geo-service: + build: + context: ./geo-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-geo-service + ports: + - "8088:8088" + environment: + - PORT=8088 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key} + # PostgreSQL (PostGIS fuer OSM-Daten) + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # MinIO (AOI Bundles, generierte Assets) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-geo + - MINIO_SECURE=false + # Ollama (Lernstationen generieren) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + # Tile Server Config + - TILE_CACHE_DIR=/app/cache/tiles + - DEM_CACHE_DIR=/app/cache/dem + - MAX_AOI_SIZE_KM2=4 + volumes: + - geo_osm_data:/app/data/osm + - geo_dem_data:/app/data/dem + - geo_tile_cache:/app/cache/tiles + - geo_aoi_bundles:/app/bundles + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8088/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # Voice Service - PersonaPlex + TaskOrchestrator + # Voice-First Interface fuer Breakpilot + # DSGVO-konform: Keine Audio-Persistenz + # Web UI: http://localhost:8091 + # ============================================ + voice-service: + build: + context: ./voice-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-voice-service + # Port 8091 wird von nginx HTTPS bereitgestellt (wss://macmini:8091) + expose: + - "8091" + environment: + - PORT=8091 + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + - VALKEY_URL=redis://valkey:6379/2 + - PERSONAPLEX_ENABLED=${PERSONAPLEX_ENABLED:-false} + - PERSONAPLEX_WS_URL=${PERSONAPLEX_WS_URL:-ws://host.docker.internal:8998} + - ORCHESTRATOR_ENABLED=true + - FALLBACK_LLM_PROVIDER=${FALLBACK_LLM_PROVIDER:-ollama} + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - OLLAMA_VOICE_MODEL=qwen2.5:32b + - BQAS_JUDGE_MODEL=qwen2.5:14b + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - ENCRYPTION_ENABLED=true + - AUDIO_PERSISTENCE=false + - AUDIO_SAMPLE_RATE=24000 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + volumes: + - voice_session_data:/app/data/sessions + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8091/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # MkDocs Documentation + # Web UI: http://localhost:8009 + # Material Theme with German language support + # ============================================ + docs: + build: + context: . + dockerfile: docs-src/Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-docs + ports: + - "8009:80" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"] + interval: 30s + timeout: 10s + retries: 3 + + # ============================================ + # Gitea - Self-hosted Git Server + # Web UI: http://localhost:3003 + # SSH: localhost:2222 + # Gitea Actions enabled for CI/CD + # ============================================ + gitea: + image: gitea/gitea:1.22-rootless + container_name: breakpilot-pwa-gitea + extra_hosts: + - "macmini:192.168.178.100" + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=postgres:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=breakpilot + - GITEA__database__PASSWD=breakpilot123 + - GITEA__server__DOMAIN=macmini + - GITEA__server__SSH_DOMAIN=macmini + - GITEA__server__ROOT_URL=http://macmini:3003/ + - GITEA__server__HTTP_PORT=3003 + - GITEA__server__SSH_PORT=2222 + - GITEA__server__SSH_LISTEN_PORT=2222 + - GITEA__actions__ENABLED=true + - GITEA__actions__DEFAULT_ACTIONS_URL=https://gitea.com + - GITEA__service__DISABLE_REGISTRATION=true + - GITEA__service__REQUIRE_SIGNIN_VIEW=false + - GITEA__repository__DEFAULT_BRANCH=main + - GITEA__log__LEVEL=Info + - GITEA__webhook__ALLOWED_HOST_LIST=macmini,192.168.178.100,woodpecker-server,localhost,external + volumes: + - gitea_data:/var/lib/gitea + - gitea_config:/etc/gitea + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3003:3003" + - "2222:2222" + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3003/api/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + + # ============================================ + # Gitea Actions Runner + # Executes CI/CD workflows defined in .gitea/workflows/ + # Includes Syft, Grype, Trivy for SBOM generation + # ============================================ + gitea-runner: + image: gitea/act_runner:latest + container_name: breakpilot-pwa-gitea-runner + environment: + - CONFIG_FILE=/config/config.yaml + - GITEA_INSTANCE_URL=http://gitea:3003 + - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN:-} + - GITEA_RUNNER_NAME=breakpilot-runner + - GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04,self-hosted:host + volumes: + - gitea_runner_data:/data + - ./gitea/runner-config.yaml:/config/config.yaml:ro + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + gitea: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Woodpecker CI - Server + # Modern CI/CD with native container support + # Web UI: http://localhost:8085 + # ============================================ + woodpecker-server: + image: woodpeckerci/woodpecker-server:v3 + container_name: breakpilot-pwa-woodpecker-server + ports: + - "8090:8000" + extra_hosts: + - "macmini:192.168.178.100" + environment: + - WOODPECKER_OPEN=true + - WOODPECKER_HOST=http://macmini:8090 + - WOODPECKER_ADMIN=pilotadmin,breakpilot_admin + # Gitea OAuth Integration + - WOODPECKER_GITEA=true + - WOODPECKER_GITEA_URL=http://macmini:3003 + - WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT:-} + - WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET:-} + # Agent Secret (fuer Agent-Authentifizierung) + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET:-woodpecker-agent-secret-key} + # Database (SQLite fuer einfache Einrichtung) + - WOODPECKER_DATABASE_DRIVER=sqlite3 + - WOODPECKER_DATABASE_DATASOURCE=/var/lib/woodpecker/woodpecker.sqlite + # Logging + - WOODPECKER_LOG_LEVEL=info + # Trust all repos (allows privileged containers) + - WOODPECKER_PLUGINS_PRIVILEGED=true + - WOODPECKER_PLUGINS_TRUSTED_CLONE=true + volumes: + - woodpecker_data:/var/lib/woodpecker + depends_on: + gitea: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Night Scheduler - Nachtabschaltung + # Stoppt Services nachts, startet sie morgens + # API: http://localhost:8096 + # ============================================ + night-scheduler: + build: ./night-scheduler + container_name: breakpilot-pwa-night-scheduler + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./night-scheduler/config:/config + - ./docker-compose.yml:/app/docker-compose.yml:ro + environment: + - COMPOSE_PROJECT_NAME=breakpilot-pwa + ports: + - "8096:8096" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8096/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # ============================================ + # Woodpecker CI - Agent + # Executes pipeline steps in containers + # ============================================ + woodpecker-agent: + image: woodpeckerci/woodpecker-agent:v3 + container_name: breakpilot-pwa-woodpecker-agent + command: agent + environment: + - WOODPECKER_SERVER=woodpecker-server:9000 + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET:-woodpecker-agent-secret-key} + - WOODPECKER_MAX_WORKFLOWS=4 + - WOODPECKER_LOG_LEVEL=info + # Backend für Container-Ausführung + - WOODPECKER_BACKEND=docker + - DOCKER_HOST=unix:///var/run/docker.sock + # Extra hosts für Pipeline-Container (damit sie macmini erreichen) + - WOODPECKER_BACKEND_DOCKER_EXTRA_HOSTS=macmini:192.168.178.100,gitea:192.168.178.100 + # Nutze das gleiche Netzwerk für Pipeline-Container + - WOODPECKER_BACKEND_DOCKER_NETWORK=breakpilot-dev_breakpilot-pwa-network + # Docker-Socket für Build-Steps (Host-Docker statt DinD) + - WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - woodpecker-server + networks: + - breakpilot-pwa-network + restart: unless-stopped + +volumes: + # Woodpecker CI Data + woodpecker_data: + driver: local + # Vault Data + vault_data: + driver: local + # Vault Agent Config (role-id, secret-id, token) + vault_agent_config: + driver: local + # Vault-managed SSL Certificates + vault_certs: + driver: local + breakpilot_pwa_data: + driver: local + # Embedding Service Model Cache + embedding_models: + driver: local + # Valkey Session Cache + valkey_data: + driver: local + dsms_data: + driver: local + klausur_uploads: + driver: local + eh_uploads: + driver: local + ocr_labeling: + driver: local + # PaddleOCR Model Cache (persist across container restarts) + paddle_models: + driver: local + # PaddleOCR Service Model Cache (x86_64 emulation) + paddleocr_models: + driver: local + qdrant_data: + driver: local + minio_data: + driver: local + synapse_data: + driver: local + synapse_db_data: + driver: local + # Jitsi Volumes + jitsi_web_config: + driver: local + jitsi_web_crontabs: + driver: local + jitsi_transcripts: + driver: local + jitsi_prosody_config: + driver: local + jitsi_prosody_plugins: + driver: local + jitsi_jicofo_config: + driver: local + jitsi_jvb_config: + driver: local + # Jibri Recording Volumes + jibri_recordings: + driver: local + # Transcription Worker Volumes + transcription_models: + driver: local + transcription_temp: + driver: local + # ERPNext Volumes + erpnext_db_data: + driver: local + erpnext_redis_queue_data: + driver: local + erpnext_redis_cache_data: + driver: local + erpnext_sites: + driver: local + erpnext_logs: + driver: local + # GeoEdu Service Volumes + geo_osm_data: + driver: local + geo_dem_data: + driver: local + geo_tile_cache: + driver: local + geo_aoi_bundles: + driver: local + # Voice Service Volumes (transient sessions only) + voice_session_data: + driver: local + # Gitea Volumes + gitea_data: + driver: local + gitea_config: + driver: local + gitea_runner_data: + driver: local + +networks: + breakpilot-pwa-network: + driver: bridge diff --git a/admin-v2/lib/sdk/academy/api.ts b/admin-v2/lib/sdk/academy/api.ts new file mode 100644 index 0000000..852b39b --- /dev/null +++ b/admin-v2/lib/sdk/academy/api.ts @@ -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( + url: string, + options: RequestInit = {}, + timeout: number = API_TIMEOUT +): Promise { + 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 { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/courses` + ) +} + +/** + * Einzelnen Kurs abrufen + */ +export async function fetchCourse(id: string): Promise { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/courses/${id}` + ) +} + +/** + * Neuen Kurs erstellen + */ +export async function createCourse(request: CourseCreateRequest): Promise { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/courses`, + { + method: 'POST', + body: JSON.stringify(request) + } + ) +} + +/** + * Kurs aktualisieren + */ +export async function updateCourse(id: string, update: CourseUpdateRequest): Promise { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`, + { + method: 'PUT', + body: JSON.stringify(update) + } + ) +} + +/** + * Kurs loeschen + */ +export async function deleteCourse(id: string): Promise { + await fetchWithTimeout( + `${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 { + 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(url) +} + +/** + * Benutzer in einen Kurs einschreiben + */ +export async function enrollUser(request: EnrollUserRequest): Promise { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/complete`, + { + method: 'POST' + } + ) +} + +// ============================================================================= +// CERTIFICATES +// ============================================================================= + +/** + * Zertifikat abrufen + */ +export async function fetchCertificate(id: string): Promise { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/certificates/${id}` + ) +} + +/** + * Zertifikat generieren nach erfolgreichem Kursabschluss + */ +export async function generateCertificate(enrollmentId: string): Promise { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/api/v1/academy/lessons/${lessonId}/quiz`, + { + method: 'POST', + body: JSON.stringify(answers) + } + ) +} + +// ============================================================================= +// STATISTICS +// ============================================================================= + +/** + * Academy-Statistiken abrufen + */ +export async function fetchAcademyStatistics(): Promise { + return fetchWithTimeout( + `${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, + } + } +} diff --git a/admin-v2/lib/sdk/academy/index.ts b/admin-v2/lib/sdk/academy/index.ts new file mode 100644 index 0000000..0bc08bc --- /dev/null +++ b/admin-v2/lib/sdk/academy/index.ts @@ -0,0 +1,6 @@ +/** + * Academy Module Exports + */ + +export * from './types' +export * from './api' diff --git a/admin-v2/lib/sdk/academy/types.ts b/admin-v2/lib/sdk/academy/types.ts new file mode 100644 index 0000000..2a09fc4 --- /dev/null +++ b/admin-v2/lib/sdk/academy/types.ts @@ -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 = { + 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 = { + 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 + byStatus: Record +} + +// ============================================================================= +// 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] +} diff --git a/admin-v2/lib/sdk/incidents/api.ts b/admin-v2/lib/sdk/incidents/api.ts new file mode 100644 index 0000000..e089267 --- /dev/null +++ b/admin-v2/lib/sdk/incidents/api.ts @@ -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( + url: string, + options: RequestInit = {}, + timeout: number = API_TIMEOUT +): Promise { + 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 { + 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(url) +} + +/** + * Einzelnen Vorfall per ID abrufen + */ +export async function fetchIncident(id: string): Promise { + return fetchWithTimeout(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`) +} + +/** + * Neuen Vorfall erstellen + */ +export async function createIncident(request: IncidentCreateRequest): Promise { + return fetchWithTimeout(`${INCIDENTS_API_BASE}/api/v1/incidents`, { + method: 'POST', + body: JSON.stringify(request) + }) +} + +/** + * Vorfall aktualisieren + */ +export async function updateIncident(id: string, update: IncidentUpdateRequest): Promise { + return fetchWithTimeout(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`, { + method: 'PUT', + body: JSON.stringify(update) + }) +} + +/** + * Vorfall loeschen (Soft Delete) + */ +export async function deleteIncident(id: string): Promise { + await fetchWithTimeout(`${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 { + return fetchWithTimeout( + `${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 { + 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 +): Promise { + return fetchWithTimeout( + `${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 +): Promise { + return fetchWithTimeout( + `${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 +): Promise { + return fetchWithTimeout( + `${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/measures`, + { + method: 'POST', + body: JSON.stringify(measure) + } + ) +} + +/** + * Massnahme aktualisieren + */ +export async function updateMeasure( + measureId: string, + update: Partial +): Promise { + return fetchWithTimeout( + `${INCIDENTS_API_BASE}/api/v1/measures/${measureId}`, + { + method: 'PUT', + body: JSON.stringify(update) + } + ) +} + +/** + * Massnahme als abgeschlossen markieren + */ +export async function completeMeasure(measureId: string): Promise { + return fetchWithTimeout( + `${INCIDENTS_API_BASE}/api/v1/measures/${measureId}/complete`, + { + method: 'POST' + } + ) +} + +// ============================================================================= +// TIMELINE +// ============================================================================= + +/** + * Zeitleisteneintrag hinzufuegen + */ +export async function addTimelineEntry( + incidentId: string, + entry: Omit +): Promise { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/close`, + { + method: 'POST', + body: JSON.stringify({ lessonsLearned }) + } + ) +} + +// ============================================================================= +// STATISTICS +// ============================================================================= + +/** + * Vorfall-Statistiken abrufen + */ +export async function fetchIncidentStatistics(): Promise { + return fetchWithTimeout( + `${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 = (items: { [key: string]: unknown }[], field: string): Record => { + const result: Record = {} + items.forEach(item => { + const key = String(item[field]) + result[key] = (result[key] || 0) + 1 + }) + return result as Record + } + + const statusCounts = countBy(incidents as unknown as { [key: string]: unknown }[], 'status') + const severityCounts = countBy(incidents as unknown as { [key: string]: unknown }[], 'severity') + const categoryCounts = countBy(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 + } + } +} diff --git a/admin-v2/lib/sdk/incidents/types.ts b/admin-v2/lib/sdk/incidents/types.ts new file mode 100644 index 0000000..e40a26d --- /dev/null +++ b/admin-v2/lib/sdk/incidents/types.ts @@ -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 = { + 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 = { + 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 = { + 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 + 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 + byCategory: Record + byStatus: Record +} + +// ============================================================================= +// 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] +} diff --git a/admin-v2/lib/sdk/types.ts b/admin-v2/lib/sdk/types.ts index 388d44f..50e6baf 100644 --- a/admin-v2/lib/sdk/types.ts +++ b/admin-v2/lib/sdk/types.ts @@ -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, + }, ] // ============================================================================= diff --git a/admin-v2/lib/sdk/whistleblower/api.ts b/admin-v2/lib/sdk/whistleblower/api.ts new file mode 100644 index 0000000..0e07909 --- /dev/null +++ b/admin-v2/lib/sdk/whistleblower/api.ts @@ -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( + url: string, + options: RequestInit = {}, + timeout: number = API_TIMEOUT +): Promise { + 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 { + 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(url) +} + +/** + * Einzelne Meldung abrufen (Admin) + */ +export async function fetchReport(id: string): Promise { + return fetchWithTimeout( + `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}` + ) +} + +/** + * Meldung aktualisieren (Status, Prioritaet, Kategorie, Zuweisung) + */ +export async function updateReport(id: string, update: ReportUpdateRequest): Promise { + return fetchWithTimeout( + `${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 { + await fetchWithTimeout( + `${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 { + 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 { + return fetchWithTimeout( + `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`, + { + method: 'POST' + } + ) +} + +/** + * Untersuchung starten + */ +export async function startInvestigation(id: string): Promise { + return fetchWithTimeout( + `${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 +): Promise { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${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 { + return fetchWithTimeout( + `${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 { + 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 { + await fetchWithTimeout( + `${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`, + { + method: 'DELETE' + } + ) +} + +// ============================================================================= +// STATISTICS +// ============================================================================= + +/** + * Statistiken fuer das Whistleblower-Dashboard abrufen + */ +export async function fetchWhistleblowerStatistics(): Promise { + return fetchWithTimeout( + `${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 = { + new: 0, + acknowledged: 0, + under_review: 0, + investigation: 0, + measures_taken: 0, + closed: 0, + rejected: 0 + } + + const byCategory: Record = { + 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 + } +} diff --git a/admin-v2/lib/sdk/whistleblower/types.ts b/admin-v2/lib/sdk/whistleblower/types.ts new file mode 100644 index 0000000..baf20b2 --- /dev/null +++ b/admin-v2/lib/sdk/whistleblower/types.ts @@ -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 = { + 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 = { + 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 + byStatus: Record +} + +// ============================================================================= +// 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] +} diff --git a/admin-v2/mkdocs.yml b/admin-v2/mkdocs.yml new file mode 100644 index 0000000..48899c6 --- /dev/null +++ b/admin-v2/mkdocs.yml @@ -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 diff --git a/admin-v2/package-lock.json b/admin-v2/package-lock.json index 168e8d6..ea216ec 100644 --- a/admin-v2/package-lock.json +++ b/admin-v2/package-lock.json @@ -1,3653 +1,17 @@ { - "name": "breakpilot-admin-v2", - "version": "1.0.0", + "name": "breakpilot-pwa", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "breakpilot-admin-v2", - "version": "1.0.0", - "dependencies": { - "jspdf": "^4.1.0", - "jszip": "^3.10.1", - "lucide-react": "^0.468.0", - "next": "^15.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "reactflow": "^11.11.4", - "uuid": "^13.0.0" - }, "devDependencies": { - "@playwright/test": "^1.50.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.10.2", - "@types/react": "^18.3.16", - "@types/react-dom": "^18.3.5", - "@types/uuid": "^10.0.0", - "@vitejs/plugin-react": "^5.1.3", - "autoprefixer": "^10.4.20", - "jsdom": "^28.0.0", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.16", - "typescript": "^5.7.2", - "vitest": "^4.0.18" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", - "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", - "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0" - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", - "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", - "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", - "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", - "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", - "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", - "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", - "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", - "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", - "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@playwright/test": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", - "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@reactflow/background": { - "version": "11.3.14", - "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", - "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/controls": { - "version": "11.2.14", - "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", - "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/core": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", - "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", - "license": "MIT", - "dependencies": { - "@types/d3": "^7.4.0", - "@types/d3-drag": "^3.0.1", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/minimap": { - "version": "11.7.14", - "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", - "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-resizer": { - "version": "2.2.14", - "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", - "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.4", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-toolbar": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", - "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "license": "MIT" - }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "license": "MIT", - "dependencies": { - "@types/d3-dsv": "*" - } - }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "license": "MIT" - }, - "node_modules/@types/d3-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", - "license": "MIT" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "license": "MIT" - }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "license": "MIT" - }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-time-format": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", - "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/raf": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", - "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", - "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.2", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/canvg": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", - "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/raf": "^3.4.0", - "core-js": "^3.8.3", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.7", - "rgbcolor": "^1.0.1", - "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.279", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", - "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", - "dev": true, - "license": "ISC" - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-png": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", - "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", - "license": "MIT", - "dependencies": { - "@types/pako": "^2.0.3", - "iobuffer": "^5.3.2", - "pako": "^2.1.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "playwright": "^1.57.0" } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3659,701 +23,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "optional": true, - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/iobuffer": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", - "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", - "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^5.3.7", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.20.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jspdf": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", - "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "fast-png": "^6.2.0", - "fflate": "^0.8.1" - }, - "optionalDependencies": { - "canvg": "^3.0.11", - "core-js": "^3.6.0", - "dompurify": "^3.3.1", - "html2canvas": "^1.0.0-rc.5" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.468.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", - "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", - "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", - "license": "MIT", - "dependencies": { - "@next/env": "15.5.9", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.7", - "@next/swc-darwin-x64": "15.5.7", - "@next/swc-linux-arm64-gnu": "15.5.7", - "@next/swc-linux-arm64-musl": "15.5.7", - "@next/swc-linux-x64-gnu": "15.5.7", - "@next/swc-linux-x64-musl": "15.5.7", - "@next/swc-win32-arm64-msvc": "15.5.7", - "@next/swc-win32-x64-msvc": "15.5.7", - "sharp": "^0.34.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT", - "optional": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/playwright": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", - "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", - "devOptional": true, + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.1" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -4366,10 +43,10 @@ } }, "node_modules/playwright-core": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", - "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", - "devOptional": true, + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -4377,1349 +54,6 @@ "engines": { "node": ">=18" } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "license": "MIT", - "optional": true, - "dependencies": { - "performance-now": "^2.1.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reactflow": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", - "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", - "license": "MIT", - "dependencies": { - "@reactflow/background": "11.3.14", - "@reactflow/controls": "11.2.14", - "@reactflow/core": "11.11.4", - "@reactflow/minimap": "11.7.14", - "@reactflow/node-resizer": "2.2.14", - "@reactflow/node-toolbar": "1.3.14" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rgbcolor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", - "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", - "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", - "optional": true, - "engines": { - "node": ">= 0.8.15" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/stackblur-canvas": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", - "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.14" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svg-pathdata": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", - "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", - "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.22" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", - "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", - "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "optional": true, - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } diff --git a/admin-v2/run-ingestion.sh b/admin-v2/run-ingestion.sh new file mode 100755 index 0000000..c46ec5e --- /dev/null +++ b/admin-v2/run-ingestion.sh @@ -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 "============================================================" diff --git a/agent-core/soul/investor-agent.soul.md b/agent-core/soul/investor-agent.soul.md new file mode 100644 index 0000000..872eb6e --- /dev/null +++ b/agent-core/soul/investor-agent.soul.md @@ -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." diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go index 1b52d5e..960791c 100644 --- a/ai-compliance-sdk/cmd/server/main.go +++ b/ai-compliance-sdk/cmd/server/main.go @@ -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 diff --git a/ai-compliance-sdk/internal/academy/models.go b/ai-compliance-sdk/internal/academy/models.go new file mode 100644 index 0000000..74b2214 --- /dev/null +++ b/ai-compliance-sdk/internal/academy/models.go @@ -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"` +} diff --git a/ai-compliance-sdk/internal/academy/store.go b/ai-compliance-sdk/internal/academy/store.go new file mode 100644 index 0000000..90ecf50 --- /dev/null +++ b/ai-compliance-sdk/internal/academy/store.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/api/handlers/academy_handlers.go b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go new file mode 100644 index 0000000..2e99fae --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go b/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go new file mode 100644 index 0000000..3d7a5e4 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go @@ -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) +} + diff --git a/ai-compliance-sdk/internal/api/handlers/vendor_handlers.go b/ai-compliance-sdk/internal/api/handlers/vendor_handlers.go new file mode 100644 index 0000000..98a3fd7 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/vendor_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go b/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go new file mode 100644 index 0000000..3805686 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/incidents/models.go b/ai-compliance-sdk/internal/incidents/models.go new file mode 100644 index 0000000..2478ba3 --- /dev/null +++ b/ai-compliance-sdk/internal/incidents/models.go @@ -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" +} diff --git a/ai-compliance-sdk/internal/incidents/store.go b/ai-compliance-sdk/internal/incidents/store.go new file mode 100644 index 0000000..f667b67 --- /dev/null +++ b/ai-compliance-sdk/internal/incidents/store.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/whistleblower/models.go b/ai-compliance-sdk/internal/whistleblower/models.go new file mode 100644 index 0000000..769bfd4 --- /dev/null +++ b/ai-compliance-sdk/internal/whistleblower/models.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/whistleblower/store.go b/ai-compliance-sdk/internal/whistleblower/store.go new file mode 100644 index 0000000..7efb6cb --- /dev/null +++ b/ai-compliance-sdk/internal/whistleblower/store.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index a6db7bb..6b9e1d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/docs-site/404.html b/docs-site/404.html new file mode 100644 index 0000000..1e2776c --- /dev/null +++ b/docs-site/404.html @@ -0,0 +1,1899 @@ + + + + + + + + + + + + + + + + + + + + + + Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ +

404 - Not found

+ +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/Dockerfile b/docs-site/Dockerfile new file mode 100644 index 0000000..a8711c6 --- /dev/null +++ b/docs-site/Dockerfile @@ -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;"] diff --git a/docs-site/api/backend-api/index.html b/docs-site/api/backend-api/index.html new file mode 100644 index 0000000..e606e38 --- /dev/null +++ b/docs-site/api/backend-api/index.html @@ -0,0 +1,3133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backend API - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + +

BreakPilot Backend API Dokumentation

+

Übersicht

+

Base URL: http://localhost:8000/api

+

Alle Endpoints erfordern Authentifizierung via JWT im Authorization-Header: +

Authorization: Bearer <token>
+

+
+

Worksheets API

+

Generiert Lernmaterialien (MC-Tests, Lückentexte, Mindmaps, Quiz).

+

POST /worksheets/generate/multiple-choice

+

Generiert Multiple-Choice-Fragen aus Quelltext.

+

Request Body: +

{
+  "source_text": "Der Text, aus dem Fragen generiert werden sollen...",
+  "num_questions": 5,
+  "difficulty": "medium",
+  "topic": "Thema",
+  "subject": "Deutsch"
+}
+

+

Response (200): +

{
+  "success": true,
+  "content": {
+    "type": "multiple_choice",
+    "data": {
+      "questions": [
+        {
+          "question": "Was ist...?",
+          "options": ["A", "B", "C", "D"],
+          "correct": 0,
+          "explanation": "Erklärung..."
+        }
+      ]
+    }
+  }
+}
+

+

POST /worksheets/generate/cloze

+

Generiert Lückentexte.

+

POST /worksheets/generate/mindmap

+

Generiert Mindmap als Mermaid-Diagramm.

+

POST /worksheets/generate/quiz

+

Generiert Mix aus verschiedenen Fragetypen.

+
+

Corrections API

+

OCR-basierte Klausurkorrektur mit automatischer Bewertung.

+

POST /corrections/

+

Erstellt neue Korrektur-Session.

+

POST /corrections/{id}/upload

+

Lädt gescannte Klausur hoch und startet OCR im Hintergrund.

+

GET /corrections/{id}

+

Ruft Korrektur-Status ab.

+

Status-Werte: +- uploaded - Datei hochgeladen +- processing - OCR läuft +- ocr_complete - OCR fertig +- analyzing - Analyse läuft +- analyzed - Analyse abgeschlossen +- completed - Fertig +- error - Fehler

+

POST /corrections/{id}/analyze

+

Analysiert extrahierten Text und bewertet Antworten.

+

GET /corrections/{id}/export-pdf

+

Exportiert korrigierte Arbeit als PDF.

+
+

Letters API

+

Elternbriefe mit GFK-Integration und PDF-Export.

+

POST /letters/

+

Erstellt neuen Elternbrief.

+

letter_type Werte: +- general - Allgemeine Information +- halbjahr - Halbjahresinformation +- fehlzeiten - Fehlzeiten-Mitteilung +- elternabend - Einladung Elternabend +- lob - Positives Feedback +- custom - Benutzerdefiniert

+

POST /letters/improve

+

Verbessert Text nach GFK-Prinzipien.

+
+

State Engine API

+

Begleiter-Modus mit Phasen-Management und Antizipation.

+

GET /state/dashboard

+

Komplettes Dashboard für Begleiter-Modus.

+

GET /state/suggestions

+

Ruft Vorschläge für Lehrer ab.

+

POST /state/milestone

+

Schließt Meilenstein ab.

+
+

Klausur-Korrektur API (Abitur)

+

Abitur-Klausurkorrektur mit 15-Punkte-System, Erst-/Zweitprüfer-Workflow und KI-gestützter Bewertung.

+

Klausur-Modi

+ + + + + + + + + + + + + + + + + +
ModusBeschreibung
landes_abiturNiBiS Niedersachsen - rechtlich geklärte Aufgaben
vorabiturLehrer-erstellte Klausuren mit Rights-Gate
+

POST /klausur-korrektur/klausuren

+

Erstellt neue Abitur-Klausur.

+

POST /klausur-korrektur/students/{id}/evaluate

+

Startet KI-Bewertung.

+

Response (200): +

{
+  "criteria_scores": {
+    "rechtschreibung": {"score": 85, "weight": 0.15},
+    "grammatik": {"score": 90, "weight": 0.15},
+    "inhalt": {"score": 75, "weight": 0.40},
+    "struktur": {"score": 80, "weight": 0.15},
+    "stil": {"score": 85, "weight": 0.15}
+  },
+  "raw_points": 80,
+  "grade_points": 11,
+  "grade_label": "2"
+}
+

+

15-Punkte-Notenschlüssel

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PunkteProzentNote
15≥95%1+
14≥90%1
13≥85%1-
12≥80%2+
11≥75%2
10≥70%2-
9≥65%3+
8≥60%3
7≥55%3-
6≥50%4+
5≥45%4
4≥40%4-
3≥33%5+
2≥27%5
1≥20%5-
0<20%6
+

Bewertungskriterien

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumGewichtBeschreibung
rechtschreibung15%Orthografie
grammatik15%Grammatik & Syntax
inhalt40%Inhaltliche Qualität
struktur15%Aufbau & Gliederung
stil15%Ausdruck & Stil
+
+

Security API (DevSecOps Dashboard)

+

API fuer das Security Dashboard mit DevSecOps-Tools Integration.

+

GET /v1/security/tools

+

Gibt Status aller DevSecOps-Tools zurueck.

+

GET /v1/security/findings

+

Gibt alle Security-Findings zurueck.

+

GET /v1/security/sbom

+

Gibt SBOM (Software Bill of Materials) zurueck.

+

POST /v1/security/scan/{type}

+

Startet einen Security-Scan.

+

Path Parameter: +- type: Scan-Typ (secrets, sast, deps, containers, sbom, all)

+
+

Fehler-Responses

+

400 Bad Request

+
{
+  "detail": "Beschreibung des Fehlers"
+}
+
+

401 Unauthorized

+
{
+  "detail": "Not authenticated"
+}
+
+

404 Not Found

+
{
+  "detail": "Ressource nicht gefunden"
+}
+
+

500 Internal Server Error

+
{
+  "detail": "Interner Serverfehler"
+}
+
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/auth-system/index.html b/docs-site/architecture/auth-system/index.html new file mode 100644 index 0000000..23c3dfb --- /dev/null +++ b/docs-site/architecture/auth-system/index.html @@ -0,0 +1,2896 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Auth-System - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

BreakPilot Authentifizierung & Autorisierung

+

Uebersicht

+

BreakPilot verwendet einen Hybrid-Ansatz fuer Authentifizierung und Autorisierung:

+
┌─────────────────────────────────────────────────────────────────────────┐
+│                        AUTHENTIFIZIERUNG                                 │
+│                     "Wer bist du?"                                       │
+│  ┌────────────────────────────────────────────────────────────────────┐ │
+│  │                    HybridAuthenticator                              │ │
+│  │  ┌─────────────────────┐      ┌─────────────────────────────────┐  │ │
+│  │  │   Keycloak          │      │   Lokales JWT                   │  │ │
+│  │  │   (Produktion)      │  OR  │   (Entwicklung)                 │  │ │
+│  │  │   RS256 + JWKS      │      │   HS256 + Secret                │  │ │
+│  │  └─────────────────────┘      └─────────────────────────────────┘  │ │
+│  └────────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+                                    │
+                                    ▼
+┌─────────────────────────────────────────────────────────────────────────┐
+│                         AUTORISIERUNG                                    │
+│                      "Was darfst du?"                                    │
+│  ┌────────────────────────────────────────────────────────────────────┐ │
+│  │                    rbac.py (Eigenentwicklung)                       │ │
+│  │  ┌─────────────────┐  ┌─────────────────┐  ┌───────────────────┐   │ │
+│  │  │ Rollen-Hierarchie│  │ PolicySet       │  │ DEFAULT_PERMISSIONS│   │ │
+│  │  │ 15+ Rollen       │  │ Bundesland-     │  │ Matrix            │   │ │
+│  │  │ - Erstkorrektor  │  │ spezifisch      │  │ Rolle→Ressource→  │   │ │
+│  │  │ - Klassenlehrer  │  │ - Niedersachsen │  │ Aktion            │   │ │
+│  │  │ - Schulleitung   │  │ - Bayern        │  │                   │   │ │
+│  │  └─────────────────┘  └─────────────────┘  └───────────────────┘   │ │
+│  └────────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+
+

Warum dieser Ansatz?

+

Alternative Loesungen (verworfen)

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ToolProblem fuer BreakPilot
CasbinZu generisch fuer Bundesland-spezifische Policies
CerbosOverhead: Externer PDP-Service fuer ~15 Rollen ueberdimensioniert
OpenFGAZanzibar-Modell optimiert fuer Graph-Beziehungen, nicht Hierarchien
Keycloak RBACKann keine ressourcen-spezifischen Zuweisungen (User X ist Erstkorrektor fuer Package Y)
+

Vorteile des Hybrid-Ansatzes

+
    +
  1. Keycloak fuer Authentifizierung:
  2. +
  3. Bewährtes IAM-System
  4. +
  5. SSO, Federation, MFA
  6. +
  7. +

    Apache-2.0 Lizenz

    +
  8. +
  9. +

    Eigenes rbac.py fuer Autorisierung:

    +
  10. +
  11. Domaenenspezifische Logik (Korrekturkette, Zeugnis-Workflow)
  12. +
  13. Bundesland-spezifische Regeln
  14. +
  15. Zeitlich begrenzte Zuweisungen
  16. +
  17. Key-Sharing fuer verschluesselte Klausuren
  18. +
+
+

Authentifizierung (auth/keycloak_auth.py)

+

Konfiguration

+
# Entwicklung: Lokales JWT (Standard)
+JWT_SECRET=your-secret-key
+
+# Produktion: Keycloak
+KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app
+KEYCLOAK_REALM=breakpilot
+KEYCLOAK_CLIENT_ID=breakpilot-backend
+KEYCLOAK_CLIENT_SECRET=your-client-secret
+
+

Token-Erkennung

+

Der HybridAuthenticator erkennt automatisch den Token-Typ:

+
# Keycloak-Token (RS256)
+{
+  "iss": "https://keycloak.breakpilot.app/realms/breakpilot",
+  "sub": "user-uuid",
+  "realm_access": {"roles": ["teacher", "admin"]},
+  ...
+}
+
+# Lokales JWT (HS256)
+{
+  "iss": "breakpilot",
+  "user_id": "user-uuid",
+  "role": "admin",
+  ...
+}
+
+

FastAPI Integration

+
from auth import get_current_user
+
+@app.get("/api/protected")
+async def protected_endpoint(user: dict = Depends(get_current_user)):
+    # user enthält: user_id, email, role, realm_roles, tenant_id
+    return {"user_id": user["user_id"]}
+
+
+

Autorisierung (klausur-service/backend/rbac.py)

+

Rollen (15+)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RolleBeschreibungBereich
erstkorrektorErster PrüferKlausur
zweitkorrektorZweiter PrüferKlausur
drittkorrektorDritter PrüferKlausur
klassenlehrerKlassenleitungZeugnis
fachlehrerFachlehrkraftNoten
fachvorsitzFachkonferenz-LeitungFachschaft
schulleitungSchulleiter/inSchule
zeugnisbeauftragterZeugnis-KoordinationZeugnis
sekretariatVerwaltungSchule
data_protection_officerDSBDSGVO
...
+

Ressourcentypen (25+)

+
class ResourceType(str, Enum):
+    EXAM_PACKAGE = "exam_package"         # Klausurpaket
+    STUDENT_SUBMISSION = "student_submission"
+    CORRECTION = "correction"
+    ZEUGNIS = "zeugnis"
+    FACHNOTE = "fachnote"
+    KOPFNOTE = "kopfnote"
+    BEMERKUNG = "bemerkung"
+    ...
+
+

Aktionen (17)

+
class Action(str, Enum):
+    CREATE = "create"
+    READ = "read"
+    UPDATE = "update"
+    DELETE = "delete"
+    SIGN_OFF = "sign_off"        # Freigabe
+    BREAK_GLASS = "break_glass"  # Notfall-Zugriff
+    SHARE_KEY = "share_key"      # Schlüssel teilen
+    ...
+
+

Permission-Pruefung

+
from klausur_service.backend.rbac import PolicyEngine
+
+engine = PolicyEngine()
+
+# Pruefe ob User X Klausur Y korrigieren darf
+allowed = engine.check_permission(
+    user_id="user-uuid",
+    action=Action.UPDATE,
+    resource_type=ResourceType.CORRECTION,
+    resource_id="klausur-uuid"
+)
+
+
+

Bundesland-spezifische Policies

+
@dataclass
+class PolicySet:
+    bundesland: str
+    abitur_type: str  # "landesabitur" | "zentralabitur"
+
+    # Korrekturkette
+    korrektoren_anzahl: int  # 2 oder 3
+    anonyme_erstkorrektur: bool
+
+    # Sichtbarkeit
+    zk_visibility_mode: ZKVisibilityMode  # BLIND | SEMI | FULL
+    eh_visibility_mode: EHVisibilityMode
+
+    # Zeugnis
+    kopfnoten_enabled: bool
+    ...
+
+

Beispiel: Niedersachsen

+
NIEDERSACHSEN_POLICY = PolicySet(
+    bundesland="niedersachsen",
+    abitur_type="landesabitur",
+    korrektoren_anzahl=2,
+    anonyme_erstkorrektur=True,
+    zk_visibility_mode=ZKVisibilityMode.BLIND,
+    eh_visibility_mode=EHVisibilityMode.SUMMARY_ONLY,
+    kopfnoten_enabled=True,
+)
+
+
+

Workflow-Beispiele

+

Klausurkorrektur-Workflow

+
1. Lehrer laedt Klausuren hoch
+   └── Rolle: "lehrer" + Action.CREATE auf EXAM_PACKAGE
+
+2. Erstkorrektor korrigiert
+   └── Rolle: "erstkorrektor" (ressourcen-spezifisch) + Action.UPDATE auf CORRECTION
+
+3. Zweitkorrektor ueberprueft
+   └── Rolle: "zweitkorrektor" + Action.READ auf CORRECTION
+   └── Policy: zk_visibility_mode bestimmt Sichtbarkeit
+
+4. Drittkorrektor (bei Abweichung)
+   └── Rolle: "drittkorrektor" + Action.SIGN_OFF
+
+

Zeugnis-Workflow

+
1. Fachlehrer traegt Noten ein
+   └── Rolle: "fachlehrer" + Action.CREATE auf FACHNOTE
+
+2. Klassenlehrer prueft
+   └── Rolle: "klassenlehrer" + Action.READ auf ZEUGNIS
+   └── Action.SIGN_OFF freigeben
+
+3. Zeugnisbeauftragter final
+   └── Rolle: "zeugnisbeauftragter" + Action.SIGN_OFF
+
+4. Schulleitung unterzeichnet
+   └── Rolle: "schulleitung" + Action.SIGN_OFF
+
+
+

Dateien

+ + + + + + + + + + + + + + + + + + + + + + + + + +
DateiBeschreibung
backend/auth/__init__.pyAuth-Modul Exports
backend/auth/keycloak_auth.pyHybrid-Authentifizierung
klausur-service/backend/rbac.pyAutorisierungs-Engine
backend/rbac_api.pyREST API fuer Rollenverwaltung
+
+

Konfiguration

+

Entwicklung (ohne Keycloak)

+
# .env
+ENVIRONMENT=development
+JWT_SECRET=dev-secret-32-chars-minimum-here
+
+

Produktion (mit Keycloak)

+
# .env
+ENVIRONMENT=production
+JWT_SECRET=<openssl rand -hex 32>
+KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app
+KEYCLOAK_REALM=breakpilot
+KEYCLOAK_CLIENT_ID=breakpilot-backend
+KEYCLOAK_CLIENT_SECRET=<from keycloak admin console>
+
+
+

Sicherheitshinweise

+
    +
  1. Secrets niemals im Code - Immer Umgebungsvariablen verwenden
  2. +
  3. JWT_SECRET in Produktion - Mindestens 32 Bytes, generiert mit openssl rand -hex 32
  4. +
  5. Keycloak HTTPS - KEYCLOAK_VERIFY_SSL=true in Produktion
  6. +
  7. Token-Expiration - Keycloak-Tokens kurz halten (5-15 Minuten)
  8. +
  9. Audit-Trail - Alle Berechtigungspruefungen werden geloggt
  10. +
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/devsecops/index.html b/docs-site/architecture/devsecops/index.html new file mode 100644 index 0000000..505b100 --- /dev/null +++ b/docs-site/architecture/devsecops/index.html @@ -0,0 +1,2699 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + DevSecOps - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

BreakPilot DevSecOps Architecture

+

Uebersicht

+

BreakPilot implementiert einen umfassenden DevSecOps-Ansatz mit Security-by-Design fuer die Entwicklung und den Betrieb der Bildungsplattform.

+
┌─────────────────────────────────────────────────────────────────────────────┐
+│                        DEVSECOPS PIPELINE                                    │
+│                                                                              │
+│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐   │
+│  │  Pre-Commit │───►│  CI/CD      │───►│  Build      │───►│  Deploy     │   │
+│  │  Hooks      │    │  Pipeline   │    │  & Scan     │    │  & Monitor  │   │
+│  └─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘   │
+│        │                  │                  │                  │           │
+│        ▼                  ▼                  ▼                  ▼           │
+│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐   │
+│  │ Gitleaks    │    │ Semgrep     │    │ Trivy       │    │ Falco       │   │
+│  │ Bandit      │    │ OWASP DC    │    │ Grype       │    │ (optional)  │   │
+│  │ Secrets     │    │ SAST/SCA    │    │ SBOM        │    │ Runtime     │   │
+│  └─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘   │
+└─────────────────────────────────────────────────────────────────────────────┘
+
+

Security Tools Stack

+

1. Secrets Detection

+ + + + + + + + + + + + + + + + + + + + + + + +
ToolVersionLizenzVerwendung
Gitleaks8.18.xMITPre-commit Hook, CI/CD
detect-secrets1.4.xApache-2.0Zusaetzliche Baseline-Pruefung
+

Konfiguration: .gitleaks.toml

+
# Lokal ausfuehren
+gitleaks detect --source . -v
+
+# Pre-commit (automatisch)
+gitleaks protect --staged -v
+
+

2. Static Application Security Testing (SAST)

+ + + + + + + + + + + + + + + + + + + + + + + +
ToolVersionLizenzSprachen
Semgrep1.52.xLGPL-2.1Python, Go, JavaScript, TypeScript
Bandit1.7.xApache-2.0Python (spezialisiert)
+

Konfiguration: .semgrep.yml

+
# Semgrep ausfuehren
+semgrep scan --config auto --config .semgrep.yml
+
+# Bandit ausfuehren
+bandit -r backend/ -ll
+
+

3. Software Composition Analysis (SCA)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolVersionLizenzVerwendung
Trivy0.48.xApache-2.0Filesystem, Container, IaC
Grype0.74.xApache-2.0Vulnerability Scanning
OWASP Dependency-Check9.xApache-2.0CVE/NVD Abgleich
+

Konfiguration: .trivy.yaml

+
# Filesystem-Scan
+trivy fs . --severity HIGH,CRITICAL
+
+# Container-Scan
+trivy image breakpilot-pwa-backend:latest
+
+

4. SBOM (Software Bill of Materials)

+ + + + + + + + + + + + + + + + + +
ToolVersionLizenzFormate
Syft0.100.xApache-2.0CycloneDX, SPDX
+
# SBOM generieren
+syft dir:. -o cyclonedx-json=sbom.json
+syft dir:. -o spdx-json=sbom-spdx.json
+
+

5. Dynamic Application Security Testing (DAST)

+ + + + + + + + + + + + + + + + + +
ToolVersionLizenzVerwendung
OWASP ZAP2.14.xApache-2.0Staging-Scans (nightly)
+
# ZAP Scan gegen Staging
+docker run -t owasp/zap2docker-stable zap-baseline.py \
+    -t http://staging.breakpilot.app -r zap-report.html
+
+

Pre-Commit Hooks

+

Die Pre-Commit-Konfiguration (.pre-commit-config.yaml) fuehrt automatisch bei jedem Commit aus:

+
    +
  1. Schnelle Checks (< 10 Sekunden):
  2. +
  3. Gitleaks (Secrets)
  4. +
  5. Trailing Whitespace
  6. +
  7. +

    YAML/JSON Validierung

    +
  8. +
  9. +

    Code Quality (< 30 Sekunden):

    +
  10. +
  11. Black/Ruff (Python Formatting)
  12. +
  13. Go fmt/vet
  14. +
  15. +

    ESLint (JavaScript)

    +
  16. +
  17. +

    Security Checks (< 60 Sekunden):

    +
  18. +
  19. Bandit (Python Security)
  20. +
  21. Semgrep (Error-Severity)
  22. +
+

Installation

+
# Pre-commit installieren
+pip install pre-commit
+
+# Hooks aktivieren
+pre-commit install
+
+# Alle Checks manuell ausfuehren
+pre-commit run --all-files
+
+

Severity-Gates

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseSeverityAktion
Pre-CommitERRORCommit blockiert
PR/CICRITICAL, HIGHPipeline blockiert
Nightly ScanMEDIUM+Report generiert
Production DeployCRITICALDeploy blockiert
+

Security Dashboard

+

Das BreakPilot Admin Panel enthaelt ein integriertes Security Dashboard unter Verwaltung > Security.

+

Features

+

Fuer Entwickler: +- Scan-Ergebnisse auf einen Blick +- Pre-commit Hook Status +- Quick-Fix Suggestions +- SBOM Viewer mit Suchfunktion

+

Fuer Security-Experten: +- Vulnerability Severity Distribution (Critical/High/Medium/Low) +- CVE-Tracking mit Fix-Verfuegbarkeit +- Compliance-Status (OWASP Top 10, DSGVO) +- Secrets Detection History

+

Fuer Ops: +- Container Image Scan Results +- Dependency Update Status +- Security Scan Scheduling +- Auto-Refresh alle 30 Sekunden

+

API Endpoints

+
GET  /api/v1/security/tools      - Tool-Status
+GET  /api/v1/security/findings   - Alle Findings
+GET  /api/v1/security/summary    - Severity-Zusammenfassung
+GET  /api/v1/security/sbom       - SBOM-Daten
+GET  /api/v1/security/history    - Scan-Historie
+GET  /api/v1/security/reports/{tool} - Tool-spezifischer Report
+POST /api/v1/security/scan/{type} - Scan starten
+GET  /api/v1/security/health     - Health-Check
+
+

Compliance

+

Die DevSecOps-Pipeline unterstuetzt folgende Compliance-Anforderungen:

+
    +
  • DSGVO/GDPR: Automatische Erkennung von PII-Leaks
  • +
  • OWASP Top 10: SAST/DAST-Scans gegen bekannte Schwachstellen
  • +
  • Supply Chain Security: SBOM-Generierung fuer Audit-Trails
  • +
  • CVE Tracking: Automatischer Abgleich mit NVD/CVE-Datenbanken
  • +
+

Tool-Installation

+

macOS (Homebrew)

+
# Security Tools
+brew install gitleaks
+brew install trivy
+brew install syft
+brew install grype
+
+# Python Tools
+pip install semgrep bandit pre-commit
+
+

Linux (apt/snap)

+
# Gitleaks
+sudo snap install gitleaks
+
+# Trivy
+sudo apt-get install trivy
+
+# Python Tools
+pip install semgrep bandit pre-commit
+
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/environments/index.html b/docs-site/architecture/environments/index.html new file mode 100644 index 0000000..a92a6b0 --- /dev/null +++ b/docs-site/architecture/environments/index.html @@ -0,0 +1,2744 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Environments - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

Umgebungs-Architektur

+

Übersicht

+

BreakPilot verwendet eine 3-Umgebungs-Strategie für sichere Entwicklung und Deployment:

+
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
+│   Development   │────▶│     Staging     │────▶│   Production    │
+│   (develop)     │     │    (staging)    │     │     (main)      │
+└─────────────────┘     └─────────────────┘     └─────────────────┘
+     Tägliche            Getesteter Code         Produktionsreif
+     Entwicklung
+
+

Umgebungen

+

Development (Dev)

+

Zweck: Tägliche Entwicklungsarbeit

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EigenschaftWert
Git Branchdevelop
Compose Filedocker-compose.yml + docker-compose.override.yml (auto)
Env File.env.dev
Databasebreakpilot_dev
DebugAktiviert
Hot-ReloadAktiviert
+

Start: +

./scripts/start.sh dev
+# oder einfach:
+docker compose up -d
+

+

Staging

+

Zweck: Getesteter, freigegebener Code vor Produktion

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EigenschaftWert
Git Branchstaging
Compose Filedocker-compose.yml + docker-compose.staging.yml
Env File.env.staging
Databasebreakpilot_staging (separates Volume)
DebugDeaktiviert
Hot-ReloadDeaktiviert
+

Start: +

./scripts/start.sh staging
+# oder:
+docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
+

+

Production (Prod)

+

Zweck: Live-System für Endbenutzer (ab Launch)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EigenschaftWert
Git Branchmain
Compose Filedocker-compose.yml + docker-compose.prod.yml
Env File.env.prod (NICHT im Repository!)
Databasebreakpilot_prod (separates Volume)
DebugDeaktiviert
VaultPflicht (keine Env-Fallbacks)
+

Datenbank-Trennung

+

Jede Umgebung verwendet separate Docker Volumes für vollständige Datenisolierung:

+
┌─────────────────────────────────────────────────────────────┐
+│                    PostgreSQL Volumes                        │
+├─────────────────────────────────────────────────────────────┤
+│  breakpilot-dev_postgres_data      │ Development Database   │
+│  breakpilot_staging_postgres       │ Staging Database       │
+│  breakpilot_prod_postgres          │ Production Database    │
+└─────────────────────────────────────────────────────────────┘
+
+

Port-Mapping

+

Um mehrere Umgebungen gleichzeitig laufen zu lassen, verwenden sie unterschiedliche Ports:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceDev PortStaging PortProd Port
Backend800080018000
PostgreSQL54325433- (intern)
MinIO9000/90019002/9003- (intern)
Qdrant6333/63346335/6336- (intern)
Mailpit8025/10258026/1026- (deaktiviert)
+

Git Branching Strategie

+
main (Prod)     ← Nur Release-Merges, geschützt
+    │
+    ▼
+staging         ← Getesteter Code, Review erforderlich
+    │
+    ▼
+develop (Dev)   ← Tägliche Arbeit, Default-Branch
+    │
+    ▼
+feature/*       ← Feature-Branches (optional)
+
+

Workflow

+
    +
  1. Entwicklung: Arbeite auf develop
  2. +
  3. Code-Review: Erstelle PR von Feature-Branch → develop
  4. +
  5. Staging: Promote developstaging mit Tests
  6. +
  7. Release: Promote stagingmain nach Freigabe
  8. +
+

Promotion-Befehle

+
# develop → staging
+./scripts/promote.sh dev-to-staging
+
+# staging → main (Production)
+./scripts/promote.sh staging-to-prod
+
+

Secrets Management

+

Development

+
    +
  • .env.dev enthält Entwicklungs-Credentials
  • +
  • Vault optional (Dev-Token)
  • +
  • Mailpit für E-Mail-Tests
  • +
+

Staging

+
    +
  • .env.staging enthält Test-Credentials
  • +
  • Vault empfohlen
  • +
  • Mailpit für E-Mail-Sicherheit
  • +
+

Production

+
    +
  • .env.prod NICHT im Repository
  • +
  • Vault PFLICHT
  • +
  • Echte SMTP-Konfiguration
  • +
+

Siehe auch: Secrets Management

+

Docker Compose Architektur

+
docker-compose.yml              ← Basis-Konfiguration
+        │
+        ├── docker-compose.override.yml  ← Dev (auto-geladen)
+        │
+        ├── docker-compose.staging.yml   ← Staging (explizit)
+        │
+        └── docker-compose.prod.yml      ← Production (explizit)
+
+

Automatisches Laden

+

Docker Compose lädt automatisch: +1. docker-compose.yml +2. docker-compose.override.yml (falls vorhanden)

+

Daher startet docker compose up automatisch die Dev-Umgebung.

+

Helper Scripts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScriptBeschreibung
scripts/env-switch.shWechselt zwischen Umgebungen
scripts/start.shStartet Services für Umgebung
scripts/stop.shStoppt Services
scripts/promote.shPromotet Code zwischen Branches
scripts/status.shZeigt aktuellen Status
+

Verifikation

+

Nach Setup prüfen:

+
# Status anzeigen
+./scripts/status.sh
+
+# Branches prüfen
+git branch -v
+
+# Volumes prüfen
+docker volume ls | grep breakpilot
+
+

Verwandte Dokumentation

+ + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/mail-rbac-architecture/index.html b/docs-site/architecture/mail-rbac-architecture/index.html new file mode 100644 index 0000000..dc3d50f --- /dev/null +++ b/docs-site/architecture/mail-rbac-architecture/index.html @@ -0,0 +1,2628 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mail-RBAC - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

Mail-RBAC Architektur mit Mitarbeiter-Anonymisierung

+

Version: 1.0.0 +Status: Architekturplanung

+
+

Executive Summary

+

Dieses Dokument beschreibt eine neuartige Architektur, die E-Mail, Kalender und Videokonferenzen mit rollenbasierter Zugriffskontrolle (RBAC) verbindet. Das Kernkonzept ermöglicht die vollständige Anonymisierung von Mitarbeiterdaten bei Verlassen des Unternehmens, während geschäftliche Kommunikationshistorie erhalten bleibt.

+
+

1. Das Problem

+

Traditionelle E-Mail-Systeme

+
max.mustermann@firma.de → Person gebunden
+                        → DSGVO: Daten müssen gelöscht werden
+                        → Geschäftshistorie geht verloren
+
+

BreakPilot-Lösung: Rollenbasierte E-Mail

+
klassenlehrer.5a@schule.breakpilot.app → Rolle gebunden
+                                       → Person kann anonymisiert werden
+                                       → Kommunikationshistorie bleibt erhalten
+
+
+

2. Architektur-Übersicht

+
┌─────────────────────────────────────────────────────────────────┐
+│                     BreakPilot Groupware                        │
+├─────────────────────────────────────────────────────────────────┤
+│                                                                 │
+│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐             │
+│  │   Webmail   │  │  Kalender   │  │   Jitsi     │             │
+│  │   (SOGo)    │  │   (SOGo)    │  │  Meeting    │             │
+│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘             │
+│         │                │                │                     │
+│         └────────────────┼────────────────┘                     │
+│                          │                                      │
+│              ┌───────────┴───────────┐                         │
+│              │   RBAC-Mail-Bridge    │ ◄─── Neue Komponente    │
+│              │   (Python/Go)         │                         │
+│              └───────────┬───────────┘                         │
+│                          │                                      │
+│    ┌─────────────────────┼─────────────────────┐               │
+│    │                     │                     │                │
+│    ▼                     ▼                     ▼                │
+│ ┌──────────┐     ┌──────────────┐     ┌────────────┐           │
+│ │PostgreSQL│     │ Mail Server  │     │  MinIO     │           │
+│ │(RBAC DB) │     │ (Stalwart)   │     │ (Backups)  │           │
+│ └──────────┘     └──────────────┘     └────────────┘           │
+│                                                                 │
+└─────────────────────────────────────────────────────────────────┘
+
+
+

3. Komponenten-Auswahl

+

3.1 E-Mail Server: Stalwart Mail Server

+

Empfehlung: Stalwart Mail Server

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumBewertung
LizenzAGPL-3.0 (Open Source)
SpracheRust (performant, sicher)
FeaturesIMAP, SMTP, JMAP, WebSocket
KalenderCalDAV integriert
KontakteCardDAV integriert
Spam/VirusIntegriert
APIREST API für Administration
+

3.2 Webmail-Client: SOGo oder Roundcube

+

Option A: SOGo (empfohlen) +- Lizenz: GPL-2.0 / LGPL-2.1 +- Kalender, Kontakte, Mail in einem +- ActiveSync Support +- Outlook-ähnliche Oberfläche

+

Option B: Roundcube +- Lizenz: GPL-3.0 +- Nur Webmail +- Benötigt separaten Kalender

+
+

4. Anonymisierungs-Workflow

+
Mitarbeiter kündigt
+        │
+        ▼
+┌───────────────────────────┐
+│ 1. Functional Mailboxes   │
+│    → Neu zuweisen oder    │
+│    → Deaktivieren         │
+└───────────┬───────────────┘
+            │
+            ▼
+┌───────────────────────────┐
+│ 2. Personal Email Account │
+│    → Anonymisieren:       │
+│    max.mustermann@...     │
+│    → mitarbeiter_a7x2@... │
+└───────────────────────────┘
+            │
+            ▼
+┌───────────────────────────┐
+│ 3. Users-Tabelle          │
+│    → Pseudonymisieren:    │
+│    name: "Max Mustermann" │
+│    → "Ehem. Mitarbeiter"  │
+└───────────────────────────┘
+            │
+            ▼
+┌───────────────────────────┐
+│ 4. Mailbox Assignments    │
+│    → Bleiben für Audit    │
+│    → User-Referenz zeigt  │
+│      auf anonymisierte    │
+│      Daten                │
+└───────────────────────────┘
+            │
+            ▼
+┌───────────────────────────┐
+│ 5. E-Mail-Archiv          │
+│    → Header anonymisieren │
+│    → Inhalte optional     │
+│      löschen              │
+└───────────────────────────┘
+
+
+

5. Unified Inbox Implementation

+

Implementierte Komponenten

+

Die Unified Inbox wurde als Teil des klausur-service implementiert:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KomponentePfadBeschreibung
Modelsklausur-service/backend/mail/models.pyPydantic Models für Accounts, E-Mails, Tasks
Databaseklausur-service/backend/mail/mail_db.pyPostgreSQL-Operationen mit asyncpg
Credentialsklausur-service/backend/mail/credentials.pyVault-Integration für IMAP/SMTP-Passwörter
Aggregatorklausur-service/backend/mail/aggregator.pyMulti-Account IMAP Sync
AI Serviceklausur-service/backend/mail/ai_service.pyKI-Analyse (Absender, Fristen, Kategorien)
Task Serviceklausur-service/backend/mail/task_service.pyArbeitsvorrat-Management
APIklausur-service/backend/mail/api.pyFastAPI Router mit 30+ Endpoints
+

API-Endpoints (Port 8086)

+
# Account Management
+POST   /api/v1/mail/accounts              - Neues Konto hinzufügen
+GET    /api/v1/mail/accounts              - Alle Konten auflisten
+DELETE /api/v1/mail/accounts/{id}         - Konto entfernen
+POST   /api/v1/mail/accounts/{id}/test    - Verbindung testen
+
+# Unified Inbox
+GET    /api/v1/mail/inbox                 - Aggregierte Inbox
+GET    /api/v1/mail/inbox/{id}            - Einzelne E-Mail
+POST   /api/v1/mail/send                  - E-Mail senden
+
+# KI-Features
+POST   /api/v1/mail/analyze/{id}          - E-Mail analysieren
+GET    /api/v1/mail/suggestions/{id}      - Antwortvorschläge
+
+# Arbeitsvorrat
+GET    /api/v1/mail/tasks                 - Alle Tasks
+POST   /api/v1/mail/tasks                 - Manuelle Task erstellen
+PATCH  /api/v1/mail/tasks/{id}            - Task aktualisieren
+GET    /api/v1/mail/tasks/dashboard       - Dashboard-Statistiken
+
+

Niedersachsen-spezifische Absendererkennung

+
KNOWN_AUTHORITIES_NI = {
+    "@mk.niedersachsen.de": "Kultusministerium Niedersachsen",
+    "@rlsb.de": "Regionales Landesamt für Schule und Bildung",
+    "@landesschulbehoerde-nds.de": "Landesschulbehörde",
+    "@nibis.de": "NiBiS",
+}
+
+
+

6. Lizenz-Übersicht

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KomponenteLizenzKommerzielle NutzungVeröffentlichungspflicht
Stalwart MailAGPL-3.0JaNur bei Code-Änderungen
SOGoGPL-2.0/LGPLJaNur bei Code-Änderungen
RoundcubeGPL-3.0JaNur bei Code-Änderungen
RBAC-Mail-BridgeEigeneN/AKann proprietär bleiben
BreakPilot BackendEigeneN/AProprietär
+
+

7. Referenzen

+ + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/multi-agent/index.html b/docs-site/architecture/multi-agent/index.html new file mode 100644 index 0000000..6340ae2 --- /dev/null +++ b/docs-site/architecture/multi-agent/index.html @@ -0,0 +1,3078 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Multi-Agent - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+ +
+ + + +
+ +
+ + + + + +

Multi-Agent Architektur - Entwicklerdokumentation

+

Status: Implementiert +Modul: /agent-core/

+
+

1. Übersicht

+

Die Multi-Agent-Architektur erweitert Breakpilot um ein verteiltes Agent-System basierend auf Mission Control Konzepten.

+

Kernkomponenten

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KomponentePfadBeschreibung
Session Management/agent-core/sessions/Lifecycle & Recovery
Shared Brain/agent-core/brain/Langzeit-Gedächtnis
Orchestrator/agent-core/orchestrator/Koordination
SOUL Files/agent-core/soul/Agent-Persönlichkeiten
+
+

2. Agent-Typen

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AgentAufgabeSOUL-Datei
TutorAgentLernbegleitung, Fragen beantwortentutor-agent.soul.md
GraderAgentKlausur-Korrektur, Bewertunggrader-agent.soul.md
QualityJudgeBQAS Qualitätsprüfungquality-judge.soul.md
AlertAgentMonitoring, Benachrichtigungenalert-agent.soul.md
OrchestratorTask-Koordinationorchestrator.soul.md
+
+

3. Wichtige Dateien

+

Session Management

+
agent-core/sessions/
+├── session_manager.py   # AgentSession, SessionManager, SessionState
+├── heartbeat.py         # HeartbeatMonitor, HeartbeatClient
+└── checkpoint.py        # CheckpointManager
+
+

Shared Brain

+
agent-core/brain/
+├── memory_store.py      # MemoryStore, Memory (mit TTL)
+├── context_manager.py   # ConversationContext, ContextManager
+└── knowledge_graph.py   # KnowledgeGraph, Entity, Relationship
+
+

Orchestrator

+
agent-core/orchestrator/
+├── message_bus.py       # MessageBus, AgentMessage, MessagePriority
+├── supervisor.py        # AgentSupervisor, AgentInfo, AgentStatus
+└── task_router.py       # TaskRouter, RoutingRule, RoutingResult
+
+
+

4. Datenbank-Schema

+

Die Migration befindet sich in: +/backend/migrations/add_agent_core_tables.sql

+

Tabellen

+
    +
  1. agent_sessions - Session-Daten mit Checkpoints
  2. +
  3. agent_memory - Langzeit-Gedächtnis mit TTL
  4. +
  5. agent_messages - Audit-Trail für Inter-Agent Kommunikation
  6. +
+

Helper-Funktionen

+
-- Abgelaufene Memories bereinigen
+SELECT cleanup_expired_agent_memory();
+
+-- Inaktive Sessions bereinigen
+SELECT cleanup_stale_agent_sessions(48); -- 48 Stunden
+
+
+

5. Integration Voice-Service

+

Der EnhancedTaskOrchestrator erweitert den bestehenden TaskOrchestrator:

+
# voice-service/services/enhanced_task_orchestrator.py
+
+from agent_core.sessions import SessionManager
+from agent_core.orchestrator import MessageBus
+
+class EnhancedTaskOrchestrator(TaskOrchestrator):
+    # Nutzt Session-Checkpoints für Recovery
+    # Routet komplexe Tasks an spezialisierte Agents
+    # Führt Quality-Checks via BQAS durch
+
+

Wichtig: Der Enhanced Orchestrator ist abwärtskompatibel und kann parallel zum Original verwendet werden.

+
+

6. Integration BQAS

+

Der QualityJudgeAgent integriert BQAS mit dem Multi-Agent-System:

+
# voice-service/bqas/quality_judge_agent.py
+
+from bqas.judge import LLMJudge
+from agent_core.orchestrator import MessageBus
+
+class QualityJudgeAgent:
+    # Wertet Responses in Echtzeit aus
+    # Nutzt Memory für konsistente Bewertungen
+    # Empfängt Evaluierungs-Requests via Message Bus
+
+
+

7. Code-Beispiele

+

Session erstellen

+
from agent_core.sessions import SessionManager
+
+manager = SessionManager(redis_client=redis, db_pool=pool)
+session = await manager.create_session(
+    agent_type="tutor-agent",
+    user_id="user-123"
+)
+
+

Memory speichern

+
from agent_core.brain import MemoryStore
+
+store = MemoryStore(redis_client=redis, db_pool=pool)
+await store.remember(
+    key="student:123:progress",
+    value={"level": 5, "score": 85},
+    agent_id="tutor-agent",
+    ttl_days=30
+)
+
+

Nachricht senden

+
from agent_core.orchestrator import MessageBus, AgentMessage
+
+bus = MessageBus(redis_client=redis)
+await bus.publish(AgentMessage(
+    sender="orchestrator",
+    receiver="grader-agent",
+    message_type="grade_request",
+    payload={"exam_id": "exam-1"}
+))
+
+
+

8. Tests ausführen

+
# Alle Agent-Core Tests
+cd agent-core && pytest -v
+
+# Mit Coverage-Report
+pytest --cov=. --cov-report=html
+
+# Einzelne Module
+pytest tests/test_session_manager.py -v
+pytest tests/test_message_bus.py -v
+
+
+

9. Deployment-Schritte

+

1. Migration ausführen

+
psql -h localhost -U breakpilot -d breakpilot \
+  -f backend/migrations/add_agent_core_tables.sql
+
+

2. Voice-Service aktualisieren

+
# Sync zu Server
+rsync -avz --exclude 'node_modules' --exclude '.git' \
+  /path/to/breakpilot-pwa/ server:/path/to/breakpilot-pwa/
+
+# Container neu bauen
+docker compose build --no-cache voice-service
+
+# Starten
+docker compose up -d voice-service
+
+

3. Verifizieren

+
# Session-Tabelle prüfen
+psql -c "SELECT COUNT(*) FROM agent_sessions;"
+
+# Memory-Tabelle prüfen
+psql -c "SELECT COUNT(*) FROM agent_memory;"
+
+
+

10. Monitoring

+

Metriken

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetrikBeschreibung
agent_session_countAnzahl aktiver Sessions
agent_heartbeat_delay_msZeit seit letztem Heartbeat
agent_message_latency_msNachrichtenlatenz
agent_memory_countGespeicherte Memories
agent_routing_success_rateErfolgreiche Routings
+

Health-Check-Endpunkte

+
GET /api/v1/agents/health       # Supervisor Status
+GET /api/v1/agents/sessions     # Aktive Sessions
+GET /api/v1/agents/memory/stats # Memory-Statistiken
+
+
+

11. Troubleshooting

+

Problem: Session nicht gefunden

+
    +
  1. Prüfen ob Valkey läuft: redis-cli ping
  2. +
  3. Session-Timeout prüfen (default 24h)
  4. +
  5. Heartbeat-Status checken
  6. +
+

Problem: Message Bus Timeout

+
    +
  1. Redis Pub/Sub Status prüfen
  2. +
  3. Ziel-Agent registriert?
  4. +
  5. Timeout erhöhen (default 30s)
  6. +
+

Problem: Memory nicht gefunden

+
    +
  1. Namespace korrekt?
  2. +
  3. TTL abgelaufen?
  4. +
  5. Cleanup-Job gelaufen?
  6. +
+
+

12. Erweiterungen

+

Neuen Agent hinzufügen

+
    +
  1. SOUL-Datei erstellen in /agent-core/soul/
  2. +
  3. Routing-Regel in task_router.py hinzufügen
  4. +
  5. Handler beim Supervisor registrieren
  6. +
  7. Tests schreiben
  8. +
+

Neuen Memory-Typ hinzufügen

+
    +
  1. Key-Schema definieren (z.B. student:*:progress)
  2. +
  3. TTL festlegen
  4. +
  5. Access-Pattern dokumentieren
  6. +
+
+

13. Referenzen

+
    +
  • Agent-Core README: /agent-core/README.md
  • +
  • Migration: /backend/migrations/add_agent_core_tables.sql
  • +
  • Voice-Service Integration: /voice-service/services/enhanced_task_orchestrator.py
  • +
  • BQAS Integration: /voice-service/bqas/quality_judge_agent.py
  • +
  • Tests: /agent-core/tests/
  • +
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/sdk-protection/index.html b/docs-site/architecture/sdk-protection/index.html new file mode 100644 index 0000000..65c9977 --- /dev/null +++ b/docs-site/architecture/sdk-protection/index.html @@ -0,0 +1,3087 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + SDK Protection - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

SDK Protection Middleware

+

1. Worum geht es?

+

Die SDK Protection Middleware schuetzt die Compliance-SDK-Endpunkte vor einer bestimmten Art von Angriff: der systematischen Enumeration. Was bedeutet das?

+
+

Ein Wettbewerber registriert sich als zahlender Kunde und laesst ein Skript langsam und verteilt alle TOM-Controls, alle Pruefaspekte und alle Assessment-Kriterien abfragen. Aus den Ergebnissen rekonstruiert er die gesamte Compliance-Framework-Logik.

+
+

Der klassische Rate Limiter (100 Requests/Minute) hilft hier nicht, weil ein cleverer Angreifer langsam vorgeht -- vielleicht nur 20 Anfragen pro Minute, dafuer systematisch und ueber Stunden. Die SDK Protection erkennt solche Muster und reagiert darauf.

+
+

Kern-Designprinzip

+

Normale Nutzer merken nichts. Ein Lehrer, der im TOM-Modul arbeitet, greift typischerweise auf 3-5 Kategorien zu und wiederholt Anfragen an gleiche Endpunkte. Ein Angreifer durchlaeuft dagegen 40+ Kategorien in alphabetischer Reihenfolge. Genau diesen Unterschied erkennt die Middleware.

+
+
+

2. Wie funktioniert der Schutz?

+

Die Middleware nutzt ein Anomaly-Score-System. Jeder Benutzer hat einen Score, der bei 0 beginnt. Verschiedene verdaechtige Verhaltensweisen erhoehen den Score. Ueber die Zeit sinkt er wieder ab. Je hoeher der Score, desto staerker wird der Benutzer gebremst.

+

Man kann es sich wie eine Ampel vorstellen:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScoreAmpelWirkungBeispiel
0-29GruenKeine EinschraenkungNormaler Nutzer
30-59Gelb1-3 Sekunden VerzoegerungLeicht auffaelliges Muster
60-84Orange5-10 Sekunden Verzoegerung, reduzierte DetailsDeutlich verdaechtiges Verhalten
85+RotZugriff blockiert (HTTP 429)Sehr wahrscheinlich automatisierter Angriff
+

Score-Zerfall

+

Der Score sinkt automatisch: Alle 5 Minuten wird er mit dem Faktor 0,95 multipliziert. Ein Score von 60 faellt also innerhalb einer Stunde auf etwa 30 -- wenn kein neues verdaechtiges Verhalten hinzukommt.

+
+

3. Was wird erkannt?

+

Die Middleware erkennt fuenf verschiedene Anomalie-Muster:

+

3.1 Hohe Kategorie-Diversitaet

+

Was: Ein Benutzer greift innerhalb einer Stunde auf mehr als 40 verschiedene SDK-Kategorien zu.

+

Warum verdaechtig: Ein normaler Nutzer arbeitet in der Regel mit 3-10 Kategorien. Wer systematisch alle durchlaeuft, sammelt vermutlich Daten.

+

Score-Erhoehung: +15

+
Normal:   tom/access-control → tom/access-control → tom/encryption → tom/encryption
+                               (3 verschiedene Kategorien in einer Stunde)
+
+Verdaechtig: tom/access-control → tom/encryption → tom/pseudonymization → tom/integrity
+             → tom/availability → tom/resilience → dsfa/threshold → dsfa/necessity → ...
+                               (40+ verschiedene Kategorien in einer Stunde)
+
+

3.2 Burst-Erkennung

+

Was: Ein Benutzer sendet mehr als 15 Anfragen an die gleiche Kategorie innerhalb von 2 Minuten.

+

Warum verdaechtig: Selbst ein eifriger Nutzer klickt nicht 15-mal pro Minute auf denselben Endpunkt. Das deutet auf automatisiertes Scraping hin.

+

Score-Erhoehung: +20

+

3.3 Sequentielle Enumeration

+

Was: Die letzten 10 aufgerufenen Kategorien sind zu mindestens 70% in alphabetischer oder numerischer Reihenfolge.

+

Warum verdaechtig: Menschen springen zwischen Kategorien -- sie arbeiten thematisch, nicht alphabetisch. Ein Skript dagegen iteriert oft ueber eine sortierte Liste.

+

Score-Erhoehung: +25

+
Verdaechtig: assessment_general → compliance_general → controls_general
+             → dsfa_measures → dsfa_necessity → dsfa_residual → dsfa_risks
+             → dsfa_threshold → eh_general → namespace_general
+             (alphabetisch sortiert = Skript-Verhalten)
+
+

3.4 Ungewoehnliche Uhrzeiten

+

Was: Anfragen zwischen 0:00 und 5:00 Uhr UTC.

+

Warum verdaechtig: Lehrer arbeiten tagsüber. Wer um 3 Uhr morgens SDK-Endpunkte abfragt, ist wahrscheinlich ein automatisierter Prozess.

+

Score-Erhoehung: +10

+

3.5 Multi-Tenant-Zugriff

+

Was: Ein Benutzer greift innerhalb einer Stunde auf mehr als 3 verschiedene Mandanten (Tenants) zu.

+

Warum verdaechtig: Ein normaler Nutzer gehoert zu einem Mandanten. Wer mehrere durchprobiert, koennte versuchen, mandantenuebergreifend Daten zu sammeln.

+

Score-Erhoehung: +15

+
+

4. Quota-System (Mengenbegrenzung)

+

Zusaetzlich zum Anomaly-Score gibt es klassische Mengenbegrenzungen in vier Zeitfenstern:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tierpro Minutepro Stundepro Tagpro Monat
Free305003.00050.000
Standard601.50010.000200.000
Enterprise1205.00050.0001.000.000
+

Wenn ein Limit in irgendeinem Zeitfenster ueberschritten wird, erhaelt der Nutzer sofort HTTP 429 -- unabhaengig vom Anomaly-Score.

+
+

5. Architektur

+

Datenfluss eines SDK-Requests

+
Request kommt an
+     │
+     ▼
+┌─────────────────────────────────────────────────────────────┐
+│  Ist der Pfad geschuetzt?                                    │
+│  (/api/sdk/*, /api/v1/tom/*, /api/v1/dsfa/*, ...)           │
+│  Nein → direkt weiterleiten                                  │
+└──────────────┬──────────────────────────────────────────────┘
+               │ Ja
+               ▼
+┌─────────────────────────────────────────────────────────────┐
+│  User + Tier + Kategorie extrahieren                         │
+│  (aus Session, API-Key oder X-SDK-Tier Header)               │
+└──────────────┬──────────────────────────────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────────────────────────────┐
+│  Multi-Window Quota pruefen                                  │
+│  (Minute / Stunde / Tag / Monat)                             │
+│  Ueberschritten → HTTP 429 zurueck                          │
+└──────────────┬──────────────────────────────────────────────┘
+               │ OK
+               ▼
+┌─────────────────────────────────────────────────────────────┐
+│  Anomaly-Score laden (aus Valkey)                            │
+│  Zeitbasierten Zerfall anwenden (×0,95 alle 5 min)           │
+└──────────────┬──────────────────────────────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────────────────────────────┐
+│  Anomalie-Detektoren ausfuehren:                             │
+│  ├── Diversity-Tracking     (+15 wenn >40 Kategorien/h)      │
+│  ├── Burst-Detection        (+20 wenn >15 gleiche/2min)      │
+│  ├── Sequential-Enumeration (+25 wenn sortiert)              │
+│  ├── Unusual-Hours          (+10 wenn 0-5 Uhr UTC)           │
+│  └── Multi-Tenant           (+15 wenn >3 Tenants/h)          │
+└──────────────┬──────────────────────────────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────────────────────────────┐
+│  Throttle-Level bestimmen                                    │
+│  Level 3 (Score ≥85) → HTTP 429                              │
+│  Level 2 (Score ≥60) → 5-10s Delay + reduzierte Details      │
+│  Level 1 (Score ≥30) → 1-3s Delay                            │
+│  Level 0             → keine Einschraenkung                  │
+└──────────────┬──────────────────────────────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────────────────────────────┐
+│  Request weiterleiten                                        │
+│  Response-Headers setzen:                                    │
+│  ├── X-SDK-Quota-Remaining-Minute/Hour                       │
+│  ├── X-SDK-Throttle-Level                                    │
+│  ├── X-SDK-Detail-Reduced (bei Level ≥2)                     │
+│  └── X-BP-Trace (HMAC-Watermark)                             │
+└─────────────────────────────────────────────────────────────┘
+
+

Valkey-Datenstrukturen

+

Die Middleware speichert alle Tracking-Daten in Valkey (Redis-Fork). Wenn Valkey nicht erreichbar ist, wird automatisch auf eine In-Memory-Implementierung zurueckgefallen.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ZweckValkey-TypKey-MusterTTL
Quota pro ZeitfensterSorted Setsdk_protect:quota:{user}:{window}Fenster + 10s
Kategorie-DiversitaetSetsdk_protect:diversity:{user}:{stunde}3660s
Burst-TrackingSorted Setsdk_protect:burst:{user}:{kategorie}130s
Sequenz-TrackingListsdk_protect:seq:{user}310s
Anomaly-ScoreHashsdk_protect:score:{user}86400s
Tenant-TrackingSetsdk_protect:tenants:{user}:{stunde}3660s
+

Watermarking

+

Jede Antwort enthaelt einen X-BP-Trace Header mit einem HMAC-basierten Fingerabdruck. Damit kann nachtraeglich nachgewiesen werden, welcher Benutzer wann welche Daten abgerufen hat -- ohne dass der Benutzer den Trace veraendern kann.

+
+

6. Geschuetzte Endpunkte

+

Die Middleware schuetzt alle Pfade, die SDK- und Compliance-relevante Daten liefern:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pfad-PrefixBereich
/api/sdk/*SDK-Hauptendpunkte
/api/compliance/*Compliance-Bewertungen
/api/v1/tom/*Technisch-organisatorische Massnahmen
/api/v1/dsfa/*Datenschutz-Folgenabschaetzung
/api/v1/vvt/*Verarbeitungsverzeichnis
/api/v1/controls/*Controls und Massnahmen
/api/v1/assessment/*Assessment-Bewertungen
/api/v1/eh/*Erwartungshorizonte
/api/v1/namespace/*Namespace-Verwaltung
+

Nicht geschuetzt sind /health, /metrics und /api/health.

+
+

7. Admin-Verwaltung

+

Ueber das Admin-Dashboard koennen Anomaly-Scores eingesehen und verwaltet werden:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointMethodeBeschreibung
/api/admin/middleware/sdk-protection/scoresGETAktuelle Anomaly-Scores aller Benutzer
/api/admin/middleware/sdk-protection/statsGETStatistik: Benutzer pro Throttle-Level
/api/admin/middleware/sdk-protection/reset-score/{user_id}POSTScore eines Benutzers zuruecksetzen
/api/admin/middleware/sdk-protection/tiersGETTier-Konfigurationen anzeigen
/api/admin/middleware/sdk-protection/tiers/{name}PUTTier-Limits aendern
+
+

8. Dateien und Quellcode

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateiBeschreibung
backend/middleware/sdk_protection.pyKern-Middleware (~460 Zeilen)
backend/middleware/__init__.pyExport der Middleware-Klassen
backend/main.pyRegistrierung im FastAPI-Stack
backend/middleware_admin_api.pyAdmin-API-Endpoints
backend/migrations/add_sdk_protection_tables.sqlDatenbank-Migration
backend/tests/test_middleware.py14 Tests fuer alle Erkennungsmechanismen
+
+

9. Datenbank-Tabellen

+

sdk_anomaly_scores

+

Speichert Snapshots der Anomaly-Scores fuer Audit und Analyse.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SpalteTypBeschreibung
idUUIDPrimaerschluessel
user_idVARCHAR(255)Benutzer-Identifikation
scoreDECIMAL(5,2)Aktueller Anomaly-Score
throttle_levelSMALLINTAktueller Throttle-Level (0-3)
triggered_rulesJSONBWelche Regeln ausgeloest wurden
endpoint_diversity_countINTAnzahl verschiedener Kategorien
request_count_1hINTAnfragen in der letzten Stunde
snapshot_atTIMESTAMPTZZeitpunkt des Snapshots
+

sdk_protection_tiers

+

Konfigurierbare Quota-Tiers, editierbar ueber die Admin-API.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SpalteTypBeschreibung
tier_nameVARCHAR(50)Name des Tiers (free, standard, enterprise)
quota_per_minuteINTMaximale Anfragen pro Minute
quota_per_hourINTMaximale Anfragen pro Stunde
quota_per_dayINTMaximale Anfragen pro Tag
quota_per_monthINTMaximale Anfragen pro Monat
diversity_thresholdINTMax verschiedene Kategorien pro Stunde
burst_thresholdINTMax gleiche Kategorie in 2 Minuten
+
+

10. Konfiguration

+

Die Middleware wird in main.py registriert:

+
from middleware import SDKProtectionMiddleware
+
+app.add_middleware(SDKProtectionMiddleware)
+
+

Alle Parameter koennen ueber die SDKProtectionConfig Dataclass angepasst werden. Die wichtigsten Umgebungsvariablen:

+ + + + + + + + + + + + + + + + + + + + +
VariableDefaultBeschreibung
VALKEY_URLredis://localhost:6379Verbindung zur Valkey-Instanz
SDK_WATERMARK_SECRET(generiert)HMAC-Secret fuer Watermarks
+
+

11. Tests

+

Die Middleware wird durch 14 automatisierte Tests abgedeckt:

+
# Alle SDK Protection Tests ausfuehren
+docker compose run --rm --no-deps backend \
+  python -m pytest tests/test_middleware.py -v -k sdk
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TestPrueft
test_allows_normal_requestNormaler Request wird durchgelassen
test_blocks_after_quota_exceeded429 bei Quota-Ueberschreitung
test_diversity_tracking_increments_scoreViele Kategorien erhoehen den Score
test_burst_detectionSchnelle gleiche Anfragen erhoehen den Score
test_sequential_enumeration_detectionAlphabetische Muster werden erkannt
test_progressive_throttling_level_1Delay bei Score >= 30
test_progressive_throttling_level_3_blocksBlock bei Score >= 85
test_score_decay_over_timeScore sinkt ueber die Zeit
test_skips_non_protected_pathsNicht-SDK-Pfade bleiben frei
test_watermark_header_presentX-BP-Trace Header vorhanden
test_fallback_to_inmemoryFunktioniert ohne Valkey
test_no_user_passes_throughAnonyme Requests passieren
test_category_extractionKorrekte Kategorie-Zuordnung
test_quota_headers_presentResponse-Headers vorhanden
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/secrets-management/index.html b/docs-site/architecture/secrets-management/index.html new file mode 100644 index 0000000..188a3f4 --- /dev/null +++ b/docs-site/architecture/secrets-management/index.html @@ -0,0 +1,2780 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Secrets Management - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

BreakPilot Secrets Management

+

Uebersicht

+

BreakPilot verwendet HashiCorp Vault als zentrales Secrets-Management-System.

+
┌─────────────────────────────────────────────────────────────────────────┐
+│                        SECRETS MANAGEMENT                                │
+│                                                                          │
+│  ┌────────────────────────────────────────────────────────────────────┐ │
+│  │                    HashiCorp Vault                                  │ │
+│  │                       Port 8200                                     │ │
+│  │  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────────┐  │ │
+│  │  │ KV v2 Engine │  │ AppRole Auth │  │ Audit Logging            │  │ │
+│  │  │ secret/      │  │ Token Auth   │  │ Verschluesselung         │  │ │
+│  │  └──────────────┘  └──────────────┘  └──────────────────────────┘  │ │
+│  └────────────────────────────────────────────────────────────────────┘ │
+│                                    │                                     │
+│              ┌─────────────────────┼─────────────────────┐              │
+│              ▼                     ▼                     ▼              │
+│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐     │
+│  │  Python Backend │    │  Go Services    │    │  Frontend       │     │
+│  │  (hvac client)  │    │  (vault-client) │    │  (via Backend)  │     │
+│  └─────────────────┘    └─────────────────┘    └─────────────────┘     │
+└─────────────────────────────────────────────────────────────────────────┘
+
+

Warum Vault?

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AlternativeNachteil
Environment VariablesKeine Audit-Logs, keine Verschluesselung, keine Rotation
Docker SecretsNur fuer Docker Swarm, keine zentrale Verwaltung
AWS Secrets ManagerCloud Lock-in, Kosten
Kubernetes SecretsKeine Verschluesselung by default, nur K8s
HashiCorp VaultOpen Source (BSL 1.1), Self-Hosted, Enterprise Features
+

Architektur

+

Secret-Hierarchie

+
secret/breakpilot/
+├── api_keys/
+│   ├── anthropic     # Anthropic Claude API Key
+│   ├── vast          # vast.ai GPU API Key
+│   ├── stripe        # Stripe Payment Key
+│   ├── stripe_webhook
+│   └── tavily        # Tavily Search API Key
+├── database/
+│   ├── postgres      # username, password, url
+│   └── synapse       # Matrix Synapse DB
+├── auth/
+│   ├── jwt           # secret, refresh_secret
+│   └── keycloak      # client_secret
+├── communication/
+│   ├── matrix        # access_token, db_password
+│   └── jitsi         # app_secret, jicofo, jvb passwords
+├── storage/
+│   └── minio         # access_key, secret_key
+└── infra/
+    └── vast          # api_key, instance_id, control_key
+
+

Python Integration

+
from secrets import get_secret
+
+# Einzelnes Secret abrufen
+api_key = get_secret("ANTHROPIC_API_KEY")
+
+# Mit Default-Wert
+debug = get_secret("DEBUG", default="false")
+
+# Als Pflicht-Secret
+db_url = get_secret("DATABASE_URL", required=True)
+
+

Fallback-Reihenfolge

+
1. HashiCorp Vault (wenn VAULT_ADDR gesetzt)
+   ↓ falls nicht verfuegbar
+2. Environment Variables
+   ↓ falls nicht gesetzt
+3. Docker Secrets (/run/secrets/)
+   ↓ falls nicht vorhanden
+4. Default-Wert (wenn angegeben)
+   ↓ sonst
+5. SecretNotFoundError (wenn required=True)
+
+

Setup

+

Entwicklung (Dev Mode)

+
# Vault starten (Dev Mode - NICHT fuer Produktion!)
+docker-compose -f docker-compose.vault.yml up -d vault
+
+# Warten bis healthy
+docker-compose -f docker-compose.vault.yml up vault-init
+
+# Environment setzen
+export VAULT_ADDR=http://localhost:8200
+export VAULT_TOKEN=breakpilot-dev-token
+
+

Secrets setzen

+
# Anthropic API Key
+vault kv put secret/breakpilot/api_keys/anthropic value='sk-ant-api03-...'
+
+# vast.ai Credentials
+vault kv put secret/breakpilot/infra/vast \
+    api_key='xxx' \
+    instance_id='123' \
+    control_key='yyy'
+
+# Database
+vault kv put secret/breakpilot/database/postgres \
+    username='breakpilot' \
+    password='supersecret' \
+    url='postgres://breakpilot:supersecret@localhost:5432/breakpilot_db'
+
+

Secrets lesen

+
# Liste aller Secrets
+vault kv list secret/breakpilot/
+
+# Secret anzeigen
+vault kv get secret/breakpilot/api_keys/anthropic
+
+# Nur den Wert
+vault kv get -field=value secret/breakpilot/api_keys/anthropic
+
+

Produktion

+

AppRole Authentication

+

In Produktion verwenden Services AppRole statt Token-Auth:

+
# 1. AppRole aktivieren (einmalig)
+vault auth enable approle
+
+# 2. Policy erstellen
+vault policy write breakpilot-backend - <<EOF
+path "secret/data/breakpilot/*" {
+  capabilities = ["read", "list"]
+}
+EOF
+
+# 3. Role erstellen
+vault write auth/approle/role/breakpilot-backend \
+    token_policies="breakpilot-backend" \
+    token_ttl=1h \
+    token_max_ttl=4h
+
+# 4. Role-ID holen (fix)
+vault read -field=role_id auth/approle/role/breakpilot-backend/role-id
+
+# 5. Secret-ID generieren (bei jedem Deploy neu)
+vault write -f auth/approle/role/breakpilot-backend/secret-id
+
+

Environment fuer Services

+
# Docker-Compose / Kubernetes
+VAULT_ADDR=https://vault.breakpilot.app:8200
+VAULT_AUTH_METHOD=approle
+VAULT_ROLE_ID=<role-id>
+VAULT_SECRET_ID=<secret-id>
+VAULT_SECRETS_PATH=breakpilot
+
+

Sicherheits-Checkliste

+

Muss erfuellt sein

+
    +
  • [ ] Keine echten Secrets in .env Dateien
  • +
  • [ ] .env in .gitignore
  • +
  • [ ] Vault im Sealed-State wenn nicht in Verwendung
  • +
  • [ ] TLS fuer Vault in Produktion
  • +
  • [ ] AppRole statt Token-Auth in Produktion
  • +
  • [ ] Audit-Logging aktiviert
  • +
  • [ ] Minimale Policies (Least Privilege)
  • +
+

Sollte erfuellt sein

+
    +
  • [ ] Automatische Secret-Rotation
  • +
  • [ ] Separate Vault-Instanz fuer Produktion
  • +
  • [ ] HSM-basiertes Auto-Unseal
  • +
  • [ ] Disaster Recovery Plan
  • +
+

Dateien

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateiBeschreibung
backend/secrets/__init__.pySecrets-Modul Exports
backend/secrets/vault_client.pyVault Client Implementation
docker-compose.vault.ymlVault Docker Configuration
vault/init-secrets.shEntwicklungs-Secrets Initialisierung
vault/policies/Vault Policy Files
+

Fehlerbehebung

+

Vault nicht erreichbar

+
# Status pruefen
+vault status
+
+# Falls sealed
+vault operator unseal <unseal-key>
+
+

Secret nicht gefunden

+
# Pfad pruefen
+vault kv list secret/breakpilot/
+
+# Cache leeren (Python)
+from secrets import get_secrets_manager
+get_secrets_manager().clear_cache()
+
+

Token abgelaufen

+
# Neuen Token holen (AppRole)
+vault write auth/approle/login \
+    role_id=$VAULT_ROLE_ID \
+    secret_id=$VAULT_SECRET_ID
+
+
+

Referenzen

+ + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/system-architecture/index.html b/docs-site/architecture/system-architecture/index.html new file mode 100644 index 0000000..97c03c3 --- /dev/null +++ b/docs-site/architecture/system-architecture/index.html @@ -0,0 +1,3099 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Systemuebersicht - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

BreakPilot PWA - System-Architektur

+

Übersicht

+

BreakPilot ist eine modulare Bildungsplattform für Lehrkräfte mit folgenden Hauptkomponenten:

+
┌─────────────────────────────────────────────────────────────────────┐
+│                           Browser                                    │
+│  ┌───────────────────────────────────────────────────────────────┐  │
+│  │                  Frontend (Studio UI)                          │  │
+│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐  │  │
+│  │  │Dashboard │ │Worksheets│ │Correction│ │Letters/Companion │  │  │
+│  │  └──────────┘ └──────────┘ └──────────┘ └──────────────────┘  │  │
+│  └───────────────────────────────────────────────────────────────┘  │
+└───────────────────────────┬─────────────────────────────────────────┘
+                            │ HTTP/REST
+                            ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│                    Python Backend (FastAPI)                          │
+│                         Port 8000                                    │
+│  ┌────────────────────────────────────────────────────────────────┐ │
+│  │                      API Layer                                  │ │
+│  │  /api/worksheets  /api/corrections  /api/letters  /api/state   │ │
+│  │  /api/school      /api/certificates /api/messenger /api/jitsi  │ │
+│  └────────────────────────────────────────────────────────────────┘ │
+│  ┌────────────────────────────────────────────────────────────────┐ │
+│  │                    Service Layer                                │ │
+│  │  FileProcessor │ PDFService │ ContentGenerators │ StateEngine  │ │
+│  └────────────────────────────────────────────────────────────────┘ │
+└───────────────────────────┬─────────────────────────────────────────┘
+                            │
+              ┌─────────────┼─────────────┐
+              ▼             ▼             ▼
+┌─────────────────┐ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐
+│  Go Consent     │ │  PostgreSQL   │ │  LLM Gateway │ │  HashiCorp   │
+│  Service        │ │  Database     │ │  (optional)  │ │  Vault       │
+│  Port 8081      │ │  Port 5432    │ │              │ │  Port 8200   │
+└─────────────────┘ └───────────────┘ └──────────────┘ └──────────────┘
+
+

Komponenten

+

1. Admin Frontend (Next.js Website)

+

Das Admin Frontend ist eine vollständige Next.js 15 Anwendung für Developer und Administratoren:

+

Technologie: Next.js 15, React 18, TypeScript, Tailwind CSS

+

Container: breakpilot-pwa-website auf Port 3000

+

Verzeichnis: /website

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModulRouteBeschreibung
Dashboard/adminÜbersicht & Statistiken
GPU Infrastruktur/admin/gpuvast.ai GPU Management
Consent Verwaltung/admin/consentRechtliche Dokumente & Versionen
Datenschutzanfragen/admin/dsrDSGVO Art. 15-21 Anfragen
DSMS/admin/dsmsDatenschutz-Management-System
Education Search/admin/edu-searchBildungsquellen & Crawler
Personensuche/admin/staff-searchUni-Mitarbeiter & Publikationen
Uni-Crawler/admin/uni-crawlerUniversitäts-Crawling Orchestrator
LLM Vergleich/admin/llm-compareKI-Provider Vergleich
PCA Platform/admin/pca-platformBot-Erkennung & Monetarisierung
Production Backlog/admin/backlogGo-Live Checkliste
Developer Docs/admin/docsAPI & Architektur Dokumentation
Kommunikation/admin/communicationMatrix & Jitsi Monitoring
Security/admin/securityDevSecOps Dashboard, Scans, Findings
SBOM/admin/sbomSoftware Bill of Materials
+

2. Lehrer Frontend (Studio UI)

+

Das Lehrer Frontend ist ein Single-Page-Application-ähnliches System für Lehrkräfte, das in Python-Modulen organisiert ist:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModulDateiBeschreibung
Basefrontend/modules/base.pyTopBar, Sidebar, Theme, Login
Dashboardfrontend/modules/dashboard.pyÜbersichtsseite
Worksheetsfrontend/modules/worksheets.pyLerneinheiten-Generator
Correctionfrontend/modules/correction.pyOCR-Klausurkorrektur
Lettersfrontend/modules/letters.pyElternkommunikation
Companionfrontend/modules/companion.pyBegleiter-Modus mit State Engine
Schoolfrontend/modules/school.pySchulverwaltung
Gradebookfrontend/modules/gradebook.pyNotenbuch
ContentCreatorfrontend/modules/content_creator.pyH5P Content Creator
ContentFeedfrontend/modules/content_feed.pyContent Discovery
Messengerfrontend/modules/messenger.pyMatrix Messenger
Jitsifrontend/modules/jitsi.pyVideokonferenzen
KlausurKorrekturfrontend/modules/klausur_korrektur.pyAbitur-Klausurkorrektur (15-Punkte-System)
AbiturDocsAdminfrontend/modules/abitur_docs_admin.pyAdmin für Abitur-Dokumente (NiBiS)
+

Jedes Modul exportiert: +- get_css() - CSS-Styles +- get_html() - HTML-Template +- get_js() - JavaScript-Logik

+

3. Python Backend (FastAPI)

+

API-Router

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RouterPräfixBeschreibung
worksheets_api/api/worksheetsContent-Generatoren (MC, Cloze, Mindmap, Quiz)
correction_api/api/correctionsOCR-Pipeline für Klausurkorrektur
letters_api/api/lettersElternbriefe mit GFK-Integration
state_engine_api/api/stateBegleiter-Modus Phasen & Vorschläge
school_api/api/schoolSchulverwaltung (Proxy zu school-service)
certificates_api/api/certificatesZeugniserstellung
messenger_api/api/messengerMatrix Messenger Integration
jitsi_api/api/jitsiJitsi Meeting-Einladungen
consent_api/api/consentDSGVO Consent-Verwaltung
gdpr_api/api/gdprGDPR-Export
klausur_korrektur_api/api/klausur-korrekturAbitur-Klausuren (15-Punkte, Gutachten, Fairness)
abitur_docs_api/api/abitur-docsNiBiS-Dokumentenverwaltung für RAG
+

Services

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceDateiBeschreibung
FileProcessorservices/file_processor.pyOCR mit PaddleOCR
PDFServiceservices/pdf_service.pyPDF-Generierung
ContentGeneratorsservices/content_generators/MC, Cloze, Mindmap, Quiz
StateEnginestate_engine/Phasen-Management & Antizipation
+

4. Klausur-Korrektur System (Abitur)

+

Das Klausur-Korrektur-System implementiert die vollständige Abitur-Bewertungspipeline:

+
┌─────────────────────────────────────────────────────────────────────┐
+│                    Klausur-Korrektur Modul                          │
+│                                                                     │
+│  ┌─────────────┐    ┌──────────────────┐    ┌─────────────────┐    │
+│  │ Modus-Wahl  │───►│ Text-Quellen &   │───►│ Erwartungs-     │    │
+│  │ LandesAbi/  │    │ Rights-Gate      │    │ horizont        │    │
+│  │ Vorabitur   │    └──────────────────┘    └─────────────────┘    │
+│  └─────────────┘                                      │            │
+│                                                       ▼            │
+│  ┌─────────────────────────────────────────────────────────────┐   │
+│  │                   Schülerarbeiten-Pipeline                   │   │
+│  │  Upload → OCR → KI-Bewertung → Gutachten → 15-Punkte-Note   │   │
+│  └─────────────────────────────────────────────────────────────┘   │
+│                                                       │            │
+│                                                       ▼            │
+│  ┌────────────────────┐    ┌──────────────────────────────────┐   │
+│  │ Erst-/Zweitprüfer  │───►│ Fairness-Analyse & PDF-Export   │   │
+│  └────────────────────┘    └──────────────────────────────────┘   │
+└─────────────────────────────────────────────────────────────────────┘
+
+

15-Punkte-Notensystem

+

Das System verwendet den deutschen Abitur-Notenschlüssel:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PunkteProzentNote
15-1395-85%1+/1/1-
12-1080-70%2+/2/2-
9-765-55%3+/3/3-
6-450-40%4+/4/4-
3-133-20%5+/5/5-
0<20%6
+

Bewertungskriterien

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumGewichtBeschreibung
Rechtschreibung15%Orthografie
Grammatik15%Grammatik & Syntax
Inhalt40%Inhaltliche Qualität (höchste Gewichtung)
Struktur15%Aufbau & Gliederung
Stil15%Ausdruck & Stil
+ +

Verwaltet DSGVO-Einwilligungen:

+
consent-service/
+├── cmd/server/        # Main entry point
+├── internal/
+│   ├── handlers/      # HTTP Handler
+│   ├── services/      # Business Logic
+│   ├── models/        # Data Models
+│   └── middleware/    # Auth Middleware
+└── migrations/        # SQL Migrations
+
+

6. LLM Gateway (Optional)

+

Wenn LLM_GATEWAY_ENABLED=true:

+
llm_gateway/
+├── routes/
+│   ├── chat.py            # Chat-Completion API
+│   ├── communication.py   # GFK-Validierung
+│   ├── edu_search_seeds.py # Bildungssuche
+│   └── legal_crawler.py   # Schulgesetz-Crawler
+└── services/
+    └── communication_service.py
+
+

Datenfluss

+

Worksheet-Generierung

+
User Input → Frontend (worksheets.py)
+    ↓
+POST /api/worksheets/generate/multiple-choice
+    ↓
+worksheets_api.py → MCGenerator (services/content_generators/)
+    ↓
+Optional: LLM für erweiterte Generierung
+    ↓
+Response: WorksheetContent → Frontend rendert Ergebnis
+
+

Klausurkorrektur

+
File Upload → Frontend (correction.py)
+    ↓
+POST /api/corrections/ (erstellen)
+POST /api/corrections/{id}/upload (Datei)
+    ↓
+Background Task: OCR via FileProcessor
+    ↓
+Poll GET /api/corrections/{id} bis status="ocr_complete"
+    ↓
+POST /api/corrections/{id}/analyze
+    ↓
+Review Interface → PUT /api/corrections/{id} (Anpassungen)
+    ↓
+GET /api/corrections/{id}/export-pdf
+
+

Sicherheit

+

Authentifizierung & Autorisierung

+

BreakPilot verwendet einen Hybrid-Ansatz:

+ + + + + + + + + + + + + + + + + + + + +
SchichtKomponenteBeschreibung
AuthentifizierungKeycloak (Prod) / Lokales JWT (Dev)Token-Validierung via JWKS oder HS256
Autorisierungrbac.py (Eigenentwicklung)Domaenenspezifische Berechtigungen
+

Siehe: Auth-System

+

Basis-Rollen

+ + + + + + + + + + + + + + + + + + + + + + + + + +
RolleBeschreibung
userNormaler Benutzer
teacher / lehrerLehrkraft
adminAdministrator
data_protection_officerDatenschutzbeauftragter
+

Erweiterte Rollen (rbac.py)

+

15+ domaenenspezifische Rollen fuer Klausurkorrektur und Zeugnisse: +- erstkorrektor, zweitkorrektor, drittkorrektor +- klassenlehrer, fachlehrer, fachvorsitz +- schulleitung, zeugnisbeauftragter, sekretariat

+

Sicherheitsfeatures

+
    +
  • JWT-basierte Authentifizierung (RS256/HS256)
  • +
  • CORS konfiguriert für Frontend-Zugriff
  • +
  • DSGVO-konformes Consent-Management
  • +
  • HashiCorp Vault fuer Secrets-Management (keine hardcodierten Secrets)
  • +
  • Bundesland-spezifische Policy-Sets
  • +
  • DevSecOps Pipeline mit automatisierten Security-Scans (SAST, SCA, Secrets Detection)
  • +
+

Siehe: +- Secrets Management +- DevSecOps

+

Deployment

+
services:
+  backend:
+    build: ./backend
+    ports: ["8000:8000"]
+    environment:
+      - DATABASE_URL=postgresql://...
+      - LLM_GATEWAY_ENABLED=false
+
+  consent-service:
+    build: ./consent-service
+    ports: ["8081:8081"]
+
+  postgres:
+    image: postgres:15
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+
+

Erweiterung

+

Neues Frontend-Modul hinzufügen:

+
    +
  1. Modul erstellen: frontend/modules/new_module.py
  2. +
  3. Klasse mit get_css(), get_html(), get_js() implementieren
  4. +
  5. In frontend/modules/__init__.py importieren und exportieren
  6. +
  7. Optional: Zugehörige API in new_module_api.py erstellen
  8. +
  9. In main.py Router registrieren
  10. +
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/architecture/zeugnis-system/index.html b/docs-site/architecture/zeugnis-system/index.html new file mode 100644 index 0000000..5b67bae --- /dev/null +++ b/docs-site/architecture/zeugnis-system/index.html @@ -0,0 +1,2660 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Zeugnis-System - Breakpilot Dokumentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + +

Zeugnis-System - Architecture Documentation

+

Overview

+

The Zeugnis (Certificate) System enables schools to generate official school certificates with grades, attendance data, and remarks. It extends the existing School-Service with comprehensive grade management and certificate generation workflows.

+

Architecture Diagram

+
                                         ┌─────────────────────────────────────┐
+                                         │      Python Backend (Port 8000)     │
+                                         │   backend/frontend/modules/school.py │
+                                         │                                     │
+                                         │  ┌─────────────────────────────────┐ │
+                                         │  │   panel-school-certificates     │ │
+                                         │  │   - Klassenauswahl              │ │
+                                         │  │   - Notenspiegel                │ │
+                                         │  │   - Zeugnis-Wizard (5 Steps)    │ │
+                                         │  │   - Workflow-Status             │ │
+                                         │  └─────────────────────────────────┘ │
+                                         └──────────────────┬──────────────────┘
+                                                            │
+                                                            ▼
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│                              School-Service (Go, Port 8084)                              │
+├─────────────────────────────────────────────────────────────────────────────────────────┤
+│                                                                                         │
+│  ┌─────────────────────┐  ┌─────────────────────┐  ┌─────────────────────────────────┐  │
+│  │  Grade Handlers     │  │ Statistics Handlers │  │    Certificate Handlers         │  │
+│  │                     │  │                     │  │                                 │  │
+│  │ GetClassGrades      │  │ GetClassStatistics  │  │ GetCertificateTemplates         │  │
+│  │ GetStudentGrades    │  │ GetSubjectStatistics│  │ GetClassCertificates            │  │
+│  │ UpdateOralGrade     │  │ GetStudentStatistics│  │ GenerateCertificate             │  │
+│  │ CalculateFinalGrades│  │ GetNotenspiegel     │  │ BulkGenerateCertificates        │  │
+│  │ LockFinalGrade      │  │                     │  │ FinalizeCertificate             │  │
+│  │ UpdateGradeWeights  │  │                     │  │ GetCertificatePDF               │  │
+│  └─────────────────────┘  └─────────────────────┘  └─────────────────────────────────┘  │
+│                                                                                         │
+└─────────────────────────────────────────────────────────────────────────────────────────┘
+                                                            │
+                                                            ▼
+                                         ┌─────────────────────────────────────┐
+                                         │         PostgreSQL Database          │
+                                         │                                     │
+                                         │  Tables:                            │
+                                         │  - grade_overview                   │
+                                         │  - exam_results                     │
+                                         │  - students                         │
+                                         │  - classes                          │
+                                         │  - subjects                         │
+                                         │  - certificates                     │
+                                         │  - attendance                       │
+                                         └─────────────────────────────────────┘
+
+

Zeugnis Workflow (Role Chain)

+

The certificate workflow follows a strict approval chain from subject teachers to school principal:

+
┌──────────────────┐    ┌──────────────────┐    ┌────────────────────────┐    ┌────────────────────┐    ┌──────────────────┐
+│   FACHLEHRER     │───▶│  KLASSENLEHRER   │───▶│  ZEUGNISBEAUFTRAGTER   │───▶│    SCHULLEITUNG    │───▶│   SEKRETARIAT    │
+│   (Subject       │    │  (Class          │    │  (Certificate          │    │    (Principal)     │    │   (Secretary)    │
+│   Teacher)       │    │   Teacher)       │    │   Coordinator)         │    │                    │    │                  │
+└──────────────────┘    └──────────────────┘    └────────────────────────┘    └────────────────────┘    └──────────────────┘
+        │                       │                         │                           │                        │
+        ▼                       ▼                         ▼                           ▼                        ▼
+   Grades Entry            Approve               Quality Check              Sign-off & Lock            Print & Archive
+   (Oral/Written)          Grades                 & Review
+
+

Workflow States

+
┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
+│   DRAFT     │────▶│  SUBMITTED  │────▶│  REVIEWED   │────▶│   SIGNED    │────▶│   PRINTED   │
+│   (Entwurf) │     │ (Eingereicht)│    │ (Geprueft)  │     │(Unterzeichnet)    │ (Gedruckt)  │
+└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘
+      │                   │                   │                   │
+      ▼                   ▼                   ▼                   ▼
+  Fachlehrer        Klassenlehrer      Zeugnisbeauftragter   Schulleitung
+
+

RBAC Integration

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleGermanDescription
FACHLEHRERFachlehrerSubject teacher - enters grades
KLASSENLEHRERKlassenlehrerClass teacher - approves class grades
ZEUGNISBEAUFTRAGTERZeugnisbeauftragterCertificate coordinator - quality control
SCHULLEITUNGSchulleitungPrincipal - final sign-off
SEKRETARIATSekretariatSecretary - printing & archiving
+

Certificate Resource Types

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ResourceTypeDescription
ZEUGNISFinal certificate document
ZEUGNIS_VORLAGECertificate template (per Bundesland)
ZEUGNIS_ENTWURFDraft certificate (before approval)
FACHNOTESubject grade
KOPFNOTEHead grade (Arbeits-/Sozialverhalten)
BEMERKUNGCertificate remarks
STATISTIKClass/subject statistics
NOTENSPIEGELGrade distribution chart
+

German Grading System

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GradeMeaningPoints
1sehr gut (excellent)15-13
2gut (good)12-10
3befriedigend (satisfactory)9-7
4ausreichend (adequate)6-4
5mangelhaft (poor)3-1
6ungenuegend (inadequate)0
+

Grade Calculation

+
Final Grade = (Written Weight * Written Avg) + (Oral Weight * Oral Avg)
+
+Default weights:
+- Written (Klassenarbeiten): 50%
+- Oral (muendliche Note): 50%
+
+Customizable per subject/student via UpdateGradeWeights endpoint.
+
+

API Routes (School-Service)

+

Grade Management

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/api/v1/school/grades/:classIdGet class grades
GET/api/v1/school/grades/student/:studentIdGet student grades
PUT/api/v1/school/grades/:studentId/:subjectId/oralUpdate oral grade
POST/api/v1/school/grades/calculateCalculate final grades
PUT/api/v1/school/grades/:studentId/:subjectId/lockLock final grade
+

Statistics

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/api/v1/school/statistics/:classIdClass statistics
GET/api/v1/school/statistics/:classId/subject/:subjectIdSubject statistics
GET/api/v1/school/statistics/student/:studentIdStudent statistics
GET/api/v1/school/statistics/:classId/notenspiegelGrade distribution
+

Certificates

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/api/v1/school/certificates/templatesList templates
GET/api/v1/school/certificates/class/:classIdClass certificates
POST/api/v1/school/certificates/generateGenerate single
POST/api/v1/school/certificates/generate-bulkGenerate bulk
GET/api/v1/school/certificates/detail/:id/pdfDownload PDF
+

Security Considerations

+
    +
  1. RBAC Enforcement: All certificate operations check user role permissions
  2. +
  3. Tenant Isolation: Teachers only see their own classes/students
  4. +
  5. Audit Trail: All grade changes and approvals logged
  6. +
  7. Lock Mechanism: Finalized certificates cannot be modified
  8. +
  9. Workflow Enforcement: Cannot skip approval steps
  10. +
+ + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/assets/images/favicon.png b/docs-site/assets/images/favicon.png new file mode 100644 index 0000000..1cf13b9 Binary files /dev/null and b/docs-site/assets/images/favicon.png differ diff --git a/docs-site/assets/javascripts/bundle.79ae519e.min.js b/docs-site/assets/javascripts/bundle.79ae519e.min.js new file mode 100644 index 0000000..3df3e5e --- /dev/null +++ b/docs-site/assets/javascripts/bundle.79ae519e.min.js @@ -0,0 +1,16 @@ +"use strict";(()=>{var Zi=Object.create;var _r=Object.defineProperty;var ea=Object.getOwnPropertyDescriptor;var ta=Object.getOwnPropertyNames,Bt=Object.getOwnPropertySymbols,ra=Object.getPrototypeOf,Ar=Object.prototype.hasOwnProperty,bo=Object.prototype.propertyIsEnumerable;var ho=(e,t,r)=>t in e?_r(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))Ar.call(t,r)&&ho(e,r,t[r]);if(Bt)for(var r of Bt(t))bo.call(t,r)&&ho(e,r,t[r]);return e};var vo=(e,t)=>{var r={};for(var o in e)Ar.call(e,o)&&t.indexOf(o)<0&&(r[o]=e[o]);if(e!=null&&Bt)for(var o of Bt(e))t.indexOf(o)<0&&bo.call(e,o)&&(r[o]=e[o]);return r};var Cr=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var oa=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of ta(t))!Ar.call(e,n)&&n!==r&&_r(e,n,{get:()=>t[n],enumerable:!(o=ea(t,n))||o.enumerable});return e};var $t=(e,t,r)=>(r=e!=null?Zi(ra(e)):{},oa(t||!e||!e.__esModule?_r(r,"default",{value:e,enumerable:!0}):r,e));var go=(e,t,r)=>new Promise((o,n)=>{var i=c=>{try{a(r.next(c))}catch(p){n(p)}},s=c=>{try{a(r.throw(c))}catch(p){n(p)}},a=c=>c.done?o(c.value):Promise.resolve(c.value).then(i,s);a((r=r.apply(e,t)).next())});var xo=Cr((kr,yo)=>{(function(e,t){typeof kr=="object"&&typeof yo!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(kr,(function(){"use strict";function e(r){var o=!0,n=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(k){return!!(k&&k!==document&&k.nodeName!=="HTML"&&k.nodeName!=="BODY"&&"classList"in k&&"contains"in k.classList)}function c(k){var ut=k.type,je=k.tagName;return!!(je==="INPUT"&&s[ut]&&!k.readOnly||je==="TEXTAREA"&&!k.readOnly||k.isContentEditable)}function p(k){k.classList.contains("focus-visible")||(k.classList.add("focus-visible"),k.setAttribute("data-focus-visible-added",""))}function l(k){k.hasAttribute("data-focus-visible-added")&&(k.classList.remove("focus-visible"),k.removeAttribute("data-focus-visible-added"))}function f(k){k.metaKey||k.altKey||k.ctrlKey||(a(r.activeElement)&&p(r.activeElement),o=!0)}function u(k){o=!1}function d(k){a(k.target)&&(o||c(k.target))&&p(k.target)}function v(k){a(k.target)&&(k.target.classList.contains("focus-visible")||k.target.hasAttribute("data-focus-visible-added"))&&(n=!0,window.clearTimeout(i),i=window.setTimeout(function(){n=!1},100),l(k.target))}function S(k){document.visibilityState==="hidden"&&(n&&(o=!0),X())}function X(){document.addEventListener("mousemove",ee),document.addEventListener("mousedown",ee),document.addEventListener("mouseup",ee),document.addEventListener("pointermove",ee),document.addEventListener("pointerdown",ee),document.addEventListener("pointerup",ee),document.addEventListener("touchmove",ee),document.addEventListener("touchstart",ee),document.addEventListener("touchend",ee)}function re(){document.removeEventListener("mousemove",ee),document.removeEventListener("mousedown",ee),document.removeEventListener("mouseup",ee),document.removeEventListener("pointermove",ee),document.removeEventListener("pointerdown",ee),document.removeEventListener("pointerup",ee),document.removeEventListener("touchmove",ee),document.removeEventListener("touchstart",ee),document.removeEventListener("touchend",ee)}function ee(k){k.target.nodeName&&k.target.nodeName.toLowerCase()==="html"||(o=!1,re())}document.addEventListener("keydown",f,!0),document.addEventListener("mousedown",u,!0),document.addEventListener("pointerdown",u,!0),document.addEventListener("touchstart",u,!0),document.addEventListener("visibilitychange",S,!0),X(),r.addEventListener("focus",d,!0),r.addEventListener("blur",v,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)}))});var ro=Cr((jy,Rn)=>{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var qa=/["'&<>]/;Rn.exports=Ka;function Ka(e){var t=""+e,r=qa.exec(t);if(!r)return t;var o,n="",i=0,s=0;for(i=r.index;i{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Nt=="object"&&typeof io=="object"?io.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Nt=="object"?Nt.ClipboardJS=r():t.ClipboardJS=r()})(Nt,function(){return(function(){var e={686:(function(o,n,i){"use strict";i.d(n,{default:function(){return Xi}});var s=i(279),a=i.n(s),c=i(370),p=i.n(c),l=i(817),f=i.n(l);function u(q){try{return document.execCommand(q)}catch(C){return!1}}var d=function(C){var _=f()(C);return u("cut"),_},v=d;function S(q){var C=document.documentElement.getAttribute("dir")==="rtl",_=document.createElement("textarea");_.style.fontSize="12pt",_.style.border="0",_.style.padding="0",_.style.margin="0",_.style.position="absolute",_.style[C?"right":"left"]="-9999px";var D=window.pageYOffset||document.documentElement.scrollTop;return _.style.top="".concat(D,"px"),_.setAttribute("readonly",""),_.value=q,_}var X=function(C,_){var D=S(C);_.container.appendChild(D);var N=f()(D);return u("copy"),D.remove(),N},re=function(C){var _=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},D="";return typeof C=="string"?D=X(C,_):C instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(C==null?void 0:C.type)?D=X(C.value,_):(D=f()(C),u("copy")),D},ee=re;function k(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?k=function(_){return typeof _}:k=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},k(q)}var ut=function(){var C=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},_=C.action,D=_===void 0?"copy":_,N=C.container,G=C.target,We=C.text;if(D!=="copy"&&D!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(G!==void 0)if(G&&k(G)==="object"&&G.nodeType===1){if(D==="copy"&&G.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(D==="cut"&&(G.hasAttribute("readonly")||G.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(We)return ee(We,{container:N});if(G)return D==="cut"?v(G):ee(G,{container:N})},je=ut;function R(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?R=function(_){return typeof _}:R=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},R(q)}function se(q,C){if(!(q instanceof C))throw new TypeError("Cannot call a class as a function")}function ce(q,C){for(var _=0;_0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof N.action=="function"?N.action:this.defaultAction,this.target=typeof N.target=="function"?N.target:this.defaultTarget,this.text=typeof N.text=="function"?N.text:this.defaultText,this.container=R(N.container)==="object"?N.container:document.body}},{key:"listenClick",value:function(N){var G=this;this.listener=p()(N,"click",function(We){return G.onClick(We)})}},{key:"onClick",value:function(N){var G=N.delegateTarget||N.currentTarget,We=this.action(G)||"copy",Yt=je({action:We,container:this.container,target:this.target(G),text:this.text(G)});this.emit(Yt?"success":"error",{action:We,text:Yt,trigger:G,clearSelection:function(){G&&G.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(N){return Mr("action",N)}},{key:"defaultTarget",value:function(N){var G=Mr("target",N);if(G)return document.querySelector(G)}},{key:"defaultText",value:function(N){return Mr("text",N)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(N){var G=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return ee(N,G)}},{key:"cut",value:function(N){return v(N)}},{key:"isSupported",value:function(){var N=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],G=typeof N=="string"?[N]:N,We=!!document.queryCommandSupported;return G.forEach(function(Yt){We=We&&!!document.queryCommandSupported(Yt)}),We}}]),_})(a()),Xi=Ji}),828:(function(o){var n=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,c){for(;a&&a.nodeType!==n;){if(typeof a.matches=="function"&&a.matches(c))return a;a=a.parentNode}}o.exports=s}),438:(function(o,n,i){var s=i(828);function a(l,f,u,d,v){var S=p.apply(this,arguments);return l.addEventListener(u,S,v),{destroy:function(){l.removeEventListener(u,S,v)}}}function c(l,f,u,d,v){return typeof l.addEventListener=="function"?a.apply(null,arguments):typeof u=="function"?a.bind(null,document).apply(null,arguments):(typeof l=="string"&&(l=document.querySelectorAll(l)),Array.prototype.map.call(l,function(S){return a(S,f,u,d,v)}))}function p(l,f,u,d){return function(v){v.delegateTarget=s(v.target,f),v.delegateTarget&&d.call(l,v)}}o.exports=c}),879:(function(o,n){n.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},n.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||n.node(i[0]))},n.string=function(i){return typeof i=="string"||i instanceof String},n.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}}),370:(function(o,n,i){var s=i(879),a=i(438);function c(u,d,v){if(!u&&!d&&!v)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(v))throw new TypeError("Third argument must be a Function");if(s.node(u))return p(u,d,v);if(s.nodeList(u))return l(u,d,v);if(s.string(u))return f(u,d,v);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function p(u,d,v){return u.addEventListener(d,v),{destroy:function(){u.removeEventListener(d,v)}}}function l(u,d,v){return Array.prototype.forEach.call(u,function(S){S.addEventListener(d,v)}),{destroy:function(){Array.prototype.forEach.call(u,function(S){S.removeEventListener(d,v)})}}}function f(u,d,v){return a(document.body,u,d,v)}o.exports=c}),817:(function(o){function n(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var c=window.getSelection(),p=document.createRange();p.selectNodeContents(i),c.removeAllRanges(),c.addRange(p),s=c.toString()}return s}o.exports=n}),279:(function(o){function n(){}n.prototype={on:function(i,s,a){var c=this.e||(this.e={});return(c[i]||(c[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var c=this;function p(){c.off(i,p),s.apply(a,arguments)}return p._=s,this.on(i,p,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),c=0,p=a.length;for(c;c0&&i[i.length-1])&&(p[0]===6||p[0]===2)){r=0;continue}if(p[0]===3&&(!i||p[1]>i[0]&&p[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function K(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],s;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(a){s={error:a}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(s)throw s.error}}return i}function B(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o1||c(d,S)})},v&&(n[d]=v(n[d])))}function c(d,v){try{p(o[d](v))}catch(S){u(i[0][3],S)}}function p(d){d.value instanceof dt?Promise.resolve(d.value.v).then(l,f):u(i[0][2],d)}function l(d){c("next",d)}function f(d){c("throw",d)}function u(d,v){d(v),i.shift(),i.length&&c(i[0][0],i[0][1])}}function To(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Oe=="function"?Oe(e):e[Symbol.iterator](),r={},o("next"),o("throw"),o("return"),r[Symbol.asyncIterator]=function(){return this},r);function o(i){r[i]=e[i]&&function(s){return new Promise(function(a,c){s=e[i](s),n(a,c,s.done,s.value)})}}function n(i,s,a,c){Promise.resolve(c).then(function(p){i({value:p,done:a})},s)}}function I(e){return typeof e=="function"}function yt(e){var t=function(o){Error.call(o),o.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var Jt=yt(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(o,n){return n+1+") "+o.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function Ze(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var qe=(function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,o,n,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Oe(s),c=a.next();!c.done;c=a.next()){var p=c.value;p.remove(this)}}catch(S){t={error:S}}finally{try{c&&!c.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var l=this.initialTeardown;if(I(l))try{l()}catch(S){i=S instanceof Jt?S.errors:[S]}var f=this._finalizers;if(f){this._finalizers=null;try{for(var u=Oe(f),d=u.next();!d.done;d=u.next()){var v=d.value;try{So(v)}catch(S){i=i!=null?i:[],S instanceof Jt?i=B(B([],K(i)),K(S.errors)):i.push(S)}}}catch(S){o={error:S}}finally{try{d&&!d.done&&(n=u.return)&&n.call(u)}finally{if(o)throw o.error}}}if(i)throw new Jt(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)So(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ze(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ze(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e})();var $r=qe.EMPTY;function Xt(e){return e instanceof qe||e&&"closed"in e&&I(e.remove)&&I(e.add)&&I(e.unsubscribe)}function So(e){I(e)?e():e.unsubscribe()}var De={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var xt={setTimeout:function(e,t){for(var r=[],o=2;o0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var o=this,n=this,i=n.hasError,s=n.isStopped,a=n.observers;return i||s?$r:(this.currentObservers=null,a.push(r),new qe(function(){o.currentObservers=null,Ze(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var o=this,n=o.hasError,i=o.thrownError,s=o.isStopped;n?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,o){return new Ho(r,o)},t})(F);var Ho=(function(e){ie(t,e);function t(r,o){var n=e.call(this)||this;return n.destination=r,n.source=o,n}return t.prototype.next=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.next)===null||n===void 0||n.call(o,r)},t.prototype.error=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.error)===null||n===void 0||n.call(o,r)},t.prototype.complete=function(){var r,o;(o=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||o===void 0||o.call(r)},t.prototype._subscribe=function(r){var o,n;return(n=(o=this.source)===null||o===void 0?void 0:o.subscribe(r))!==null&&n!==void 0?n:$r},t})(T);var jr=(function(e){ie(t,e);function t(r){var o=e.call(this)||this;return o._value=r,o}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var o=e.prototype._subscribe.call(this,r);return!o.closed&&r.next(this._value),o},t.prototype.getValue=function(){var r=this,o=r.hasError,n=r.thrownError,i=r._value;if(o)throw n;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t})(T);var Rt={now:function(){return(Rt.delegate||Date).now()},delegate:void 0};var It=(function(e){ie(t,e);function t(r,o,n){r===void 0&&(r=1/0),o===void 0&&(o=1/0),n===void 0&&(n=Rt);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=o,i._timestampProvider=n,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=o===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,o),i}return t.prototype.next=function(r){var o=this,n=o.isStopped,i=o._buffer,s=o._infiniteTimeWindow,a=o._timestampProvider,c=o._windowTime;n||(i.push(r),!s&&i.push(a.now()+c)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var o=this._innerSubscribe(r),n=this,i=n._infiniteTimeWindow,s=n._buffer,a=s.slice(),c=0;c0?e.prototype.schedule.call(this,r,o):(this.delay=o,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,o){return o>0||this.closed?e.prototype.execute.call(this,r,o):this._execute(r,o)},t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!=null&&n>0||n==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.flush(this),0)},t})(St);var Ro=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t})(Ot);var Dr=new Ro(Po);var Io=(function(e){ie(t,e);function t(r,o){var n=e.call(this,r,o)||this;return n.scheduler=r,n.work=o,n}return t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!==null&&n>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.actions.push(this),r._scheduled||(r._scheduled=Tt.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,o,n){var i;if(n===void 0&&(n=0),n!=null?n>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,o,n);var s=r.actions;o!=null&&o===r._scheduled&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==o&&(Tt.cancelAnimationFrame(o),r._scheduled=void 0)},t})(St);var Fo=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var o;r?o=r.id:(o=this._scheduled,this._scheduled=void 0);var n=this.actions,i;r=r||n.shift();do if(i=r.execute(r.state,r.delay))break;while((r=n[0])&&r.id===o&&n.shift());if(this._active=!1,i){for(;(r=n[0])&&r.id===o&&n.shift();)r.unsubscribe();throw i}},t})(Ot);var ye=new Fo(Io);var y=new F(function(e){return e.complete()});function tr(e){return e&&I(e.schedule)}function Vr(e){return e[e.length-1]}function pt(e){return I(Vr(e))?e.pop():void 0}function Fe(e){return tr(Vr(e))?e.pop():void 0}function rr(e,t){return typeof Vr(e)=="number"?e.pop():t}var Lt=(function(e){return e&&typeof e.length=="number"&&typeof e!="function"});function or(e){return I(e==null?void 0:e.then)}function nr(e){return I(e[wt])}function ir(e){return Symbol.asyncIterator&&I(e==null?void 0:e[Symbol.asyncIterator])}function ar(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function fa(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var sr=fa();function cr(e){return I(e==null?void 0:e[sr])}function pr(e){return wo(this,arguments,function(){var r,o,n,i;return Gt(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,dt(r.read())];case 3:return o=s.sent(),n=o.value,i=o.done,i?[4,dt(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,dt(n)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function lr(e){return I(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(nr(e))return ua(e);if(Lt(e))return da(e);if(or(e))return ha(e);if(ir(e))return jo(e);if(cr(e))return ba(e);if(lr(e))return va(e)}throw ar(e)}function ua(e){return new F(function(t){var r=e[wt]();if(I(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function da(e){return new F(function(t){for(var r=0;r=2;return function(o){return o.pipe(e?g(function(n,i){return e(n,i,o)}):be,Ee(1),r?Qe(t):tn(function(){return new fr}))}}function Yr(e){return e<=0?function(){return y}:E(function(t,r){var o=[];t.subscribe(w(r,function(n){o.push(n),e=2,!0))}function le(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new T}:t,o=e.resetOnError,n=o===void 0?!0:o,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,c=a===void 0?!0:a;return function(p){var l,f,u,d=0,v=!1,S=!1,X=function(){f==null||f.unsubscribe(),f=void 0},re=function(){X(),l=u=void 0,v=S=!1},ee=function(){var k=l;re(),k==null||k.unsubscribe()};return E(function(k,ut){d++,!S&&!v&&X();var je=u=u!=null?u:r();ut.add(function(){d--,d===0&&!S&&!v&&(f=Br(ee,c))}),je.subscribe(ut),!l&&d>0&&(l=new bt({next:function(R){return je.next(R)},error:function(R){S=!0,X(),f=Br(re,n,R),je.error(R)},complete:function(){v=!0,X(),f=Br(re,s),je.complete()}}),U(k).subscribe(l))})(p)}}function Br(e,t){for(var r=[],o=2;oe.next(document)),e}function M(e,t=document){return Array.from(t.querySelectorAll(e))}function j(e,t=document){let r=ue(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ue(e,t=document){return t.querySelector(e)||void 0}function Ne(){var e,t,r,o;return(o=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?o:void 0}var Ra=L(h(document.body,"focusin"),h(document.body,"focusout")).pipe(Ae(1),Q(void 0),m(()=>Ne()||document.body),Z(1));function Ye(e){return Ra.pipe(m(t=>e.contains(t)),Y())}function it(e,t){return H(()=>L(h(e,"mouseenter").pipe(m(()=>!0)),h(e,"mouseleave").pipe(m(()=>!1))).pipe(t?jt(r=>He(+!r*t)):be,Q(e.matches(":hover"))))}function sn(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)sn(e,r)}function x(e,t,...r){let o=document.createElement(e);if(t)for(let n of Object.keys(t))typeof t[n]!="undefined"&&(typeof t[n]!="boolean"?o.setAttribute(n,t[n]):o.setAttribute(n,""));for(let n of r)sn(o,n);return o}function br(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function _t(e){let t=x("script",{src:e});return H(()=>(document.head.appendChild(t),L(h(t,"load"),h(t,"error").pipe(b(()=>Nr(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(m(()=>{}),A(()=>document.head.removeChild(t)),Ee(1))))}var cn=new T,Ia=H(()=>typeof ResizeObserver=="undefined"?_t("https://unpkg.com/resize-observer-polyfill"):$(void 0)).pipe(m(()=>new ResizeObserver(e=>e.forEach(t=>cn.next(t)))),b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function de(e){return{width:e.offsetWidth,height:e.offsetHeight}}function Le(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return Ia.pipe(O(r=>r.observe(t)),b(r=>cn.pipe(g(o=>o.target===t),A(()=>r.unobserve(t)))),m(()=>de(e)),Q(de(e)))}function At(e){return{width:e.scrollWidth,height:e.scrollHeight}}function vr(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function pn(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function Be(e){return{x:e.offsetLeft,y:e.offsetTop}}function ln(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function mn(e){return L(h(window,"load"),h(window,"resize")).pipe($e(0,ye),m(()=>Be(e)),Q(Be(e)))}function gr(e){return{x:e.scrollLeft,y:e.scrollTop}}function Ge(e){return L(h(e,"scroll"),h(window,"scroll"),h(window,"resize")).pipe($e(0,ye),m(()=>gr(e)),Q(gr(e)))}var fn=new T,Fa=H(()=>$(new IntersectionObserver(e=>{for(let t of e)fn.next(t)},{threshold:0}))).pipe(b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function mt(e){return Fa.pipe(O(t=>t.observe(e)),b(t=>fn.pipe(g(({target:r})=>r===e),A(()=>t.unobserve(e)),m(({isIntersecting:r})=>r))))}function un(e,t=16){return Ge(e).pipe(m(({y:r})=>{let o=de(e),n=At(e);return r>=n.height-o.height-t}),Y())}var yr={drawer:j("[data-md-toggle=drawer]"),search:j("[data-md-toggle=search]")};function dn(e){return yr[e].checked}function at(e,t){yr[e].checked!==t&&yr[e].click()}function Je(e){let t=yr[e];return h(t,"change").pipe(m(()=>t.checked),Q(t.checked))}function ja(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ua(){return L(h(window,"compositionstart").pipe(m(()=>!0)),h(window,"compositionend").pipe(m(()=>!1))).pipe(Q(!1))}function hn(){let e=h(window,"keydown").pipe(g(t=>!(t.metaKey||t.ctrlKey)),m(t=>({mode:dn("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),g(({mode:t,type:r})=>{if(t==="global"){let o=Ne();if(typeof o!="undefined")return!ja(o,r)}return!0}),le());return Ua().pipe(b(t=>t?y:e))}function we(){return new URL(location.href)}function st(e,t=!1){if(V("navigation.instant")&&!t){let r=x("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function bn(){return new T}function vn(){return location.hash.slice(1)}function gn(e){let t=x("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Zr(e){return L(h(window,"hashchange"),e).pipe(m(vn),Q(vn()),g(t=>t.length>0),Z(1))}function yn(e){return Zr(e).pipe(m(t=>ue(`[id="${t}"]`)),g(t=>typeof t!="undefined"))}function Wt(e){let t=matchMedia(e);return ur(r=>t.addListener(()=>r(t.matches))).pipe(Q(t.matches))}function xn(){let e=matchMedia("print");return L(h(window,"beforeprint").pipe(m(()=>!0)),h(window,"afterprint").pipe(m(()=>!1))).pipe(Q(e.matches))}function eo(e,t){return e.pipe(b(r=>r?t():y))}function to(e,t){return new F(r=>{let o=new XMLHttpRequest;return o.open("GET",`${e}`),o.responseType="blob",o.addEventListener("load",()=>{o.status>=200&&o.status<300?(r.next(o.response),r.complete()):r.error(new Error(o.statusText))}),o.addEventListener("error",()=>{r.error(new Error("Network error"))}),o.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(o.addEventListener("progress",n=>{var i;if(n.lengthComputable)t.progress$.next(n.loaded/n.total*100);else{let s=(i=o.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(n.loaded/+s*100)}}),t.progress$.next(5)),o.send(),()=>o.abort()})}function ze(e,t){return to(e,t).pipe(b(r=>r.text()),m(r=>JSON.parse(r)),Z(1))}function xr(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/html")),Z(1))}function En(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/xml")),Z(1))}function wn(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function Tn(){return L(h(window,"scroll",{passive:!0}),h(window,"resize",{passive:!0})).pipe(m(wn),Q(wn()))}function Sn(){return{width:innerWidth,height:innerHeight}}function On(){return h(window,"resize",{passive:!0}).pipe(m(Sn),Q(Sn()))}function Ln(){return z([Tn(),On()]).pipe(m(([e,t])=>({offset:e,size:t})),Z(1))}function Er(e,{viewport$:t,header$:r}){let o=t.pipe(ne("size")),n=z([o,r]).pipe(m(()=>Be(e)));return z([r,t,n]).pipe(m(([{height:i},{offset:s,size:a},{x:c,y:p}])=>({offset:{x:s.x-c,y:s.y-p+i},size:a})))}function Wa(e){return h(e,"message",t=>t.data)}function Da(e){let t=new T;return t.subscribe(r=>e.postMessage(r)),t}function Mn(e,t=new Worker(e)){let r=Wa(t),o=Da(t),n=new T;n.subscribe(o);let i=o.pipe(oe(),ae(!0));return n.pipe(oe(),Ve(r.pipe(W(i))),le())}var Va=j("#__config"),Ct=JSON.parse(Va.textContent);Ct.base=`${new URL(Ct.base,we())}`;function Te(){return Ct}function V(e){return Ct.features.includes(e)}function Me(e,t){return typeof t!="undefined"?Ct.translations[e].replace("#",t.toString()):Ct.translations[e]}function Ce(e,t=document){return j(`[data-md-component=${e}]`,t)}function me(e,t=document){return M(`[data-md-component=${e}]`,t)}function Na(e){let t=j(".md-typeset > :first-child",e);return h(t,"click",{once:!0}).pipe(m(()=>j(".md-typeset",e)),m(r=>({hash:__md_hash(r.innerHTML)})))}function _n(e){if(!V("announce.dismiss")||!e.childElementCount)return y;if(!e.hidden){let t=j(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return H(()=>{let t=new T;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),Na(e).pipe(O(r=>t.next(r)),A(()=>t.complete()),m(r=>P({ref:e},r)))})}function za(e,{target$:t}){return t.pipe(m(r=>({hidden:r!==e})))}function An(e,t){let r=new T;return r.subscribe(({hidden:o})=>{e.hidden=o}),za(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))}function Dt(e,t){return t==="inline"?x("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"})):x("div",{class:"md-tooltip",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"}))}function wr(...e){return x("div",{class:"md-tooltip2",role:"dialog"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function Cn(...e){return x("div",{class:"md-tooltip2",role:"tooltip"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function kn(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("a",{href:r,class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}else return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("span",{class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}function Hn(e){return x("button",{class:"md-code__button",title:Me("clipboard.copy"),"data-clipboard-target":`#${e} > code`,"data-md-type":"copy"})}function $n(){return x("button",{class:"md-code__button",title:"Toggle line selection","data-md-type":"select"})}function Pn(){return x("nav",{class:"md-code__nav"})}var In=$t(ro());function oo(e,t){let r=t&2,o=t&1,n=Object.keys(e.terms).filter(c=>!e.terms[c]).reduce((c,p)=>[...c,x("del",null,(0,In.default)(p))," "],[]).slice(0,-1),i=Te(),s=new URL(e.location,i.base);V("search.highlight")&&s.searchParams.set("h",Object.entries(e.terms).filter(([,c])=>c).reduce((c,[p])=>`${c} ${p}`.trim(),""));let{tags:a}=Te();return x("a",{href:`${s}`,class:"md-search-result__link",tabIndex:-1},x("article",{class:"md-search-result__article md-typeset","data-md-score":e.score.toFixed(2)},r>0&&x("div",{class:"md-search-result__icon md-icon"}),r>0&&x("h1",null,e.title),r<=0&&x("h2",null,e.title),o>0&&e.text.length>0&&e.text,e.tags&&x("nav",{class:"md-tags"},e.tags.map(c=>{let p=a?c in a?`md-tag-icon md-tag--${a[c]}`:"md-tag-icon":"";return x("span",{class:`md-tag ${p}`},c)})),o>0&&n.length>0&&x("p",{class:"md-search-result__terms"},Me("search.result.term.missing"),": ",...n)))}function Fn(e){let t=e[0].score,r=[...e],o=Te(),n=r.findIndex(l=>!`${new URL(l.location,o.base)}`.includes("#")),[i]=r.splice(n,1),s=r.findIndex(l=>l.scoreoo(l,1)),...c.length?[x("details",{class:"md-search-result__more"},x("summary",{tabIndex:-1},x("div",null,c.length>0&&c.length===1?Me("search.result.more.one"):Me("search.result.more.other",c.length))),...c.map(l=>oo(l,1)))]:[]];return x("li",{class:"md-search-result__item"},p)}function jn(e){return x("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>x("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?br(r):r)))}function no(e){let t=`tabbed-control tabbed-control--${e}`;return x("div",{class:t,hidden:!0},x("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function Un(e){return x("div",{class:"md-typeset__scrollwrap"},x("div",{class:"md-typeset__table"},e))}function Qa(e){var o;let t=Te(),r=new URL(`../${e.version}/`,t.base);return x("li",{class:"md-version__item"},x("a",{href:`${r}`,class:"md-version__link"},e.title,((o=t.version)==null?void 0:o.alias)&&e.aliases.length>0&&x("span",{class:"md-version__alias"},e.aliases[0])))}function Wn(e,t){var o;let r=Te();return e=e.filter(n=>{var i;return!((i=n.properties)!=null&&i.hidden)}),x("div",{class:"md-version"},x("button",{class:"md-version__current","aria-label":Me("select.version")},t.title,((o=r.version)==null?void 0:o.alias)&&t.aliases.length>0&&x("span",{class:"md-version__alias"},t.aliases[0])),x("ul",{class:"md-version__list"},e.map(Qa)))}var Ya=0;function Ba(e,t=250){let r=z([Ye(e),it(e,t)]).pipe(m(([n,i])=>n||i),Y()),o=H(()=>pn(e)).pipe(J(Ge),gt(1),Pe(r),m(()=>ln(e)));return r.pipe(Re(n=>n),b(()=>z([r,o])),m(([n,i])=>({active:n,offset:i})),le())}function Vt(e,t,r=250){let{content$:o,viewport$:n}=t,i=`__tooltip2_${Ya++}`;return H(()=>{let s=new T,a=new jr(!1);s.pipe(oe(),ae(!1)).subscribe(a);let c=a.pipe(jt(l=>He(+!l*250,Dr)),Y(),b(l=>l?o:y),O(l=>l.id=i),le());z([s.pipe(m(({active:l})=>l)),c.pipe(b(l=>it(l,250)),Q(!1))]).pipe(m(l=>l.some(f=>f))).subscribe(a);let p=a.pipe(g(l=>l),te(c,n),m(([l,f,{size:u}])=>{let d=e.getBoundingClientRect(),v=d.width/2;if(f.role==="tooltip")return{x:v,y:8+d.height};if(d.y>=u.height/2){let{height:S}=de(f);return{x:v,y:-16-S}}else return{x:v,y:16+d.height}}));return z([c,s,p]).subscribe(([l,{offset:f},u])=>{l.style.setProperty("--md-tooltip-host-x",`${f.x}px`),l.style.setProperty("--md-tooltip-host-y",`${f.y}px`),l.style.setProperty("--md-tooltip-x",`${u.x}px`),l.style.setProperty("--md-tooltip-y",`${u.y}px`),l.classList.toggle("md-tooltip2--top",u.y<0),l.classList.toggle("md-tooltip2--bottom",u.y>=0)}),a.pipe(g(l=>l),te(c,(l,f)=>f),g(l=>l.role==="tooltip")).subscribe(l=>{let f=de(j(":scope > *",l));l.style.setProperty("--md-tooltip-width",`${f.width}px`),l.style.setProperty("--md-tooltip-tail","0px")}),a.pipe(Y(),xe(ye),te(c)).subscribe(([l,f])=>{f.classList.toggle("md-tooltip2--active",l)}),z([a.pipe(g(l=>l)),c]).subscribe(([l,f])=>{f.role==="dialog"?(e.setAttribute("aria-controls",i),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",i)}),a.pipe(g(l=>!l)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),Ba(e,r).pipe(O(l=>s.next(l)),A(()=>s.complete()),m(l=>P({ref:e},l)))})}function Xe(e,{viewport$:t},r=document.body){return Vt(e,{content$:new F(o=>{let n=e.title,i=Cn(n);return o.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",n)}}),viewport$:t},0)}function Ga(e,t){let r=H(()=>z([mn(e),Ge(t)])).pipe(m(([{x:o,y:n},i])=>{let{width:s,height:a}=de(e);return{x:o-i.x+s/2,y:n-i.y+a/2}}));return Ye(e).pipe(b(o=>r.pipe(m(n=>({active:o,offset:n})),Ee(+!o||1/0))))}function Dn(e,t,{target$:r}){let[o,n]=Array.from(e.children);return H(()=>{let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({offset:a}){e.style.setProperty("--md-tooltip-x",`${a.x}px`),e.style.setProperty("--md-tooltip-y",`${a.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),mt(e).pipe(W(s)).subscribe(a=>{e.toggleAttribute("data-md-visible",a)}),L(i.pipe(g(({active:a})=>a)),i.pipe(Ae(250),g(({active:a})=>!a))).subscribe({next({active:a}){a?e.prepend(o):o.remove()},complete(){e.prepend(o)}}),i.pipe($e(16,ye)).subscribe(({active:a})=>{o.classList.toggle("md-tooltip--active",a)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:a})=>a)).subscribe({next(a){a?e.style.setProperty("--md-tooltip-0",`${-a}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),h(n,"click").pipe(W(s),g(a=>!(a.metaKey||a.ctrlKey))).subscribe(a=>{a.stopPropagation(),a.preventDefault()}),h(n,"mousedown").pipe(W(s),te(i)).subscribe(([a,{active:c}])=>{var p;if(a.button!==0||a.metaKey||a.ctrlKey)a.preventDefault();else if(c){a.preventDefault();let l=e.parentElement.closest(".md-annotation");l instanceof HTMLElement?l.focus():(p=Ne())==null||p.blur()}}),r.pipe(W(s),g(a=>a===o),nt(125)).subscribe(()=>e.focus()),Ga(e,t).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function Ja(e){let t=Te();if(e.tagName!=="CODE")return[e];let r=[".c",".c1",".cm"];if(t.annotate&&typeof t.annotate=="object"){let o=e.closest("[class|=language]");if(o)for(let n of Array.from(o.classList)){if(!n.startsWith("language-"))continue;let[,i]=n.split("-");i in t.annotate&&r.push(...t.annotate[i])}}return M(r.join(", "),e)}function Xa(e){let t=[];for(let r of Ja(e)){let o=[],n=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=n.nextNode();i;i=n.nextNode())o.push(i);for(let i of o){let s;for(;s=/(\(\d+\))(!)?/.exec(i.textContent);){let[,a,c]=s;if(typeof c=="undefined"){let p=i.splitText(s.index);i=p.splitText(a.length),t.push(p)}else{i.textContent=a,t.push(i);break}}}}return t}function Vn(e,t){t.append(...Array.from(e.childNodes))}function Tr(e,t,{target$:r,print$:o}){let n=t.closest("[id]"),i=n==null?void 0:n.id,s=new Map;for(let a of Xa(t)){let[,c]=a.textContent.match(/\((\d+)\)/);ue(`:scope > li:nth-child(${c})`,e)&&(s.set(c,kn(c,i)),a.replaceWith(s.get(c)))}return s.size===0?y:H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=[];for(let[l,f]of s)p.push([j(".md-typeset",f),j(`:scope > li:nth-child(${l})`,e)]);return o.pipe(W(c)).subscribe(l=>{e.hidden=!l,e.classList.toggle("md-annotation-list",l);for(let[f,u]of p)l?Vn(f,u):Vn(u,f)}),L(...[...s].map(([,l])=>Dn(l,t,{target$:r}))).pipe(A(()=>a.complete()),le())})}function Nn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Nn(t)}}function zn(e,t){return H(()=>{let r=Nn(e);return typeof r!="undefined"?Tr(r,e,t):y})}var Kn=$t(ao());var Za=0,qn=L(h(window,"keydown").pipe(m(()=>!0)),L(h(window,"keyup"),h(window,"contextmenu")).pipe(m(()=>!1))).pipe(Q(!1),Z(1));function Qn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Qn(t)}}function es(e){return Le(e).pipe(m(({width:t})=>({scrollable:At(e).width>t})),ne("scrollable"))}function Yn(e,t){let{matches:r}=matchMedia("(hover)"),o=H(()=>{let n=new T,i=n.pipe(Yr(1));n.subscribe(({scrollable:d})=>{d&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let s=[],a=e.closest("pre"),c=a.closest("[id]"),p=c?c.id:Za++;a.id=`__code_${p}`;let l=[],f=e.closest(".highlight");if(f instanceof HTMLElement){let d=Qn(f);if(typeof d!="undefined"&&(f.classList.contains("annotate")||V("content.code.annotate"))){let v=Tr(d,e,t);l.push(Le(f).pipe(W(i),m(({width:S,height:X})=>S&&X),Y(),b(S=>S?v:y)))}}let u=M(":scope > span[id]",e);if(u.length&&(e.classList.add("md-code__content"),e.closest(".select")||V("content.code.select")&&!e.closest(".no-select"))){let d=+u[0].id.split("-").pop(),v=$n();s.push(v),V("content.tooltips")&&l.push(Xe(v,{viewport$}));let S=h(v,"click").pipe(Ut(R=>!R,!1),O(()=>v.blur()),le());S.subscribe(R=>{v.classList.toggle("md-code__button--active",R)});let X=fe(u).pipe(J(R=>it(R).pipe(m(se=>[R,se]))));S.pipe(b(R=>R?X:y)).subscribe(([R,se])=>{let ce=ue(".hll.select",R);if(ce&&!se)ce.replaceWith(...Array.from(ce.childNodes));else if(!ce&&se){let he=document.createElement("span");he.className="hll select",he.append(...Array.from(R.childNodes).slice(1)),R.append(he)}});let re=fe(u).pipe(J(R=>h(R,"mousedown").pipe(O(se=>se.preventDefault()),m(()=>R)))),ee=S.pipe(b(R=>R?re:y),te(qn),m(([R,se])=>{var he;let ce=u.indexOf(R)+d;if(se===!1)return[ce,ce];{let Se=M(".hll",e).map(Ue=>u.indexOf(Ue.parentElement)+d);return(he=window.getSelection())==null||he.removeAllRanges(),[Math.min(ce,...Se),Math.max(ce,...Se)]}})),k=Zr(y).pipe(g(R=>R.startsWith(`__codelineno-${p}-`)));k.subscribe(R=>{let[,,se]=R.split("-"),ce=se.split(":").map(Se=>+Se-d+1);ce.length===1&&ce.push(ce[0]);for(let Se of M(".hll:not(.select)",e))Se.replaceWith(...Array.from(Se.childNodes));let he=u.slice(ce[0]-1,ce[1]);for(let Se of he){let Ue=document.createElement("span");Ue.className="hll",Ue.append(...Array.from(Se.childNodes).slice(1)),Se.append(Ue)}}),k.pipe(Ee(1),xe(pe)).subscribe(R=>{if(R.includes(":")){let se=document.getElementById(R.split(":")[0]);se&&setTimeout(()=>{let ce=se,he=-64;for(;ce!==document.body;)he+=ce.offsetTop,ce=ce.offsetParent;window.scrollTo({top:he})},1)}});let je=fe(M('a[href^="#__codelineno"]',f)).pipe(J(R=>h(R,"click").pipe(O(se=>se.preventDefault()),m(()=>R)))).pipe(W(i),te(qn),m(([R,se])=>{let he=+j(`[id="${R.hash.slice(1)}"]`).parentElement.id.split("-").pop();if(se===!1)return[he,he];{let Se=M(".hll",e).map(Ue=>+Ue.parentElement.id.split("-").pop());return[Math.min(he,...Se),Math.max(he,...Se)]}}));L(ee,je).subscribe(R=>{let se=`#__codelineno-${p}-`;R[0]===R[1]?se+=R[0]:se+=`${R[0]}:${R[1]}`,history.replaceState({},"",se),window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.origin+window.location.pathname+se,oldURL:window.location.href}))})}if(Kn.default.isSupported()&&(e.closest(".copy")||V("content.code.copy")&&!e.closest(".no-copy"))){let d=Hn(a.id);s.push(d),V("content.tooltips")&&l.push(Xe(d,{viewport$}))}if(s.length){let d=Pn();d.append(...s),a.insertBefore(d,e)}return es(e).pipe(O(d=>n.next(d)),A(()=>n.complete()),m(d=>P({ref:e},d)),Ve(L(...l).pipe(W(i))))});return V("content.lazy")?mt(e).pipe(g(n=>n),Ee(1),b(()=>o)):o}function ts(e,{target$:t,print$:r}){let o=!0;return L(t.pipe(m(n=>n.closest("details:not([open])")),g(n=>e===n),m(()=>({action:"open",reveal:!0}))),r.pipe(g(n=>n||!o),O(()=>o=e.open),m(n=>({action:n?"open":"close"}))))}function Bn(e,t){return H(()=>{let r=new T;return r.subscribe(({action:o,reveal:n})=>{e.toggleAttribute("open",o==="open"),n&&e.scrollIntoView()}),ts(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}var Gn=0;function rs(e){let t=document.createElement("h3");t.innerHTML=e.innerHTML;let r=[t],o=e.nextElementSibling;for(;o&&!(o instanceof HTMLHeadingElement);)r.push(o),o=o.nextElementSibling;return r}function os(e,t){for(let r of M("[href], [src]",e))for(let o of["href","src"]){let n=r.getAttribute(o);if(n&&!/^(?:[a-z]+:)?\/\//i.test(n)){r[o]=new URL(r.getAttribute(o),t).toString();break}}for(let r of M("[name^=__], [for]",e))for(let o of["id","for","name"]){let n=r.getAttribute(o);n&&r.setAttribute(o,`${n}$preview_${Gn}`)}return Gn++,$(e)}function Jn(e,t){let{sitemap$:r}=t;if(!(e instanceof HTMLAnchorElement))return y;if(!(V("navigation.instant.preview")||e.hasAttribute("data-preview")))return y;e.removeAttribute("title");let o=z([Ye(e),it(e)]).pipe(m(([i,s])=>i||s),Y(),g(i=>i));return rt([r,o]).pipe(b(([i])=>{let s=new URL(e.href);return s.search=s.hash="",i.has(`${s}`)?$(s):y}),b(i=>xr(i).pipe(b(s=>os(s,i)))),b(i=>{let s=e.hash?`article [id="${e.hash.slice(1)}"]`:"article h1",a=ue(s,i);return typeof a=="undefined"?y:$(rs(a))})).pipe(b(i=>{let s=new F(a=>{let c=wr(...i);return a.next(c),document.body.append(c),()=>c.remove()});return Vt(e,P({content$:s},t))}))}var Xn=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color)}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var so,is=0;function as(){return typeof mermaid=="undefined"||mermaid instanceof Element?_t("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):$(void 0)}function Zn(e){return e.classList.remove("mermaid"),so||(so=as().pipe(O(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Xn,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),m(()=>{}),Z(1))),so.subscribe(()=>go(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${is++}`,r=x("div",{class:"mermaid"}),o=e.textContent,{svg:n,fn:i}=yield mermaid.render(t,o),s=r.attachShadow({mode:"closed"});s.innerHTML=n,e.replaceWith(r),i==null||i(s)})),so.pipe(m(()=>({ref:e})))}var ei=x("table");function ti(e){return e.replaceWith(ei),ei.replaceWith(Un(e)),$({ref:e})}function ss(e){let t=e.find(r=>r.checked)||e[0];return L(...e.map(r=>h(r,"change").pipe(m(()=>j(`label[for="${r.id}"]`))))).pipe(Q(j(`label[for="${t.id}"]`)),m(r=>({active:r})))}function ri(e,{viewport$:t,target$:r}){let o=j(".tabbed-labels",e),n=M(":scope > input",e),i=no("prev");e.append(i);let s=no("next");return e.append(s),H(()=>{let a=new T,c=a.pipe(oe(),ae(!0));z([a,Le(e),mt(e)]).pipe(W(c),$e(1,ye)).subscribe({next([{active:p},l]){let f=Be(p),{width:u}=de(p);e.style.setProperty("--md-indicator-x",`${f.x}px`),e.style.setProperty("--md-indicator-width",`${u}px`);let d=gr(o);(f.xd.x+l.width)&&o.scrollTo({left:Math.max(0,f.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),z([Ge(o),Le(o)]).pipe(W(c)).subscribe(([p,l])=>{let f=At(o);i.hidden=p.x<16,s.hidden=p.x>f.width-l.width-16}),L(h(i,"click").pipe(m(()=>-1)),h(s,"click").pipe(m(()=>1))).pipe(W(c)).subscribe(p=>{let{width:l}=de(o);o.scrollBy({left:l*p,behavior:"smooth"})}),r.pipe(W(c),g(p=>n.includes(p))).subscribe(p=>p.click()),o.classList.add("tabbed-labels--linked");for(let p of n){let l=j(`label[for="${p.id}"]`);l.replaceChildren(x("a",{href:`#${l.htmlFor}`,tabIndex:-1},...Array.from(l.childNodes))),h(l.firstElementChild,"click").pipe(W(c),g(f=>!(f.metaKey||f.ctrlKey)),O(f=>{f.preventDefault(),f.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${l.htmlFor}`),l.click()})}return V("content.tabs.link")&&a.pipe(Ie(1),te(t)).subscribe(([{active:p},{offset:l}])=>{let f=p.innerText.trim();if(p.hasAttribute("data-md-switching"))p.removeAttribute("data-md-switching");else{let u=e.offsetTop-l.y;for(let v of M("[data-tabs]"))for(let S of M(":scope > input",v)){let X=j(`label[for="${S.id}"]`);if(X!==p&&X.innerText.trim()===f){X.setAttribute("data-md-switching",""),S.click();break}}window.scrollTo({top:e.offsetTop-u});let d=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([f,...d])])}}),a.pipe(W(c)).subscribe(()=>{for(let p of M("audio, video",e))p.offsetWidth&&p.autoplay?p.play().catch(()=>{}):p.pause()}),ss(n).pipe(O(p=>a.next(p)),A(()=>a.complete()),m(p=>P({ref:e},p)))}).pipe(et(pe))}function oi(e,t){let{viewport$:r,target$:o,print$:n}=t;return L(...M(".annotate:not(.highlight)",e).map(i=>zn(i,{target$:o,print$:n})),...M("pre:not(.mermaid) > code",e).map(i=>Yn(i,{target$:o,print$:n})),...M("a",e).map(i=>Jn(i,t)),...M("pre.mermaid",e).map(i=>Zn(i)),...M("table:not([class])",e).map(i=>ti(i)),...M("details",e).map(i=>Bn(i,{target$:o,print$:n})),...M("[data-tabs]",e).map(i=>ri(i,{viewport$:r,target$:o})),...M("[title]:not([data-preview])",e).filter(()=>V("content.tooltips")).map(i=>Xe(i,{viewport$:r})),...M(".footnote-ref",e).filter(()=>V("content.footnote.tooltips")).map(i=>Vt(i,{content$:new F(s=>{let a=new URL(i.href).hash.slice(1),c=Array.from(document.getElementById(a).cloneNode(!0).children),p=wr(...c);return s.next(p),document.body.append(p),()=>p.remove()}),viewport$:r})))}function cs(e,{alert$:t}){return t.pipe(b(r=>L($(!0),$(!1).pipe(nt(2e3))).pipe(m(o=>({message:r,active:o})))))}function ni(e,t){let r=j(".md-typeset",e);return H(()=>{let o=new T;return o.subscribe(({message:n,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=n}),cs(e,t).pipe(O(n=>o.next(n)),A(()=>o.complete()),m(n=>P({ref:e},n)))})}var ps=0;function ls(e,t){document.body.append(e);let{width:r}=de(e);e.style.setProperty("--md-tooltip-width",`${r}px`),e.remove();let o=vr(t),n=typeof o!="undefined"?Ge(o):$({x:0,y:0}),i=L(Ye(t),it(t)).pipe(Y());return z([i,n]).pipe(m(([s,a])=>{let{x:c,y:p}=Be(t),l=de(t),f=t.closest("table");return f&&t.parentElement&&(c+=f.offsetLeft+t.parentElement.offsetLeft,p+=f.offsetTop+t.parentElement.offsetTop),{active:s,offset:{x:c-a.x+l.width/2-r/2,y:p-a.y+l.height+8}}}))}function ii(e){let t=e.title;if(!t.length)return y;let r=`__tooltip_${ps++}`,o=Dt(r,"inline"),n=j(".md-typeset",o);return n.innerHTML=t,H(()=>{let i=new T;return i.subscribe({next({offset:s}){o.style.setProperty("--md-tooltip-x",`${s.x}px`),o.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){o.style.removeProperty("--md-tooltip-x"),o.style.removeProperty("--md-tooltip-y")}}),L(i.pipe(g(({active:s})=>s)),i.pipe(Ae(250),g(({active:s})=>!s))).subscribe({next({active:s}){s?(e.insertAdjacentElement("afterend",o),e.setAttribute("aria-describedby",r),e.removeAttribute("title")):(o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t))},complete(){o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t)}}),i.pipe($e(16,ye)).subscribe(({active:s})=>{o.classList.toggle("md-tooltip--active",s)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:s})=>s)).subscribe({next(s){s?o.style.setProperty("--md-tooltip-0",`${-s}px`):o.style.removeProperty("--md-tooltip-0")},complete(){o.style.removeProperty("--md-tooltip-0")}}),ls(o,e).pipe(O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))}).pipe(et(pe))}function ms({viewport$:e}){if(!V("header.autohide"))return $(!1);let t=e.pipe(m(({offset:{y:n}})=>n),ot(2,1),m(([n,i])=>[nMath.abs(i-n.y)>100),m(([,[n]])=>n),Y()),o=Je("search");return z([e,o]).pipe(m(([{offset:n},i])=>n.y>400&&!i),Y(),b(n=>n?r:$(!1)),Q(!1))}function ai(e,t){return H(()=>z([Le(e),ms(t)])).pipe(m(([{height:r},o])=>({height:r,hidden:o})),Y((r,o)=>r.height===o.height&&r.hidden===o.hidden),Z(1))}function si(e,{header$:t,main$:r}){return H(()=>{let o=new T,n=o.pipe(oe(),ae(!0));o.pipe(ne("active"),Pe(t)).subscribe(([{active:s},{hidden:a}])=>{e.classList.toggle("md-header--shadow",s&&!a),e.hidden=a});let i=fe(M("[title]",e)).pipe(g(()=>V("content.tooltips")),J(s=>ii(s)));return r.subscribe(o),t.pipe(W(n),m(s=>P({ref:e},s)),Ve(i.pipe(W(n))))})}function fs(e,{viewport$:t,header$:r}){return Er(e,{viewport$:t,header$:r}).pipe(m(({offset:{y:o}})=>{let{height:n}=de(e);return{active:n>0&&o>=n}}),ne("active"))}function ci(e,t){return H(()=>{let r=new T;r.subscribe({next({active:n}){e.classList.toggle("md-header__title--active",n)},complete(){e.classList.remove("md-header__title--active")}});let o=ue(".md-content h1");return typeof o=="undefined"?y:fs(o,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))})}function pi(e,{viewport$:t,header$:r}){let o=r.pipe(m(({height:i})=>i),Y()),n=o.pipe(b(()=>Le(e).pipe(m(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),ne("bottom"))));return z([o,n,t]).pipe(m(([i,{top:s,bottom:a},{offset:{y:c},size:{height:p}}])=>(p=Math.max(0,p-Math.max(0,s-c,i)-Math.max(0,p+c-a)),{offset:s-i,height:p,active:s-i<=c})),Y((i,s)=>i.offset===s.offset&&i.height===s.height&&i.active===s.active))}function us(e){let t=__md_get("__palette")||{index:e.findIndex(o=>matchMedia(o.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return $(...e).pipe(J(o=>h(o,"change").pipe(m(()=>o))),Q(e[r]),m(o=>({index:e.indexOf(o),color:{media:o.getAttribute("data-md-color-media"),scheme:o.getAttribute("data-md-color-scheme"),primary:o.getAttribute("data-md-color-primary"),accent:o.getAttribute("data-md-color-accent")}})),Z(1))}function li(e){let t=M("input",e),r=x("meta",{name:"theme-color"});document.head.appendChild(r);let o=x("meta",{name:"color-scheme"});document.head.appendChild(o);let n=Wt("(prefers-color-scheme: light)");return H(()=>{let i=new T;return i.subscribe(s=>{if(document.body.setAttribute("data-md-color-switching",""),s.color.media==="(prefers-color-scheme)"){let a=matchMedia("(prefers-color-scheme: light)"),c=document.querySelector(a.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");s.color.scheme=c.getAttribute("data-md-color-scheme"),s.color.primary=c.getAttribute("data-md-color-primary"),s.color.accent=c.getAttribute("data-md-color-accent")}for(let[a,c]of Object.entries(s.color))document.body.setAttribute(`data-md-color-${a}`,c);for(let a=0;as.key==="Enter"),te(i,(s,a)=>a)).subscribe(({index:s})=>{s=(s+1)%t.length,t[s].click(),t[s].focus()}),i.pipe(m(()=>{let s=Ce("header"),a=window.getComputedStyle(s);return o.content=a.colorScheme,a.backgroundColor.match(/\d+/g).map(c=>(+c).toString(16).padStart(2,"0")).join("")})).subscribe(s=>r.content=`#${s}`),i.pipe(xe(pe)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),us(t).pipe(W(n.pipe(Ie(1))),vt(),O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))})}function mi(e,{progress$:t}){return H(()=>{let r=new T;return r.subscribe(({value:o})=>{e.style.setProperty("--md-progress-value",`${o}`)}),t.pipe(O(o=>r.next({value:o})),A(()=>r.complete()),m(o=>({ref:e,value:o})))})}function fi(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,e}function ds(e,t){let r=new Map;for(let o of M("url",e)){let n=j("loc",o),i=[fi(new URL(n.textContent),t)];r.set(`${i[0]}`,i);for(let s of M("[rel=alternate]",o)){let a=s.getAttribute("href");a!=null&&i.push(fi(new URL(a),t))}}return r}function kt(e){return En(new URL("sitemap.xml",e)).pipe(m(t=>ds(t,new URL(e))),ve(()=>$(new Map)),le())}function ui({document$:e}){let t=new Map;e.pipe(b(()=>M("link[rel=alternate]")),m(r=>new URL(r.href)),g(r=>!t.has(r.toString())),J(r=>kt(r).pipe(m(o=>[r,o]),ve(()=>y)))).subscribe(([r,o])=>{t.set(r.toString().replace(/\/$/,""),o)}),h(document.body,"click").pipe(g(r=>!r.metaKey&&!r.ctrlKey),b(r=>{if(r.target instanceof Element){let o=r.target.closest("a");if(o&&!o.target){let n=[...t].find(([f])=>o.href.startsWith(`${f}/`));if(typeof n=="undefined")return y;let[i,s]=n,a=we();if(a.href.startsWith(i))return y;let c=Te(),p=a.href.replace(c.base,"");p=`${i}/${p}`;let l=s.has(p.split("#")[0])?new URL(p,c.base):new URL(i);return r.preventDefault(),$(l)}}return y})).subscribe(r=>st(r,!0))}var co=$t(ao());function hs(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function di({alert$:e}){co.default.isSupported()&&new F(t=>{new co.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||hs(j(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe(O(t=>{t.trigger.focus()}),m(()=>Me("clipboard.copied"))).subscribe(e)}function hi(e,t){if(!(e.target instanceof Element))return y;let r=e.target.closest("a");if(r===null)return y;if(r.target||e.metaKey||e.ctrlKey)return y;let o=new URL(r.href);return o.search=o.hash="",t.has(`${o}`)?(e.preventDefault(),$(r)):y}function bi(e){let t=new Map;for(let r of M(":scope > *",e.head))t.set(r.outerHTML,r);return t}function vi(e){for(let t of M("[href], [src]",e))for(let r of["href","src"]){let o=t.getAttribute(r);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){t[r]=t[r];break}}return $(e)}function bs(e){for(let o of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...V("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let n=ue(o),i=ue(o,e);typeof n!="undefined"&&typeof i!="undefined"&&n.replaceWith(i)}let t=bi(document);for(let[o,n]of bi(e))t.has(o)?t.delete(o):document.head.appendChild(n);for(let o of t.values()){let n=o.getAttribute("name");n!=="theme-color"&&n!=="color-scheme"&&o.remove()}let r=Ce("container");return Ke(M("script",r)).pipe(b(o=>{let n=e.createElement("script");if(o.src){for(let i of o.getAttributeNames())n.setAttribute(i,o.getAttribute(i));return o.replaceWith(n),new F(i=>{n.onload=()=>i.complete()})}else return n.textContent=o.textContent,o.replaceWith(n),y}),oe(),ae(document))}function gi({sitemap$:e,location$:t,viewport$:r,progress$:o}){if(location.protocol==="file:")return y;$(document).subscribe(vi);let n=h(document.body,"click").pipe(Pe(e),b(([a,c])=>hi(a,c)),m(({href:a})=>new URL(a)),le()),i=h(window,"popstate").pipe(m(we),le());n.pipe(te(r)).subscribe(([a,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",a)}),L(n,i).subscribe(t);let s=t.pipe(ne("pathname"),b(a=>xr(a,{progress$:o}).pipe(ve(()=>(st(a,!0),y)))),b(vi),b(bs),le());return L(s.pipe(te(t,(a,c)=>c)),s.pipe(b(()=>t),ne("hash")),t.pipe(Y((a,c)=>a.pathname===c.pathname&&a.hash===c.hash),b(()=>n),O(()=>history.back()))).subscribe(a=>{var c,p;history.state!==null||!a.hash?window.scrollTo(0,(p=(c=history.state)==null?void 0:c.y)!=null?p:0):(history.scrollRestoration="auto",gn(a.hash),history.scrollRestoration="manual")}),t.subscribe(()=>{history.scrollRestoration="manual"}),h(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),r.pipe(ne("offset"),Ae(100)).subscribe(({offset:a})=>{history.replaceState(a,"")}),V("navigation.instant.prefetch")&&L(h(document.body,"mousemove"),h(document.body,"focusin")).pipe(Pe(e),b(([a,c])=>hi(a,c)),Ae(25),Qr(({href:a})=>a),hr(a=>{let c=document.createElement("link");return c.rel="prefetch",c.href=a.toString(),document.head.appendChild(c),h(c,"load").pipe(m(()=>c),Ee(1))})).subscribe(a=>a.remove()),s}var yi=$t(ro());function xi(e){let t=e.separator.split("|").map(n=>n.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":n).join("|"),r=new RegExp(t,"img"),o=(n,i,s)=>`${i}${s}`;return n=>{n=n.replace(/[\s*+\-:~^]+/g," ").replace(/&/g,"&").trim();let i=new RegExp(`(^|${e.separator}|)(${n.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&").replace(r,"|")})`,"img");return s=>(0,yi.default)(s).replace(i,o).replace(/<\/mark>(\s+)]*>/img,"$1")}}function zt(e){return e.type===1}function Sr(e){return e.type===3}function Ei(e,t){let r=Mn(e);return L($(location.protocol!=="file:"),Je("search")).pipe(Re(o=>o),b(()=>t)).subscribe(({config:o,docs:n})=>r.next({type:0,data:{config:o,docs:n,options:{suggest:V("search.suggest")}}})),r}function wi(e){var l;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:o,currentBaseURL:n}=e,i=(l=po(n))==null?void 0:l.pathname;if(i===void 0)return;let s=ys(o.pathname,i);if(s===void 0)return;let a=Es(t.keys());if(!t.has(a))return;let c=po(s,a);if(!c||!t.has(c.href))return;let p=po(s,r);if(p)return p.hash=o.hash,p.search=o.search,p}function po(e,t){try{return new URL(e,t)}catch(r){return}}function ys(e,t){if(e.startsWith(t))return e.slice(t.length)}function xs(e,t){let r=Math.min(e.length,t.length),o;for(o=0;oy)),o=r.pipe(m(n=>{let[,i]=t.base.match(/([^/]+)\/?$/);return n.find(({version:s,aliases:a})=>s===i||a.includes(i))||n[0]}));r.pipe(m(n=>new Map(n.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),b(n=>h(document.body,"click").pipe(g(i=>!i.metaKey&&!i.ctrlKey),te(o),b(([i,s])=>{if(i.target instanceof Element){let a=i.target.closest("a");if(a&&!a.target&&n.has(a.href)){let c=a.href;return!i.target.closest(".md-version")&&n.get(c)===s?y:(i.preventDefault(),$(new URL(c)))}}return y}),b(i=>kt(i).pipe(m(s=>{var a;return(a=wi({selectedVersionSitemap:s,selectedVersionBaseURL:i,currentLocation:we(),currentBaseURL:t.base}))!=null?a:i})))))).subscribe(n=>st(n,!0)),z([r,o]).subscribe(([n,i])=>{j(".md-header__topic").appendChild(Wn(n,i))}),e.pipe(b(()=>o)).subscribe(n=>{var a;let i=new URL(t.base),s=__md_get("__outdated",sessionStorage,i);if(s===null){s=!0;let c=((a=t.version)==null?void 0:a.default)||"latest";Array.isArray(c)||(c=[c]);e:for(let p of c)for(let l of n.aliases.concat(n.version))if(new RegExp(p,"i").test(l)){s=!1;break e}__md_set("__outdated",s,sessionStorage,i)}if(s)for(let c of me("outdated"))c.hidden=!1})}function ws(e,{worker$:t}){let{searchParams:r}=we();r.has("q")&&(at("search",!0),e.value=r.get("q"),e.focus(),Je("search").pipe(Re(i=>!i)).subscribe(()=>{let i=we();i.searchParams.delete("q"),history.replaceState({},"",`${i}`)}));let o=Ye(e),n=L(t.pipe(Re(zt)),h(e,"keyup"),o).pipe(m(()=>e.value),Y());return z([n,o]).pipe(m(([i,s])=>({value:i,focus:s})),Z(1))}function Si(e,{worker$:t}){let r=new T,o=r.pipe(oe(),ae(!0));z([t.pipe(Re(zt)),r],(i,s)=>s).pipe(ne("value")).subscribe(({value:i})=>t.next({type:2,data:i})),r.pipe(ne("focus")).subscribe(({focus:i})=>{i&&at("search",i)}),h(e.form,"reset").pipe(W(o)).subscribe(()=>e.focus());let n=j("header [for=__search]");return h(n,"click").subscribe(()=>e.focus()),ws(e,{worker$:t}).pipe(O(i=>r.next(i)),A(()=>r.complete()),m(i=>P({ref:e},i)),Z(1))}function Oi(e,{worker$:t,query$:r}){let o=new T,n=un(e.parentElement).pipe(g(Boolean)),i=e.parentElement,s=j(":scope > :first-child",e),a=j(":scope > :last-child",e);Je("search").subscribe(l=>{a.setAttribute("role",l?"list":"presentation"),a.hidden=!l}),o.pipe(te(r),Gr(t.pipe(Re(zt)))).subscribe(([{items:l},{value:f}])=>{switch(l.length){case 0:s.textContent=f.length?Me("search.result.none"):Me("search.result.placeholder");break;case 1:s.textContent=Me("search.result.one");break;default:let u=br(l.length);s.textContent=Me("search.result.other",u)}});let c=o.pipe(O(()=>a.innerHTML=""),b(({items:l})=>L($(...l.slice(0,10)),$(...l.slice(10)).pipe(ot(4),Xr(n),b(([f])=>f)))),m(Fn),le());return c.subscribe(l=>a.appendChild(l)),c.pipe(J(l=>{let f=ue("details",l);return typeof f=="undefined"?y:h(f,"toggle").pipe(W(o),m(()=>f))})).subscribe(l=>{l.open===!1&&l.offsetTop<=i.scrollTop&&i.scrollTo({top:l.offsetTop})}),t.pipe(g(Sr),m(({data:l})=>l)).pipe(O(l=>o.next(l)),A(()=>o.complete()),m(l=>P({ref:e},l)))}function Ts(e,{query$:t}){return t.pipe(m(({value:r})=>{let o=we();return o.hash="",r=r.replace(/\s+/g,"+").replace(/&/g,"%26").replace(/=/g,"%3D"),o.search=`q=${r}`,{url:o}}))}function Li(e,t){let r=new T,o=r.pipe(oe(),ae(!0));return r.subscribe(({url:n})=>{e.setAttribute("data-clipboard-text",e.href),e.href=`${n}`}),h(e,"click").pipe(W(o)).subscribe(n=>n.preventDefault()),Ts(e,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))}function Mi(e,{worker$:t,keyboard$:r}){let o=new T,n=Ce("search-query"),i=L(h(n,"keydown"),h(n,"focus")).pipe(xe(pe),m(()=>n.value),Y());return o.pipe(Pe(i),m(([{suggest:a},c])=>{let p=c.split(/([\s-]+)/);if(a!=null&&a.length&&p[p.length-1]){let l=a[a.length-1];l.startsWith(p[p.length-1])&&(p[p.length-1]=l)}else p.length=0;return p})).subscribe(a=>e.innerHTML=a.join("").replace(/\s/g," ")),r.pipe(g(({mode:a})=>a==="search")).subscribe(a=>{a.type==="ArrowRight"&&e.innerText.length&&n.selectionStart===n.value.length&&(n.value=e.innerText)}),t.pipe(g(Sr),m(({data:a})=>a)).pipe(O(a=>o.next(a)),A(()=>o.complete()),m(()=>({ref:e})))}function _i(e,{index$:t,keyboard$:r}){let o=Te();try{let n=Ei(o.search,t),i=Ce("search-query",e),s=Ce("search-result",e);h(e,"click").pipe(g(({target:c})=>c instanceof Element&&!!c.closest("a"))).subscribe(()=>at("search",!1)),r.pipe(g(({mode:c})=>c==="search")).subscribe(c=>{let p=Ne();switch(c.type){case"Enter":if(p===i){let l=new Map;for(let f of M(":first-child [href]",s)){let u=f.firstElementChild;l.set(f,parseFloat(u.getAttribute("data-md-score")))}if(l.size){let[[f]]=[...l].sort(([,u],[,d])=>d-u);f.click()}c.claim()}break;case"Escape":case"Tab":at("search",!1),i.blur();break;case"ArrowUp":case"ArrowDown":if(typeof p=="undefined")i.focus();else{let l=[i,...M(":not(details) > [href], summary, details[open] [href]",s)],f=Math.max(0,(Math.max(0,l.indexOf(p))+l.length+(c.type==="ArrowUp"?-1:1))%l.length);l[f].focus()}c.claim();break;default:i!==Ne()&&i.focus()}}),r.pipe(g(({mode:c})=>c==="global")).subscribe(c=>{switch(c.type){case"f":case"s":case"/":i.focus(),i.select(),c.claim();break}});let a=Si(i,{worker$:n});return L(a,Oi(s,{worker$:n,query$:a})).pipe(Ve(...me("search-share",e).map(c=>Li(c,{query$:a})),...me("search-suggest",e).map(c=>Mi(c,{worker$:n,keyboard$:r}))))}catch(n){return e.hidden=!0,tt}}function Ai(e,{index$:t,location$:r}){return z([t,r.pipe(Q(we()),g(o=>!!o.searchParams.get("h")))]).pipe(m(([o,n])=>xi(o.config)(n.searchParams.get("h"))),m(o=>{var s;let n=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let a=i.nextNode();a;a=i.nextNode())if((s=a.parentElement)!=null&&s.offsetHeight){let c=a.textContent,p=o(c);p.length>c.length&&n.set(a,p)}for(let[a,c]of n){let{childNodes:p}=x("span",null,c);a.replaceWith(...Array.from(p))}return{ref:e,nodes:n}}))}function Ss(e,{viewport$:t,main$:r}){let o=e.closest(".md-grid"),n=o.offsetTop-o.parentElement.offsetTop;return z([r,t]).pipe(m(([{offset:i,height:s},{offset:{y:a}}])=>(s=s+Math.min(n,Math.max(0,a-i))-n,{height:s,locked:a>=i+n})),Y((i,s)=>i.height===s.height&&i.locked===s.locked))}function lo(e,o){var n=o,{header$:t}=n,r=vo(n,["header$"]);let i=j(".md-sidebar__scrollwrap",e),{y:s}=Be(i);return H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=a.pipe($e(0,ye));return p.pipe(te(t)).subscribe({next([{height:l},{height:f}]){i.style.height=`${l-2*s}px`,e.style.top=`${f}px`},complete(){i.style.height="",e.style.top=""}}),p.pipe(Re()).subscribe(()=>{for(let l of M(".md-nav__link--active[href]",e)){if(!l.clientHeight)continue;let f=l.closest(".md-sidebar__scrollwrap");if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2})}}}),fe(M("label[tabindex]",e)).pipe(J(l=>h(l,"click").pipe(xe(pe),m(()=>l),W(c)))).subscribe(l=>{let f=j(`[id="${l.htmlFor}"]`);j(`[aria-labelledby="${l.id}"]`).setAttribute("aria-expanded",`${f.checked}`)}),V("content.tooltips")&&fe(M("abbr[title]",e)).pipe(J(l=>Xe(l,{viewport$})),W(c)).subscribe(),Ss(e,r).pipe(O(l=>a.next(l)),A(()=>a.complete()),m(l=>P({ref:e},l)))})}function Ci(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return rt(ze(`${r}/releases/latest`).pipe(ve(()=>y),m(o=>({version:o.tag_name})),Qe({})),ze(r).pipe(ve(()=>y),m(o=>({stars:o.stargazers_count,forks:o.forks_count})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}else{let r=`https://api.github.com/users/${e}`;return ze(r).pipe(m(o=>({repositories:o.public_repos})),Qe({}))}}function ki(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return rt(ze(`${r}/releases/permalink/latest`).pipe(ve(()=>y),m(({tag_name:o})=>({version:o})),Qe({})),ze(r).pipe(ve(()=>y),m(({star_count:o,forks_count:n})=>({stars:o,forks:n})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}function Hi(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,o]=t;return Ci(r,o)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,o]=t;return ki(r,o)}return y}var Os;function Ls(e){return Os||(Os=H(()=>{let t=__md_get("__source",sessionStorage);if(t)return $(t);if(me("consent").length){let o=__md_get("__consent");if(!(o&&o.github))return y}return Hi(e.href).pipe(O(o=>__md_set("__source",o,sessionStorage)))}).pipe(ve(()=>y),g(t=>Object.keys(t).length>0),m(t=>({facts:t})),Z(1)))}function $i(e){let t=j(":scope > :last-child",e);return H(()=>{let r=new T;return r.subscribe(({facts:o})=>{t.appendChild(jn(o)),t.classList.add("md-source__repository--active")}),Ls(e).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function Ms(e,{viewport$:t,header$:r}){return Le(document.body).pipe(b(()=>Er(e,{header$:r,viewport$:t})),m(({offset:{y:o}})=>({hidden:o>=10})),ne("hidden"))}function Pi(e,t){return H(()=>{let r=new T;return r.subscribe({next({hidden:o}){e.hidden=o},complete(){e.hidden=!1}}),(V("navigation.tabs.sticky")?$({hidden:!1}):Ms(e,t)).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function _s(e,{viewport$:t,header$:r}){let o=new Map,n=M(".md-nav__link",e);for(let a of n){let c=decodeURIComponent(a.hash.substring(1)),p=ue(`[id="${c}"]`);typeof p!="undefined"&&o.set(a,p)}let i=r.pipe(ne("height"),m(({height:a})=>{let c=Ce("main"),p=j(":scope > :first-child",c);return a+.8*(p.offsetTop-c.offsetTop)}),le());return Le(document.body).pipe(ne("height"),b(a=>H(()=>{let c=[];return $([...o].reduce((p,[l,f])=>{for(;c.length&&o.get(c[c.length-1]).tagName>=f.tagName;)c.pop();let u=f.offsetTop;for(;!u&&f.parentElement;)f=f.parentElement,u=f.offsetTop;let d=f.offsetParent;for(;d;d=d.offsetParent)u+=d.offsetTop;return p.set([...c=[...c,l]].reverse(),u)},new Map))}).pipe(m(c=>new Map([...c].sort(([,p],[,l])=>p-l))),Pe(i),b(([c,p])=>t.pipe(Ut(([l,f],{offset:{y:u},size:d})=>{let v=u+d.height>=Math.floor(a.height);for(;f.length;){let[,S]=f[0];if(S-p=u&&!v)f=[l.pop(),...f];else break}return[l,f]},[[],[...c]]),Y((l,f)=>l[0]===f[0]&&l[1]===f[1])))))).pipe(m(([a,c])=>({prev:a.map(([p])=>p),next:c.map(([p])=>p)})),Q({prev:[],next:[]}),ot(2,1),m(([a,c])=>a.prev.length{let i=new T,s=i.pipe(oe(),ae(!0));if(i.subscribe(({prev:a,next:c})=>{for(let[p]of c)p.classList.remove("md-nav__link--passed"),p.classList.remove("md-nav__link--active");for(let[p,[l]]of a.entries())l.classList.add("md-nav__link--passed"),l.classList.toggle("md-nav__link--active",p===a.length-1)}),V("toc.follow")){let a=L(t.pipe(Ae(1),m(()=>{})),t.pipe(Ae(250),m(()=>"smooth")));i.pipe(g(({prev:c})=>c.length>0),Pe(o.pipe(xe(pe))),te(a)).subscribe(([[{prev:c}],p])=>{let[l]=c[c.length-1];if(l.offsetHeight){let f=vr(l);if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2,behavior:p})}}})}return V("navigation.tracking")&&t.pipe(W(s),ne("offset"),Ae(250),Ie(1),W(n.pipe(Ie(1))),vt({delay:250}),te(i)).subscribe(([,{prev:a}])=>{let c=we(),p=a[a.length-1];if(p&&p.length){let[l]=p,{hash:f}=new URL(l.href);c.hash!==f&&(c.hash=f,history.replaceState({},"",`${c}`))}else c.hash="",history.replaceState({},"",`${c}`)}),_s(e,{viewport$:t,header$:r}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function As(e,{viewport$:t,main$:r,target$:o}){let n=t.pipe(m(({offset:{y:s}})=>s),ot(2,1),m(([s,a])=>s>a&&a>0),Y()),i=r.pipe(m(({active:s})=>s));return z([i,n]).pipe(m(([s,a])=>!(s&&a)),Y(),W(o.pipe(Ie(1))),ae(!0),vt({delay:250}),m(s=>({hidden:s})))}function Ii(e,{viewport$:t,header$:r,main$:o,target$:n}){let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({hidden:a}){e.hidden=a,a?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(W(s),ne("height")).subscribe(({height:a})=>{e.style.top=`${a+16}px`}),h(e,"click").subscribe(a=>{a.preventDefault(),window.scrollTo({top:0})}),As(e,{viewport$:t,main$:o,target$:n}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))}function Fi({document$:e,viewport$:t}){e.pipe(b(()=>M(".md-ellipsis")),J(r=>mt(r).pipe(W(e.pipe(Ie(1))),g(o=>o),m(()=>r),Ee(1))),g(r=>r.offsetWidth{let o=r.innerText,n=r.closest("a")||r;return n.title=o,V("content.tooltips")?Xe(n,{viewport$:t}).pipe(W(e.pipe(Ie(1))),A(()=>n.removeAttribute("title"))):y})).subscribe(),V("content.tooltips")&&e.pipe(b(()=>M(".md-status")),J(r=>Xe(r,{viewport$:t}))).subscribe()}function ji({document$:e,tablet$:t}){e.pipe(b(()=>M(".md-toggle--indeterminate")),O(r=>{r.indeterminate=!0,r.checked=!1}),J(r=>h(r,"change").pipe(Jr(()=>r.classList.contains("md-toggle--indeterminate")),m(()=>r))),te(t)).subscribe(([r,o])=>{r.classList.remove("md-toggle--indeterminate"),o&&(r.checked=!1)})}function Cs(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function Ui({document$:e}){e.pipe(b(()=>M("[data-md-scrollfix]")),O(t=>t.removeAttribute("data-md-scrollfix")),g(Cs),J(t=>h(t,"touchstart").pipe(m(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}function Wi({viewport$:e,tablet$:t}){z([Je("search"),t]).pipe(m(([r,o])=>r&&!o),b(r=>$(r).pipe(nt(r?400:100))),te(e)).subscribe(([r,{offset:{y:o}}])=>{if(r)document.body.setAttribute("data-md-scrolllock",""),document.body.style.top=`-${o}px`;else{let n=-1*parseInt(document.body.style.top,10);document.body.removeAttribute("data-md-scrolllock"),document.body.style.top="",n&&window.scrollTo(0,n)}})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let o=e[r];typeof o=="string"?o=document.createTextNode(o):o.parentNode&&o.parentNode.removeChild(o),r?t.insertBefore(this.previousSibling,o):t.replaceChild(o,this)}}}));function ks(){return location.protocol==="file:"?_t(`${new URL("search/search_index.js",Or.base)}`).pipe(m(()=>__index),Z(1)):ze(new URL("search/search_index.json",Or.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var ct=an(),Kt=bn(),Ht=yn(Kt),mo=hn(),ke=Ln(),Lr=Wt("(min-width: 60em)"),Vi=Wt("(min-width: 76.25em)"),Ni=xn(),Or=Te(),zi=document.forms.namedItem("search")?ks():tt,fo=new T;di({alert$:fo});ui({document$:ct});var uo=new T,qi=kt(Or.base);V("navigation.instant")&&gi({sitemap$:qi,location$:Kt,viewport$:ke,progress$:uo}).subscribe(ct);var Di;((Di=Or.version)==null?void 0:Di.provider)==="mike"&&Ti({document$:ct});L(Kt,Ht).pipe(nt(125)).subscribe(()=>{at("drawer",!1),at("search",!1)});mo.pipe(g(({mode:e})=>e==="global")).subscribe(e=>{switch(e.type){case"p":case",":let t=ue("link[rel=prev]");typeof t!="undefined"&&st(t);break;case"n":case".":let r=ue("link[rel=next]");typeof r!="undefined"&&st(r);break;case"Enter":let o=Ne();o instanceof HTMLLabelElement&&o.click()}});Fi({viewport$:ke,document$:ct});ji({document$:ct,tablet$:Lr});Ui({document$:ct});Wi({viewport$:ke,tablet$:Lr});var ft=ai(Ce("header"),{viewport$:ke}),qt=ct.pipe(m(()=>Ce("main")),b(e=>pi(e,{viewport$:ke,header$:ft})),Z(1)),Hs=L(...me("consent").map(e=>An(e,{target$:Ht})),...me("dialog").map(e=>ni(e,{alert$:fo})),...me("palette").map(e=>li(e)),...me("progress").map(e=>mi(e,{progress$:uo})),...me("search").map(e=>_i(e,{index$:zi,keyboard$:mo})),...me("source").map(e=>$i(e))),$s=H(()=>L(...me("announce").map(e=>_n(e)),...me("content").map(e=>oi(e,{sitemap$:qi,viewport$:ke,target$:Ht,print$:Ni})),...me("content").map(e=>V("search.highlight")?Ai(e,{index$:zi,location$:Kt}):y),...me("header").map(e=>si(e,{viewport$:ke,header$:ft,main$:qt})),...me("header-title").map(e=>ci(e,{viewport$:ke,header$:ft})),...me("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?eo(Vi,()=>lo(e,{viewport$:ke,header$:ft,main$:qt})):eo(Lr,()=>lo(e,{viewport$:ke,header$:ft,main$:qt}))),...me("tabs").map(e=>Pi(e,{viewport$:ke,header$:ft})),...me("toc").map(e=>Ri(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})),...me("top").map(e=>Ii(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})))),Ki=ct.pipe(b(()=>$s),Ve(Hs),Z(1));Ki.subscribe();window.document$=ct;window.location$=Kt;window.target$=Ht;window.keyboard$=mo;window.viewport$=ke;window.tablet$=Lr;window.screen$=Vi;window.print$=Ni;window.alert$=fo;window.progress$=uo;window.component$=Ki;})(); +//# sourceMappingURL=bundle.79ae519e.min.js.map + diff --git a/docs-site/assets/javascripts/bundle.79ae519e.min.js.map b/docs-site/assets/javascripts/bundle.79ae519e.min.js.map new file mode 100644 index 0000000..5cf0289 --- /dev/null +++ b/docs-site/assets/javascripts/bundle.79ae519e.min.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["node_modules/focus-visible/dist/focus-visible.js", "node_modules/escape-html/index.js", "node_modules/clipboard/dist/clipboard.js", "src/templates/assets/javascripts/bundle.ts", "node_modules/tslib/tslib.es6.mjs", "node_modules/rxjs/src/internal/util/isFunction.ts", "node_modules/rxjs/src/internal/util/createErrorClass.ts", "node_modules/rxjs/src/internal/util/UnsubscriptionError.ts", "node_modules/rxjs/src/internal/util/arrRemove.ts", "node_modules/rxjs/src/internal/Subscription.ts", "node_modules/rxjs/src/internal/config.ts", "node_modules/rxjs/src/internal/scheduler/timeoutProvider.ts", "node_modules/rxjs/src/internal/util/reportUnhandledError.ts", "node_modules/rxjs/src/internal/util/noop.ts", "node_modules/rxjs/src/internal/NotificationFactories.ts", "node_modules/rxjs/src/internal/util/errorContext.ts", "node_modules/rxjs/src/internal/Subscriber.ts", "node_modules/rxjs/src/internal/symbol/observable.ts", "node_modules/rxjs/src/internal/util/identity.ts", "node_modules/rxjs/src/internal/util/pipe.ts", "node_modules/rxjs/src/internal/Observable.ts", "node_modules/rxjs/src/internal/util/lift.ts", "node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts", "node_modules/rxjs/src/internal/scheduler/animationFrameProvider.ts", "node_modules/rxjs/src/internal/util/ObjectUnsubscribedError.ts", "node_modules/rxjs/src/internal/Subject.ts", "node_modules/rxjs/src/internal/BehaviorSubject.ts", "node_modules/rxjs/src/internal/scheduler/dateTimestampProvider.ts", "node_modules/rxjs/src/internal/ReplaySubject.ts", "node_modules/rxjs/src/internal/scheduler/Action.ts", "node_modules/rxjs/src/internal/scheduler/intervalProvider.ts", "node_modules/rxjs/src/internal/scheduler/AsyncAction.ts", "node_modules/rxjs/src/internal/Scheduler.ts", "node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts", "node_modules/rxjs/src/internal/scheduler/async.ts", "node_modules/rxjs/src/internal/scheduler/QueueAction.ts", "node_modules/rxjs/src/internal/scheduler/QueueScheduler.ts", "node_modules/rxjs/src/internal/scheduler/queue.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameAction.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameScheduler.ts", "node_modules/rxjs/src/internal/scheduler/animationFrame.ts", "node_modules/rxjs/src/internal/observable/empty.ts", "node_modules/rxjs/src/internal/util/isScheduler.ts", "node_modules/rxjs/src/internal/util/args.ts", "node_modules/rxjs/src/internal/util/isArrayLike.ts", "node_modules/rxjs/src/internal/util/isPromise.ts", "node_modules/rxjs/src/internal/util/isInteropObservable.ts", "node_modules/rxjs/src/internal/util/isAsyncIterable.ts", "node_modules/rxjs/src/internal/util/throwUnobservableError.ts", "node_modules/rxjs/src/internal/symbol/iterator.ts", "node_modules/rxjs/src/internal/util/isIterable.ts", "node_modules/rxjs/src/internal/util/isReadableStreamLike.ts", "node_modules/rxjs/src/internal/observable/innerFrom.ts", "node_modules/rxjs/src/internal/util/executeSchedule.ts", "node_modules/rxjs/src/internal/operators/observeOn.ts", "node_modules/rxjs/src/internal/operators/subscribeOn.ts", "node_modules/rxjs/src/internal/scheduled/scheduleObservable.ts", "node_modules/rxjs/src/internal/scheduled/schedulePromise.ts", "node_modules/rxjs/src/internal/scheduled/scheduleArray.ts", "node_modules/rxjs/src/internal/scheduled/scheduleIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleAsyncIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleReadableStreamLike.ts", "node_modules/rxjs/src/internal/scheduled/scheduled.ts", "node_modules/rxjs/src/internal/observable/from.ts", "node_modules/rxjs/src/internal/observable/of.ts", "node_modules/rxjs/src/internal/observable/throwError.ts", "node_modules/rxjs/src/internal/util/EmptyError.ts", "node_modules/rxjs/src/internal/util/isDate.ts", "node_modules/rxjs/src/internal/operators/map.ts", "node_modules/rxjs/src/internal/util/mapOneOrManyArgs.ts", "node_modules/rxjs/src/internal/util/argsArgArrayOrObject.ts", "node_modules/rxjs/src/internal/util/createObject.ts", "node_modules/rxjs/src/internal/observable/combineLatest.ts", "node_modules/rxjs/src/internal/operators/mergeInternals.ts", "node_modules/rxjs/src/internal/operators/mergeMap.ts", "node_modules/rxjs/src/internal/operators/mergeAll.ts", "node_modules/rxjs/src/internal/operators/concatAll.ts", "node_modules/rxjs/src/internal/observable/concat.ts", "node_modules/rxjs/src/internal/observable/defer.ts", "node_modules/rxjs/src/internal/observable/fromEvent.ts", "node_modules/rxjs/src/internal/observable/fromEventPattern.ts", "node_modules/rxjs/src/internal/observable/timer.ts", "node_modules/rxjs/src/internal/observable/merge.ts", "node_modules/rxjs/src/internal/observable/never.ts", "node_modules/rxjs/src/internal/util/argsOrArgArray.ts", "node_modules/rxjs/src/internal/operators/filter.ts", "node_modules/rxjs/src/internal/observable/zip.ts", "node_modules/rxjs/src/internal/operators/audit.ts", "node_modules/rxjs/src/internal/operators/auditTime.ts", "node_modules/rxjs/src/internal/operators/bufferCount.ts", "node_modules/rxjs/src/internal/operators/catchError.ts", "node_modules/rxjs/src/internal/operators/scanInternals.ts", "node_modules/rxjs/src/internal/operators/combineLatest.ts", "node_modules/rxjs/src/internal/operators/combineLatestWith.ts", "node_modules/rxjs/src/internal/operators/debounce.ts", "node_modules/rxjs/src/internal/operators/debounceTime.ts", "node_modules/rxjs/src/internal/operators/defaultIfEmpty.ts", "node_modules/rxjs/src/internal/operators/take.ts", "node_modules/rxjs/src/internal/operators/ignoreElements.ts", "node_modules/rxjs/src/internal/operators/mapTo.ts", "node_modules/rxjs/src/internal/operators/delayWhen.ts", "node_modules/rxjs/src/internal/operators/delay.ts", "node_modules/rxjs/src/internal/operators/distinct.ts", "node_modules/rxjs/src/internal/operators/distinctUntilChanged.ts", "node_modules/rxjs/src/internal/operators/distinctUntilKeyChanged.ts", "node_modules/rxjs/src/internal/operators/throwIfEmpty.ts", "node_modules/rxjs/src/internal/operators/endWith.ts", "node_modules/rxjs/src/internal/operators/exhaustMap.ts", "node_modules/rxjs/src/internal/operators/finalize.ts", "node_modules/rxjs/src/internal/operators/first.ts", "node_modules/rxjs/src/internal/operators/takeLast.ts", "node_modules/rxjs/src/internal/operators/merge.ts", "node_modules/rxjs/src/internal/operators/mergeWith.ts", "node_modules/rxjs/src/internal/operators/repeat.ts", "node_modules/rxjs/src/internal/operators/scan.ts", "node_modules/rxjs/src/internal/operators/share.ts", "node_modules/rxjs/src/internal/operators/shareReplay.ts", "node_modules/rxjs/src/internal/operators/skip.ts", "node_modules/rxjs/src/internal/operators/skipUntil.ts", "node_modules/rxjs/src/internal/operators/startWith.ts", "node_modules/rxjs/src/internal/operators/switchMap.ts", "node_modules/rxjs/src/internal/operators/takeUntil.ts", "node_modules/rxjs/src/internal/operators/takeWhile.ts", "node_modules/rxjs/src/internal/operators/tap.ts", "node_modules/rxjs/src/internal/operators/throttle.ts", "node_modules/rxjs/src/internal/operators/throttleTime.ts", "node_modules/rxjs/src/internal/operators/withLatestFrom.ts", "node_modules/rxjs/src/internal/operators/zip.ts", "node_modules/rxjs/src/internal/operators/zipWith.ts", "src/templates/assets/javascripts/browser/document/index.ts", "src/templates/assets/javascripts/browser/element/_/index.ts", "src/templates/assets/javascripts/browser/element/focus/index.ts", "src/templates/assets/javascripts/browser/element/hover/index.ts", "src/templates/assets/javascripts/utilities/h/index.ts", "src/templates/assets/javascripts/utilities/round/index.ts", "src/templates/assets/javascripts/browser/script/index.ts", "src/templates/assets/javascripts/browser/element/size/_/index.ts", "src/templates/assets/javascripts/browser/element/size/content/index.ts", "src/templates/assets/javascripts/browser/element/offset/_/index.ts", "src/templates/assets/javascripts/browser/element/offset/content/index.ts", "src/templates/assets/javascripts/browser/element/visibility/index.ts", "src/templates/assets/javascripts/browser/toggle/index.ts", "src/templates/assets/javascripts/browser/keyboard/index.ts", "src/templates/assets/javascripts/browser/location/_/index.ts", "src/templates/assets/javascripts/browser/location/hash/index.ts", "src/templates/assets/javascripts/browser/media/index.ts", "src/templates/assets/javascripts/browser/request/index.ts", "src/templates/assets/javascripts/browser/viewport/offset/index.ts", "src/templates/assets/javascripts/browser/viewport/size/index.ts", "src/templates/assets/javascripts/browser/viewport/_/index.ts", "src/templates/assets/javascripts/browser/viewport/at/index.ts", "src/templates/assets/javascripts/browser/worker/index.ts", "src/templates/assets/javascripts/_/index.ts", "src/templates/assets/javascripts/components/_/index.ts", "src/templates/assets/javascripts/components/announce/index.ts", "src/templates/assets/javascripts/components/consent/index.ts", "src/templates/assets/javascripts/templates/tooltip/index.tsx", "src/templates/assets/javascripts/templates/annotation/index.tsx", "src/templates/assets/javascripts/templates/clipboard/index.tsx", "src/templates/assets/javascripts/templates/search/index.tsx", "src/templates/assets/javascripts/templates/source/index.tsx", "src/templates/assets/javascripts/templates/tabbed/index.tsx", "src/templates/assets/javascripts/templates/table/index.tsx", "src/templates/assets/javascripts/templates/version/index.tsx", "src/templates/assets/javascripts/components/tooltip2/index.ts", "src/templates/assets/javascripts/components/content/annotation/_/index.ts", "src/templates/assets/javascripts/components/content/annotation/list/index.ts", "src/templates/assets/javascripts/components/content/annotation/block/index.ts", "src/templates/assets/javascripts/components/content/code/_/index.ts", "src/templates/assets/javascripts/components/content/details/index.ts", "src/templates/assets/javascripts/components/content/link/index.ts", "src/templates/assets/javascripts/components/content/mermaid/index.css", "src/templates/assets/javascripts/components/content/mermaid/index.ts", "src/templates/assets/javascripts/components/content/table/index.ts", "src/templates/assets/javascripts/components/content/tabs/index.ts", "src/templates/assets/javascripts/components/content/_/index.ts", "src/templates/assets/javascripts/components/dialog/index.ts", "src/templates/assets/javascripts/components/tooltip/index.ts", "src/templates/assets/javascripts/components/header/_/index.ts", "src/templates/assets/javascripts/components/header/title/index.ts", "src/templates/assets/javascripts/components/main/index.ts", "src/templates/assets/javascripts/components/palette/index.ts", "src/templates/assets/javascripts/components/progress/index.ts", "src/templates/assets/javascripts/integrations/sitemap/index.ts", "src/templates/assets/javascripts/integrations/alternate/index.ts", "src/templates/assets/javascripts/integrations/clipboard/index.ts", "src/templates/assets/javascripts/integrations/instant/index.ts", "src/templates/assets/javascripts/integrations/search/highlighter/index.ts", "src/templates/assets/javascripts/integrations/search/worker/message/index.ts", "src/templates/assets/javascripts/integrations/search/worker/_/index.ts", "src/templates/assets/javascripts/integrations/version/findurl/index.ts", "src/templates/assets/javascripts/integrations/version/index.ts", "src/templates/assets/javascripts/components/search/query/index.ts", "src/templates/assets/javascripts/components/search/result/index.ts", "src/templates/assets/javascripts/components/search/share/index.ts", "src/templates/assets/javascripts/components/search/suggest/index.ts", "src/templates/assets/javascripts/components/search/_/index.ts", "src/templates/assets/javascripts/components/search/highlight/index.ts", "src/templates/assets/javascripts/components/sidebar/index.ts", "src/templates/assets/javascripts/components/source/facts/github/index.ts", "src/templates/assets/javascripts/components/source/facts/gitlab/index.ts", "src/templates/assets/javascripts/components/source/facts/_/index.ts", "src/templates/assets/javascripts/components/source/_/index.ts", "src/templates/assets/javascripts/components/tabs/index.ts", "src/templates/assets/javascripts/components/toc/index.ts", "src/templates/assets/javascripts/components/top/index.ts", "src/templates/assets/javascripts/patches/ellipsis/index.ts", "src/templates/assets/javascripts/patches/indeterminate/index.ts", "src/templates/assets/javascripts/patches/scrollfix/index.ts", "src/templates/assets/javascripts/patches/scrolllock/index.ts", "src/templates/assets/javascripts/polyfills/index.ts"], + "sourcesContent": ["(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n typeof define === 'function' && define.amd ? define(factory) :\n (factory());\n}(this, (function () { 'use strict';\n\n /**\n * Applies the :focus-visible polyfill at the given scope.\n * A scope in this case is either the top-level Document or a Shadow Root.\n *\n * @param {(Document|ShadowRoot)} scope\n * @see https://github.com/WICG/focus-visible\n */\n function applyFocusVisiblePolyfill(scope) {\n var hadKeyboardEvent = true;\n var hadFocusVisibleRecently = false;\n var hadFocusVisibleRecentlyTimeout = null;\n\n var inputTypesAllowlist = {\n text: true,\n search: true,\n url: true,\n tel: true,\n email: true,\n password: true,\n number: true,\n date: true,\n month: true,\n week: true,\n time: true,\n datetime: true,\n 'datetime-local': true\n };\n\n /**\n * Helper function for legacy browsers and iframes which sometimes focus\n * elements like document, body, and non-interactive SVG.\n * @param {Element} el\n */\n function isValidFocusTarget(el) {\n if (\n el &&\n el !== document &&\n el.nodeName !== 'HTML' &&\n el.nodeName !== 'BODY' &&\n 'classList' in el &&\n 'contains' in el.classList\n ) {\n return true;\n }\n return false;\n }\n\n /**\n * Computes whether the given element should automatically trigger the\n * `focus-visible` class being added, i.e. whether it should always match\n * `:focus-visible` when focused.\n * @param {Element} el\n * @return {boolean}\n */\n function focusTriggersKeyboardModality(el) {\n var type = el.type;\n var tagName = el.tagName;\n\n if (tagName === 'INPUT' && inputTypesAllowlist[type] && !el.readOnly) {\n return true;\n }\n\n if (tagName === 'TEXTAREA' && !el.readOnly) {\n return true;\n }\n\n if (el.isContentEditable) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Add the `focus-visible` class to the given element if it was not added by\n * the author.\n * @param {Element} el\n */\n function addFocusVisibleClass(el) {\n if (el.classList.contains('focus-visible')) {\n return;\n }\n el.classList.add('focus-visible');\n el.setAttribute('data-focus-visible-added', '');\n }\n\n /**\n * Remove the `focus-visible` class from the given element if it was not\n * originally added by the author.\n * @param {Element} el\n */\n function removeFocusVisibleClass(el) {\n if (!el.hasAttribute('data-focus-visible-added')) {\n return;\n }\n el.classList.remove('focus-visible');\n el.removeAttribute('data-focus-visible-added');\n }\n\n /**\n * If the most recent user interaction was via the keyboard;\n * and the key press did not include a meta, alt/option, or control key;\n * then the modality is keyboard. Otherwise, the modality is not keyboard.\n * Apply `focus-visible` to any current active element and keep track\n * of our keyboard modality state with `hadKeyboardEvent`.\n * @param {KeyboardEvent} e\n */\n function onKeyDown(e) {\n if (e.metaKey || e.altKey || e.ctrlKey) {\n return;\n }\n\n if (isValidFocusTarget(scope.activeElement)) {\n addFocusVisibleClass(scope.activeElement);\n }\n\n hadKeyboardEvent = true;\n }\n\n /**\n * If at any point a user clicks with a pointing device, ensure that we change\n * the modality away from keyboard.\n * This avoids the situation where a user presses a key on an already focused\n * element, and then clicks on a different element, focusing it with a\n * pointing device, while we still think we're in keyboard modality.\n * @param {Event} e\n */\n function onPointerDown(e) {\n hadKeyboardEvent = false;\n }\n\n /**\n * On `focus`, add the `focus-visible` class to the target if:\n * - the target received focus as a result of keyboard navigation, or\n * - the event target is an element that will likely require interaction\n * via the keyboard (e.g. a text box)\n * @param {Event} e\n */\n function onFocus(e) {\n // Prevent IE from focusing the document or HTML element.\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {\n addFocusVisibleClass(e.target);\n }\n }\n\n /**\n * On `blur`, remove the `focus-visible` class from the target.\n * @param {Event} e\n */\n function onBlur(e) {\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (\n e.target.classList.contains('focus-visible') ||\n e.target.hasAttribute('data-focus-visible-added')\n ) {\n // To detect a tab/window switch, we look for a blur event followed\n // rapidly by a visibility change.\n // If we don't see a visibility change within 100ms, it's probably a\n // regular focus change.\n hadFocusVisibleRecently = true;\n window.clearTimeout(hadFocusVisibleRecentlyTimeout);\n hadFocusVisibleRecentlyTimeout = window.setTimeout(function() {\n hadFocusVisibleRecently = false;\n }, 100);\n removeFocusVisibleClass(e.target);\n }\n }\n\n /**\n * If the user changes tabs, keep track of whether or not the previously\n * focused element had .focus-visible.\n * @param {Event} e\n */\n function onVisibilityChange(e) {\n if (document.visibilityState === 'hidden') {\n // If the tab becomes active again, the browser will handle calling focus\n // on the element (Safari actually calls it twice).\n // If this tab change caused a blur on an element with focus-visible,\n // re-apply the class when the user switches back to the tab.\n if (hadFocusVisibleRecently) {\n hadKeyboardEvent = true;\n }\n addInitialPointerMoveListeners();\n }\n }\n\n /**\n * Add a group of listeners to detect usage of any pointing devices.\n * These listeners will be added when the polyfill first loads, and anytime\n * the window is blurred, so that they are active when the window regains\n * focus.\n */\n function addInitialPointerMoveListeners() {\n document.addEventListener('mousemove', onInitialPointerMove);\n document.addEventListener('mousedown', onInitialPointerMove);\n document.addEventListener('mouseup', onInitialPointerMove);\n document.addEventListener('pointermove', onInitialPointerMove);\n document.addEventListener('pointerdown', onInitialPointerMove);\n document.addEventListener('pointerup', onInitialPointerMove);\n document.addEventListener('touchmove', onInitialPointerMove);\n document.addEventListener('touchstart', onInitialPointerMove);\n document.addEventListener('touchend', onInitialPointerMove);\n }\n\n function removeInitialPointerMoveListeners() {\n document.removeEventListener('mousemove', onInitialPointerMove);\n document.removeEventListener('mousedown', onInitialPointerMove);\n document.removeEventListener('mouseup', onInitialPointerMove);\n document.removeEventListener('pointermove', onInitialPointerMove);\n document.removeEventListener('pointerdown', onInitialPointerMove);\n document.removeEventListener('pointerup', onInitialPointerMove);\n document.removeEventListener('touchmove', onInitialPointerMove);\n document.removeEventListener('touchstart', onInitialPointerMove);\n document.removeEventListener('touchend', onInitialPointerMove);\n }\n\n /**\n * When the polfyill first loads, assume the user is in keyboard modality.\n * If any event is received from a pointing device (e.g. mouse, pointer,\n * touch), turn off keyboard modality.\n * This accounts for situations where focus enters the page from the URL bar.\n * @param {Event} e\n */\n function onInitialPointerMove(e) {\n // Work around a Safari quirk that fires a mousemove on whenever the\n // window blurs, even if you're tabbing out of the page. \u00AF\\_(\u30C4)_/\u00AF\n if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {\n return;\n }\n\n hadKeyboardEvent = false;\n removeInitialPointerMoveListeners();\n }\n\n // For some kinds of state, we are interested in changes at the global scope\n // only. For example, global pointer input, global key presses and global\n // visibility change should affect the state at every scope:\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('mousedown', onPointerDown, true);\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('touchstart', onPointerDown, true);\n document.addEventListener('visibilitychange', onVisibilityChange, true);\n\n addInitialPointerMoveListeners();\n\n // For focus and blur, we specifically care about state changes in the local\n // scope. This is because focus / blur events that originate from within a\n // shadow root are not re-dispatched from the host element if it was already\n // the active element in its own scope:\n scope.addEventListener('focus', onFocus, true);\n scope.addEventListener('blur', onBlur, true);\n\n // We detect that a node is a ShadowRoot by ensuring that it is a\n // DocumentFragment and also has a host property. This check covers native\n // implementation and polyfill implementation transparently. If we only cared\n // about the native implementation, we could just check if the scope was\n // an instance of a ShadowRoot.\n if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) {\n // Since a ShadowRoot is a special kind of DocumentFragment, it does not\n // have a root element to add a class to. So, we add this attribute to the\n // host element instead:\n scope.host.setAttribute('data-js-focus-visible', '');\n } else if (scope.nodeType === Node.DOCUMENT_NODE) {\n document.documentElement.classList.add('js-focus-visible');\n document.documentElement.setAttribute('data-js-focus-visible', '');\n }\n }\n\n // It is important to wrap all references to global window and document in\n // these checks to support server-side rendering use cases\n // @see https://github.com/WICG/focus-visible/issues/199\n if (typeof window !== 'undefined' && typeof document !== 'undefined') {\n // Make the polyfill helper globally available. This can be used as a signal\n // to interested libraries that wish to coordinate with the polyfill for e.g.,\n // applying the polyfill to a shadow root:\n window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;\n\n // Notify interested libraries of the polyfill's presence, in case the\n // polyfill was loaded lazily:\n var event;\n\n try {\n event = new CustomEvent('focus-visible-polyfill-ready');\n } catch (error) {\n // IE11 does not support using CustomEvent as a constructor directly:\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('focus-visible-polyfill-ready', false, false, {});\n }\n\n window.dispatchEvent(event);\n }\n\n if (typeof document !== 'undefined') {\n // Apply the polyfill to the global document, so that no JavaScript\n // coordination is required to use the polyfill in the top-level document:\n applyFocusVisiblePolyfill(document);\n }\n\n})));\n", "/*!\n * escape-html\n * Copyright(c) 2012-2013 TJ Holowaychuk\n * Copyright(c) 2015 Andreas Lubbe\n * Copyright(c) 2015 Tiancheng \"Timothy\" Gu\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module variables.\n * @private\n */\n\nvar matchHtmlRegExp = /[\"'&<>]/;\n\n/**\n * Module exports.\n * @public\n */\n\nmodule.exports = escapeHtml;\n\n/**\n * Escape special characters in the given string of html.\n *\n * @param {string} string The string to escape for inserting into HTML\n * @return {string}\n * @public\n */\n\nfunction escapeHtml(string) {\n var str = '' + string;\n var match = matchHtmlRegExp.exec(str);\n\n if (!match) {\n return str;\n }\n\n var escape;\n var html = '';\n var index = 0;\n var lastIndex = 0;\n\n for (index = match.index; index < str.length; index++) {\n switch (str.charCodeAt(index)) {\n case 34: // \"\n escape = '"';\n break;\n case 38: // &\n escape = '&';\n break;\n case 39: // '\n escape = ''';\n break;\n case 60: // <\n escape = '<';\n break;\n case 62: // >\n escape = '>';\n break;\n default:\n continue;\n }\n\n if (lastIndex !== index) {\n html += str.substring(lastIndex, index);\n }\n\n lastIndex = index + 1;\n html += escape;\n }\n\n return lastIndex !== index\n ? html + str.substring(lastIndex, index)\n : html;\n}\n", "/*!\n * clipboard.js v2.0.11\n * https://clipboardjs.com/\n *\n * Licensed MIT \u00A9 Zeno Rocha\n */\n(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ClipboardJS\"] = factory();\n\telse\n\t\troot[\"ClipboardJS\"] = factory();\n})(this, function() {\nreturn /******/ (function() { // webpackBootstrap\n/******/ \tvar __webpack_modules__ = ({\n\n/***/ 686:\n/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {\n\n\"use strict\";\n\n// EXPORTS\n__webpack_require__.d(__webpack_exports__, {\n \"default\": function() { return /* binding */ clipboard; }\n});\n\n// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js\nvar tiny_emitter = __webpack_require__(279);\nvar tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);\n// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js\nvar listen = __webpack_require__(370);\nvar listen_default = /*#__PURE__*/__webpack_require__.n(listen);\n// EXTERNAL MODULE: ./node_modules/select/src/select.js\nvar src_select = __webpack_require__(817);\nvar select_default = /*#__PURE__*/__webpack_require__.n(src_select);\n;// CONCATENATED MODULE: ./src/common/command.js\n/**\n * Executes a given operation type.\n * @param {String} type\n * @return {Boolean}\n */\nfunction command(type) {\n try {\n return document.execCommand(type);\n } catch (err) {\n return false;\n }\n}\n;// CONCATENATED MODULE: ./src/actions/cut.js\n\n\n/**\n * Cut action wrapper.\n * @param {String|HTMLElement} target\n * @return {String}\n */\n\nvar ClipboardActionCut = function ClipboardActionCut(target) {\n var selectedText = select_default()(target);\n command('cut');\n return selectedText;\n};\n\n/* harmony default export */ var actions_cut = (ClipboardActionCut);\n;// CONCATENATED MODULE: ./src/common/create-fake-element.js\n/**\n * Creates a fake textarea element with a value.\n * @param {String} value\n * @return {HTMLElement}\n */\nfunction createFakeElement(value) {\n var isRTL = document.documentElement.getAttribute('dir') === 'rtl';\n var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS\n\n fakeElement.style.fontSize = '12pt'; // Reset box model\n\n fakeElement.style.border = '0';\n fakeElement.style.padding = '0';\n fakeElement.style.margin = '0'; // Move element out of screen horizontally\n\n fakeElement.style.position = 'absolute';\n fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically\n\n var yPosition = window.pageYOffset || document.documentElement.scrollTop;\n fakeElement.style.top = \"\".concat(yPosition, \"px\");\n fakeElement.setAttribute('readonly', '');\n fakeElement.value = value;\n return fakeElement;\n}\n;// CONCATENATED MODULE: ./src/actions/copy.js\n\n\n\n/**\n * Create fake copy action wrapper using a fake element.\n * @param {String} target\n * @param {Object} options\n * @return {String}\n */\n\nvar fakeCopyAction = function fakeCopyAction(value, options) {\n var fakeElement = createFakeElement(value);\n options.container.appendChild(fakeElement);\n var selectedText = select_default()(fakeElement);\n command('copy');\n fakeElement.remove();\n return selectedText;\n};\n/**\n * Copy action wrapper.\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @return {String}\n */\n\n\nvar ClipboardActionCopy = function ClipboardActionCopy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n var selectedText = '';\n\n if (typeof target === 'string') {\n selectedText = fakeCopyAction(target, options);\n } else if (target instanceof HTMLInputElement && !['text', 'search', 'url', 'tel', 'password'].includes(target === null || target === void 0 ? void 0 : target.type)) {\n // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n selectedText = fakeCopyAction(target.value, options);\n } else {\n selectedText = select_default()(target);\n command('copy');\n }\n\n return selectedText;\n};\n\n/* harmony default export */ var actions_copy = (ClipboardActionCopy);\n;// CONCATENATED MODULE: ./src/actions/default.js\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\n\n\n/**\n * Inner function which performs selection from either `text` or `target`\n * properties and then executes copy or cut operations.\n * @param {Object} options\n */\n\nvar ClipboardActionDefault = function ClipboardActionDefault() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n // Defines base properties passed from constructor.\n var _options$action = options.action,\n action = _options$action === void 0 ? 'copy' : _options$action,\n container = options.container,\n target = options.target,\n text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'.\n\n if (action !== 'copy' && action !== 'cut') {\n throw new Error('Invalid \"action\" value, use either \"copy\" or \"cut\"');\n } // Sets the `target` property using an element that will be have its content copied.\n\n\n if (target !== undefined) {\n if (target && _typeof(target) === 'object' && target.nodeType === 1) {\n if (action === 'copy' && target.hasAttribute('disabled')) {\n throw new Error('Invalid \"target\" attribute. Please use \"readonly\" instead of \"disabled\" attribute');\n }\n\n if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {\n throw new Error('Invalid \"target\" attribute. You can\\'t cut text from elements with \"readonly\" or \"disabled\" attributes');\n }\n } else {\n throw new Error('Invalid \"target\" value, use a valid Element');\n }\n } // Define selection strategy based on `text` property.\n\n\n if (text) {\n return actions_copy(text, {\n container: container\n });\n } // Defines which selection strategy based on `target` property.\n\n\n if (target) {\n return action === 'cut' ? actions_cut(target) : actions_copy(target, {\n container: container\n });\n }\n};\n\n/* harmony default export */ var actions_default = (ClipboardActionDefault);\n;// CONCATENATED MODULE: ./src/clipboard.js\nfunction clipboard_typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return clipboard_typeof(obj); }\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nfunction _inherits(subClass, superClass) { if (typeof superClass !== \"function\" && superClass !== null) { throw new TypeError(\"Super expression must either be null or a function\"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }\n\nfunction _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }\n\nfunction _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }\n\nfunction _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === \"object\" || typeof call === \"function\")) { return call; } return _assertThisInitialized(self); }\n\nfunction _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\"); } return self; }\n\nfunction _isNativeReflectConstruct() { if (typeof Reflect === \"undefined\" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === \"function\") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }\n\nfunction _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }\n\n\n\n\n\n\n/**\n * Helper function to retrieve attribute value.\n * @param {String} suffix\n * @param {Element} element\n */\n\nfunction getAttributeValue(suffix, element) {\n var attribute = \"data-clipboard-\".concat(suffix);\n\n if (!element.hasAttribute(attribute)) {\n return;\n }\n\n return element.getAttribute(attribute);\n}\n/**\n * Base class which takes one or more elements, adds event listeners to them,\n * and instantiates a new `ClipboardAction` on each click.\n */\n\n\nvar Clipboard = /*#__PURE__*/function (_Emitter) {\n _inherits(Clipboard, _Emitter);\n\n var _super = _createSuper(Clipboard);\n\n /**\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n * @param {Object} options\n */\n function Clipboard(trigger, options) {\n var _this;\n\n _classCallCheck(this, Clipboard);\n\n _this = _super.call(this);\n\n _this.resolveOptions(options);\n\n _this.listenClick(trigger);\n\n return _this;\n }\n /**\n * Defines if attributes would be resolved using internal setter functions\n * or custom functions that were passed in the constructor.\n * @param {Object} options\n */\n\n\n _createClass(Clipboard, [{\n key: \"resolveOptions\",\n value: function resolveOptions() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n this.action = typeof options.action === 'function' ? options.action : this.defaultAction;\n this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;\n this.text = typeof options.text === 'function' ? options.text : this.defaultText;\n this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;\n }\n /**\n * Adds a click event listener to the passed trigger.\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n */\n\n }, {\n key: \"listenClick\",\n value: function listenClick(trigger) {\n var _this2 = this;\n\n this.listener = listen_default()(trigger, 'click', function (e) {\n return _this2.onClick(e);\n });\n }\n /**\n * Defines a new `ClipboardAction` on each click event.\n * @param {Event} e\n */\n\n }, {\n key: \"onClick\",\n value: function onClick(e) {\n var trigger = e.delegateTarget || e.currentTarget;\n var action = this.action(trigger) || 'copy';\n var text = actions_default({\n action: action,\n container: this.container,\n target: this.target(trigger),\n text: this.text(trigger)\n }); // Fires an event based on the copy operation result.\n\n this.emit(text ? 'success' : 'error', {\n action: action,\n text: text,\n trigger: trigger,\n clearSelection: function clearSelection() {\n if (trigger) {\n trigger.focus();\n }\n\n window.getSelection().removeAllRanges();\n }\n });\n }\n /**\n * Default `action` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultAction\",\n value: function defaultAction(trigger) {\n return getAttributeValue('action', trigger);\n }\n /**\n * Default `target` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultTarget\",\n value: function defaultTarget(trigger) {\n var selector = getAttributeValue('target', trigger);\n\n if (selector) {\n return document.querySelector(selector);\n }\n }\n /**\n * Allow fire programmatically a copy action\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @returns Text copied.\n */\n\n }, {\n key: \"defaultText\",\n\n /**\n * Default `text` lookup function.\n * @param {Element} trigger\n */\n value: function defaultText(trigger) {\n return getAttributeValue('text', trigger);\n }\n /**\n * Destroy lifecycle.\n */\n\n }, {\n key: \"destroy\",\n value: function destroy() {\n this.listener.destroy();\n }\n }], [{\n key: \"copy\",\n value: function copy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n return actions_copy(target, options);\n }\n /**\n * Allow fire programmatically a cut action\n * @param {String|HTMLElement} target\n * @returns Text cutted.\n */\n\n }, {\n key: \"cut\",\n value: function cut(target) {\n return actions_cut(target);\n }\n /**\n * Returns the support of the given action, or all actions if no action is\n * given.\n * @param {String} [action]\n */\n\n }, {\n key: \"isSupported\",\n value: function isSupported() {\n var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];\n var actions = typeof action === 'string' ? [action] : action;\n var support = !!document.queryCommandSupported;\n actions.forEach(function (action) {\n support = support && !!document.queryCommandSupported(action);\n });\n return support;\n }\n }]);\n\n return Clipboard;\n}((tiny_emitter_default()));\n\n/* harmony default export */ var clipboard = (Clipboard);\n\n/***/ }),\n\n/***/ 828:\n/***/ (function(module) {\n\nvar DOCUMENT_NODE_TYPE = 9;\n\n/**\n * A polyfill for Element.matches()\n */\nif (typeof Element !== 'undefined' && !Element.prototype.matches) {\n var proto = Element.prototype;\n\n proto.matches = proto.matchesSelector ||\n proto.mozMatchesSelector ||\n proto.msMatchesSelector ||\n proto.oMatchesSelector ||\n proto.webkitMatchesSelector;\n}\n\n/**\n * Finds the closest parent that matches a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @return {Function}\n */\nfunction closest (element, selector) {\n while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {\n if (typeof element.matches === 'function' &&\n element.matches(selector)) {\n return element;\n }\n element = element.parentNode;\n }\n}\n\nmodule.exports = closest;\n\n\n/***/ }),\n\n/***/ 438:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar closest = __webpack_require__(828);\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction _delegate(element, selector, type, callback, useCapture) {\n var listenerFn = listener.apply(this, arguments);\n\n element.addEventListener(type, listenerFn, useCapture);\n\n return {\n destroy: function() {\n element.removeEventListener(type, listenerFn, useCapture);\n }\n }\n}\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element|String|Array} [elements]\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction delegate(elements, selector, type, callback, useCapture) {\n // Handle the regular Element usage\n if (typeof elements.addEventListener === 'function') {\n return _delegate.apply(null, arguments);\n }\n\n // Handle Element-less usage, it defaults to global delegation\n if (typeof type === 'function') {\n // Use `document` as the first parameter, then apply arguments\n // This is a short way to .unshift `arguments` without running into deoptimizations\n return _delegate.bind(null, document).apply(null, arguments);\n }\n\n // Handle Selector-based usage\n if (typeof elements === 'string') {\n elements = document.querySelectorAll(elements);\n }\n\n // Handle Array-like based usage\n return Array.prototype.map.call(elements, function (element) {\n return _delegate(element, selector, type, callback, useCapture);\n });\n}\n\n/**\n * Finds closest match and invokes callback.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Function}\n */\nfunction listener(element, selector, type, callback) {\n return function(e) {\n e.delegateTarget = closest(e.target, selector);\n\n if (e.delegateTarget) {\n callback.call(element, e);\n }\n }\n}\n\nmodule.exports = delegate;\n\n\n/***/ }),\n\n/***/ 879:\n/***/ (function(__unused_webpack_module, exports) {\n\n/**\n * Check if argument is a HTML element.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.node = function(value) {\n return value !== undefined\n && value instanceof HTMLElement\n && value.nodeType === 1;\n};\n\n/**\n * Check if argument is a list of HTML elements.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.nodeList = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return value !== undefined\n && (type === '[object NodeList]' || type === '[object HTMLCollection]')\n && ('length' in value)\n && (value.length === 0 || exports.node(value[0]));\n};\n\n/**\n * Check if argument is a string.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.string = function(value) {\n return typeof value === 'string'\n || value instanceof String;\n};\n\n/**\n * Check if argument is a function.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.fn = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return type === '[object Function]';\n};\n\n\n/***/ }),\n\n/***/ 370:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar is = __webpack_require__(879);\nvar delegate = __webpack_require__(438);\n\n/**\n * Validates all params and calls the right\n * listener function based on its target type.\n *\n * @param {String|HTMLElement|HTMLCollection|NodeList} target\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listen(target, type, callback) {\n if (!target && !type && !callback) {\n throw new Error('Missing required arguments');\n }\n\n if (!is.string(type)) {\n throw new TypeError('Second argument must be a String');\n }\n\n if (!is.fn(callback)) {\n throw new TypeError('Third argument must be a Function');\n }\n\n if (is.node(target)) {\n return listenNode(target, type, callback);\n }\n else if (is.nodeList(target)) {\n return listenNodeList(target, type, callback);\n }\n else if (is.string(target)) {\n return listenSelector(target, type, callback);\n }\n else {\n throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');\n }\n}\n\n/**\n * Adds an event listener to a HTML element\n * and returns a remove listener function.\n *\n * @param {HTMLElement} node\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNode(node, type, callback) {\n node.addEventListener(type, callback);\n\n return {\n destroy: function() {\n node.removeEventListener(type, callback);\n }\n }\n}\n\n/**\n * Add an event listener to a list of HTML elements\n * and returns a remove listener function.\n *\n * @param {NodeList|HTMLCollection} nodeList\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNodeList(nodeList, type, callback) {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.addEventListener(type, callback);\n });\n\n return {\n destroy: function() {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.removeEventListener(type, callback);\n });\n }\n }\n}\n\n/**\n * Add an event listener to a selector\n * and returns a remove listener function.\n *\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenSelector(selector, type, callback) {\n return delegate(document.body, selector, type, callback);\n}\n\nmodule.exports = listen;\n\n\n/***/ }),\n\n/***/ 817:\n/***/ (function(module) {\n\nfunction select(element) {\n var selectedText;\n\n if (element.nodeName === 'SELECT') {\n element.focus();\n\n selectedText = element.value;\n }\n else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {\n var isReadOnly = element.hasAttribute('readonly');\n\n if (!isReadOnly) {\n element.setAttribute('readonly', '');\n }\n\n element.select();\n element.setSelectionRange(0, element.value.length);\n\n if (!isReadOnly) {\n element.removeAttribute('readonly');\n }\n\n selectedText = element.value;\n }\n else {\n if (element.hasAttribute('contenteditable')) {\n element.focus();\n }\n\n var selection = window.getSelection();\n var range = document.createRange();\n\n range.selectNodeContents(element);\n selection.removeAllRanges();\n selection.addRange(range);\n\n selectedText = selection.toString();\n }\n\n return selectedText;\n}\n\nmodule.exports = select;\n\n\n/***/ }),\n\n/***/ 279:\n/***/ (function(module) {\n\nfunction E () {\n // Keep this empty so it's easier to inherit from\n // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)\n}\n\nE.prototype = {\n on: function (name, callback, ctx) {\n var e = this.e || (this.e = {});\n\n (e[name] || (e[name] = [])).push({\n fn: callback,\n ctx: ctx\n });\n\n return this;\n },\n\n once: function (name, callback, ctx) {\n var self = this;\n function listener () {\n self.off(name, listener);\n callback.apply(ctx, arguments);\n };\n\n listener._ = callback\n return this.on(name, listener, ctx);\n },\n\n emit: function (name) {\n var data = [].slice.call(arguments, 1);\n var evtArr = ((this.e || (this.e = {}))[name] || []).slice();\n var i = 0;\n var len = evtArr.length;\n\n for (i; i < len; i++) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n },\n\n off: function (name, callback) {\n var e = this.e || (this.e = {});\n var evts = e[name];\n var liveEvents = [];\n\n if (evts && callback) {\n for (var i = 0, len = evts.length; i < len; i++) {\n if (evts[i].fn !== callback && evts[i].fn._ !== callback)\n liveEvents.push(evts[i]);\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n (liveEvents.length)\n ? e[name] = liveEvents\n : delete e[name];\n\n return this;\n }\n};\n\nmodule.exports = E;\nmodule.exports.TinyEmitter = E;\n\n\n/***/ })\n\n/******/ \t});\n/************************************************************************/\n/******/ \t// The module cache\n/******/ \tvar __webpack_module_cache__ = {};\n/******/ \t\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(__webpack_module_cache__[moduleId]) {\n/******/ \t\t\treturn __webpack_module_cache__[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = __webpack_module_cache__[moduleId] = {\n/******/ \t\t\t// no module.id needed\n/******/ \t\t\t// no module.loaded needed\n/******/ \t\t\texports: {}\n/******/ \t\t};\n/******/ \t\n/******/ \t\t// Execute the module function\n/******/ \t\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n/******/ \t\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/ \t\n/************************************************************************/\n/******/ \t/* webpack/runtime/compat get default export */\n/******/ \t!function() {\n/******/ \t\t// getDefaultExport function for compatibility with non-harmony modules\n/******/ \t\t__webpack_require__.n = function(module) {\n/******/ \t\t\tvar getter = module && module.__esModule ?\n/******/ \t\t\t\tfunction() { return module['default']; } :\n/******/ \t\t\t\tfunction() { return module; };\n/******/ \t\t\t__webpack_require__.d(getter, { a: getter });\n/******/ \t\t\treturn getter;\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/define property getters */\n/******/ \t!function() {\n/******/ \t\t// define getter functions for harmony exports\n/******/ \t\t__webpack_require__.d = function(exports, definition) {\n/******/ \t\t\tfor(var key in definition) {\n/******/ \t\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n/******/ \t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n/******/ \t\t\t\t}\n/******/ \t\t\t}\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/hasOwnProperty shorthand */\n/******/ \t!function() {\n/******/ \t\t__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }\n/******/ \t}();\n/******/ \t\n/************************************************************************/\n/******/ \t// module exports must be returned from runtime so entry inlining is disabled\n/******/ \t// startup\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(686);\n/******/ })()\n.default;\n});", "/*\n * Copyright (c) 2016-2025 Martin Donath \n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\nimport \"focus-visible\"\n\nimport {\n EMPTY,\n NEVER,\n Observable,\n Subject,\n defer,\n delay,\n filter,\n map,\n merge,\n mergeWith,\n shareReplay,\n switchMap\n} from \"rxjs\"\n\nimport { configuration, feature } from \"./_\"\nimport {\n at,\n getActiveElement,\n getOptionalElement,\n requestJSON,\n setLocation,\n setToggle,\n watchDocument,\n watchKeyboard,\n watchLocation,\n watchLocationTarget,\n watchMedia,\n watchPrint,\n watchScript,\n watchViewport\n} from \"./browser\"\nimport {\n getComponentElement,\n getComponentElements,\n mountAnnounce,\n mountBackToTop,\n mountConsent,\n mountContent,\n mountDialog,\n mountHeader,\n mountHeaderTitle,\n mountPalette,\n mountProgress,\n mountSearch,\n mountSearchHiglight,\n mountSidebar,\n mountSource,\n mountTableOfContents,\n mountTabs,\n watchHeader,\n watchMain\n} from \"./components\"\nimport {\n SearchIndex,\n fetchSitemap,\n setupAlternate,\n setupClipboardJS,\n setupInstantNavigation,\n setupVersionSelector\n} from \"./integrations\"\nimport {\n patchEllipsis,\n patchIndeterminate,\n patchScrollfix,\n patchScrolllock\n} from \"./patches\"\nimport \"./polyfills\"\n\n/* ----------------------------------------------------------------------------\n * Functions - @todo refactor\n * ------------------------------------------------------------------------- */\n\n/**\n * Fetch search index\n *\n * @returns Search index observable\n */\nfunction fetchSearchIndex(): Observable {\n if (location.protocol === \"file:\") {\n return watchScript(\n `${new URL(\"search/search_index.js\", config.base)}`\n )\n .pipe(\n // @ts-ignore - @todo fix typings\n map(() => __index),\n shareReplay(1)\n )\n } else {\n return requestJSON(\n new URL(\"search/search_index.json\", config.base)\n )\n }\n}\n\n/* ----------------------------------------------------------------------------\n * Application\n * ------------------------------------------------------------------------- */\n\n/* Yay, JavaScript is available */\ndocument.documentElement.classList.remove(\"no-js\")\ndocument.documentElement.classList.add(\"js\")\n\n/* Set up navigation observables and subjects */\nconst document$ = watchDocument()\nconst location$ = watchLocation()\nconst target$ = watchLocationTarget(location$)\nconst keyboard$ = watchKeyboard()\n\n/* Set up media observables */\nconst viewport$ = watchViewport()\nconst tablet$ = watchMedia(\"(min-width: 60em)\")\nconst screen$ = watchMedia(\"(min-width: 76.25em)\")\nconst print$ = watchPrint()\n\n/* Retrieve search index, if search is enabled */\nconst config = configuration()\nconst index$ = document.forms.namedItem(\"search\")\n ? fetchSearchIndex()\n : NEVER\n\n/* Set up Clipboard.js integration */\nconst alert$ = new Subject()\nsetupClipboardJS({ alert$ })\n\n/* Set up language selector */\nsetupAlternate({ document$ })\n\n/* Set up progress indicator */\nconst progress$ = new Subject()\n\n/* Set up sitemap for instant navigation and previews */\nconst sitemap$ = fetchSitemap(config.base)\n\n/* Set up instant navigation, if enabled */\nif (feature(\"navigation.instant\"))\n setupInstantNavigation({ sitemap$, location$, viewport$, progress$ })\n .subscribe(document$)\n\n/* Set up version selector */\nif (config.version?.provider === \"mike\")\n setupVersionSelector({ document$ })\n\n/* Always close drawer and search on navigation */\nmerge(location$, target$)\n .pipe(\n delay(125)\n )\n .subscribe(() => {\n setToggle(\"drawer\", false)\n setToggle(\"search\", false)\n })\n\n/* Set up global keyboard handlers */\nkeyboard$\n .pipe(\n filter(({ mode }) => mode === \"global\")\n )\n .subscribe(key => {\n switch (key.type) {\n\n /* Go to previous page */\n case \"p\":\n case \",\":\n const prev = getOptionalElement(\"link[rel=prev]\")\n if (typeof prev !== \"undefined\")\n setLocation(prev)\n break\n\n /* Go to next page */\n case \"n\":\n case \".\":\n const next = getOptionalElement(\"link[rel=next]\")\n if (typeof next !== \"undefined\")\n setLocation(next)\n break\n\n /* Expand navigation, see https://bit.ly/3ZjG5io */\n case \"Enter\":\n const active = getActiveElement()\n if (active instanceof HTMLLabelElement)\n active.click()\n }\n })\n\n/* Set up patches */\npatchEllipsis({ viewport$, document$ })\npatchIndeterminate({ document$, tablet$ })\npatchScrollfix({ document$ })\npatchScrolllock({ viewport$, tablet$ })\n\n/* Set up header and main area observable */\nconst header$ = watchHeader(getComponentElement(\"header\"), { viewport$ })\nconst main$ = document$\n .pipe(\n map(() => getComponentElement(\"main\")),\n switchMap(el => watchMain(el, { viewport$, header$ })),\n shareReplay(1)\n )\n\n/* Set up control component observables */\nconst control$ = merge(\n\n /* Consent */\n ...getComponentElements(\"consent\")\n .map(el => mountConsent(el, { target$ })),\n\n /* Dialog */\n ...getComponentElements(\"dialog\")\n .map(el => mountDialog(el, { alert$ })),\n\n /* Color palette */\n ...getComponentElements(\"palette\")\n .map(el => mountPalette(el)),\n\n /* Progress bar */\n ...getComponentElements(\"progress\")\n .map(el => mountProgress(el, { progress$ })),\n\n /* Search */\n ...getComponentElements(\"search\")\n .map(el => mountSearch(el, { index$, keyboard$ })),\n\n /* Repository information */\n ...getComponentElements(\"source\")\n .map(el => mountSource(el))\n)\n\n/* Set up content component observables */\nconst content$ = defer(() => merge(\n\n /* Announcement bar */\n ...getComponentElements(\"announce\")\n .map(el => mountAnnounce(el)),\n\n /* Content */\n ...getComponentElements(\"content\")\n .map(el => mountContent(el, { sitemap$, viewport$, target$, print$ })),\n\n /* Search highlighting */\n ...getComponentElements(\"content\")\n .map(el => feature(\"search.highlight\")\n ? mountSearchHiglight(el, { index$, location$ })\n : EMPTY\n ),\n\n /* Header */\n ...getComponentElements(\"header\")\n .map(el => mountHeader(el, { viewport$, header$, main$ })),\n\n /* Header title */\n ...getComponentElements(\"header-title\")\n .map(el => mountHeaderTitle(el, { viewport$, header$ })),\n\n /* Sidebar */\n ...getComponentElements(\"sidebar\")\n .map(el => el.getAttribute(\"data-md-type\") === \"navigation\"\n ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))\n : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))\n ),\n\n /* Navigation tabs */\n ...getComponentElements(\"tabs\")\n .map(el => mountTabs(el, { viewport$, header$ })),\n\n /* Table of contents */\n ...getComponentElements(\"toc\")\n .map(el => mountTableOfContents(el, {\n viewport$, header$, main$, target$\n })),\n\n /* Back-to-top button */\n ...getComponentElements(\"top\")\n .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))\n))\n\n/* Set up component observables */\nconst component$ = document$\n .pipe(\n switchMap(() => content$),\n mergeWith(control$),\n shareReplay(1)\n )\n\n/* Subscribe to all components */\ncomponent$.subscribe()\n\n/* ----------------------------------------------------------------------------\n * Exports\n * ------------------------------------------------------------------------- */\n\nwindow.document$ = document$ /* Document observable */\nwindow.location$ = location$ /* Location subject */\nwindow.target$ = target$ /* Location target observable */\nwindow.keyboard$ = keyboard$ /* Keyboard observable */\nwindow.viewport$ = viewport$ /* Viewport observable */\nwindow.tablet$ = tablet$ /* Media tablet observable */\nwindow.screen$ = screen$ /* Media screen observable */\nwindow.print$ = print$ /* Media print observable */\nwindow.alert$ = alert$ /* Alert subject */\nwindow.progress$ = progress$ /* Progress indicator subject */\nwindow.component$ = component$ /* Component observable */\n", "/******************************************************************************\nCopyright (c) Microsoft Corporation.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n***************************************************************************** */\n/* global Reflect, Promise, SuppressedError, Symbol, Iterator */\n\nvar extendStatics = function(d, b) {\n extendStatics = Object.setPrototypeOf ||\n ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||\n function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };\n return extendStatics(d, b);\n};\n\nexport function __extends(d, b) {\n if (typeof b !== \"function\" && b !== null)\n throw new TypeError(\"Class extends value \" + String(b) + \" is not a constructor or null\");\n extendStatics(d, b);\n function __() { this.constructor = d; }\n d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());\n}\n\nexport var __assign = function() {\n __assign = Object.assign || function __assign(t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n }\n return __assign.apply(this, arguments);\n}\n\nexport function __rest(s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)\n t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\")\n for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))\n t[p[i]] = s[p[i]];\n }\n return t;\n}\n\nexport function __decorate(decorators, target, key, desc) {\n var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;\n if (typeof Reflect === \"object\" && typeof Reflect.decorate === \"function\") r = Reflect.decorate(decorators, target, key, desc);\n else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;\n return c > 3 && r && Object.defineProperty(target, key, r), r;\n}\n\nexport function __param(paramIndex, decorator) {\n return function (target, key) { decorator(target, key, paramIndex); }\n}\n\nexport function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {\n function accept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; }\n var kind = contextIn.kind, key = kind === \"getter\" ? \"get\" : kind === \"setter\" ? \"set\" : \"value\";\n var target = !descriptorIn && ctor ? contextIn[\"static\"] ? ctor : ctor.prototype : null;\n var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});\n var _, done = false;\n for (var i = decorators.length - 1; i >= 0; i--) {\n var context = {};\n for (var p in contextIn) context[p] = p === \"access\" ? {} : contextIn[p];\n for (var p in contextIn.access) context.access[p] = contextIn.access[p];\n context.addInitializer = function (f) { if (done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); extraInitializers.push(accept(f || null)); };\n var result = (0, decorators[i])(kind === \"accessor\" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);\n if (kind === \"accessor\") {\n if (result === void 0) continue;\n if (result === null || typeof result !== \"object\") throw new TypeError(\"Object expected\");\n if (_ = accept(result.get)) descriptor.get = _;\n if (_ = accept(result.set)) descriptor.set = _;\n if (_ = accept(result.init)) initializers.unshift(_);\n }\n else if (_ = accept(result)) {\n if (kind === \"field\") initializers.unshift(_);\n else descriptor[key] = _;\n }\n }\n if (target) Object.defineProperty(target, contextIn.name, descriptor);\n done = true;\n};\n\nexport function __runInitializers(thisArg, initializers, value) {\n var useValue = arguments.length > 2;\n for (var i = 0; i < initializers.length; i++) {\n value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);\n }\n return useValue ? value : void 0;\n};\n\nexport function __propKey(x) {\n return typeof x === \"symbol\" ? x : \"\".concat(x);\n};\n\nexport function __setFunctionName(f, name, prefix) {\n if (typeof name === \"symbol\") name = name.description ? \"[\".concat(name.description, \"]\") : \"\";\n return Object.defineProperty(f, \"name\", { configurable: true, value: prefix ? \"\".concat(prefix, \" \", name) : name });\n};\n\nexport function __metadata(metadataKey, metadataValue) {\n if (typeof Reflect === \"object\" && typeof Reflect.metadata === \"function\") return Reflect.metadata(metadataKey, metadataValue);\n}\n\nexport function __awaiter(thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n}\n\nexport function __generator(thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === \"function\" ? Iterator : Object).prototype);\n return g.next = verb(0), g[\"throw\"] = verb(1), g[\"return\"] = verb(2), typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n}\n\nexport var __createBinding = Object.create ? (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n var desc = Object.getOwnPropertyDescriptor(m, k);\n if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n desc = { enumerable: true, get: function() { return m[k]; } };\n }\n Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n o[k2] = m[k];\n});\n\nexport function __exportStar(m, o) {\n for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);\n}\n\nexport function __values(o) {\n var s = typeof Symbol === \"function\" && Symbol.iterator, m = s && o[s], i = 0;\n if (m) return m.call(o);\n if (o && typeof o.length === \"number\") return {\n next: function () {\n if (o && i >= o.length) o = void 0;\n return { value: o && o[i++], done: !o };\n }\n };\n throw new TypeError(s ? \"Object is not iterable.\" : \"Symbol.iterator is not defined.\");\n}\n\nexport function __read(o, n) {\n var m = typeof Symbol === \"function\" && o[Symbol.iterator];\n if (!m) return o;\n var i = m.call(o), r, ar = [], e;\n try {\n while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);\n }\n catch (error) { e = { error: error }; }\n finally {\n try {\n if (r && !r.done && (m = i[\"return\"])) m.call(i);\n }\n finally { if (e) throw e.error; }\n }\n return ar;\n}\n\n/** @deprecated */\nexport function __spread() {\n for (var ar = [], i = 0; i < arguments.length; i++)\n ar = ar.concat(__read(arguments[i]));\n return ar;\n}\n\n/** @deprecated */\nexport function __spreadArrays() {\n for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;\n for (var r = Array(s), k = 0, i = 0; i < il; i++)\n for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)\n r[k] = a[j];\n return r;\n}\n\nexport function __spreadArray(to, from, pack) {\n if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {\n if (ar || !(i in from)) {\n if (!ar) ar = Array.prototype.slice.call(from, 0, i);\n ar[i] = from[i];\n }\n }\n return to.concat(ar || Array.prototype.slice.call(from));\n}\n\nexport function __await(v) {\n return this instanceof __await ? (this.v = v, this) : new __await(v);\n}\n\nexport function __asyncGenerator(thisArg, _arguments, generator) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var g = generator.apply(thisArg, _arguments || []), i, q = [];\n return i = Object.create((typeof AsyncIterator === \"function\" ? AsyncIterator : Object).prototype), verb(\"next\"), verb(\"throw\"), verb(\"return\", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;\n function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }\n function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }\n function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }\n function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }\n function fulfill(value) { resume(\"next\", value); }\n function reject(value) { resume(\"throw\", value); }\n function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }\n}\n\nexport function __asyncDelegator(o) {\n var i, p;\n return i = {}, verb(\"next\"), verb(\"throw\", function (e) { throw e; }), verb(\"return\"), i[Symbol.iterator] = function () { return this; }, i;\n function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }\n}\n\nexport function __asyncValues(o) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var m = o[Symbol.asyncIterator], i;\n return m ? m.call(o) : (o = typeof __values === \"function\" ? __values(o) : o[Symbol.iterator](), i = {}, verb(\"next\"), verb(\"throw\"), verb(\"return\"), i[Symbol.asyncIterator] = function () { return this; }, i);\n function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }\n function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }\n}\n\nexport function __makeTemplateObject(cooked, raw) {\n if (Object.defineProperty) { Object.defineProperty(cooked, \"raw\", { value: raw }); } else { cooked.raw = raw; }\n return cooked;\n};\n\nvar __setModuleDefault = Object.create ? (function(o, v) {\n Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n o[\"default\"] = v;\n};\n\nexport function __importStar(mod) {\n if (mod && mod.__esModule) return mod;\n var result = {};\n if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n __setModuleDefault(result, mod);\n return result;\n}\n\nexport function __importDefault(mod) {\n return (mod && mod.__esModule) ? mod : { default: mod };\n}\n\nexport function __classPrivateFieldGet(receiver, state, kind, f) {\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a getter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot read private member from an object whose class did not declare it\");\n return kind === \"m\" ? f : kind === \"a\" ? f.call(receiver) : f ? f.value : state.get(receiver);\n}\n\nexport function __classPrivateFieldSet(receiver, state, value, kind, f) {\n if (kind === \"m\") throw new TypeError(\"Private method is not writable\");\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a setter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot write private member to an object whose class did not declare it\");\n return (kind === \"a\" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;\n}\n\nexport function __classPrivateFieldIn(state, receiver) {\n if (receiver === null || (typeof receiver !== \"object\" && typeof receiver !== \"function\")) throw new TypeError(\"Cannot use 'in' operator on non-object\");\n return typeof state === \"function\" ? receiver === state : state.has(receiver);\n}\n\nexport function __addDisposableResource(env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n}\n\nvar _SuppressedError = typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n};\n\nexport function __disposeResources(env) {\n function fail(e) {\n env.error = env.hasError ? new _SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n}\n\nexport default {\n __extends,\n __assign,\n __rest,\n __decorate,\n __param,\n __metadata,\n __awaiter,\n __generator,\n __createBinding,\n __exportStar,\n __values,\n __read,\n __spread,\n __spreadArrays,\n __spreadArray,\n __await,\n __asyncGenerator,\n __asyncDelegator,\n __asyncValues,\n __makeTemplateObject,\n __importStar,\n __importDefault,\n __classPrivateFieldGet,\n __classPrivateFieldSet,\n __classPrivateFieldIn,\n __addDisposableResource,\n __disposeResources,\n};\n", "/**\n * Returns true if the object is a function.\n * @param value The value to check\n */\nexport function isFunction(value: any): value is (...args: any[]) => any {\n return typeof value === 'function';\n}\n", "/**\n * Used to create Error subclasses until the community moves away from ES5.\n *\n * This is because compiling from TypeScript down to ES5 has issues with subclassing Errors\n * as well as other built-in types: https://github.com/Microsoft/TypeScript/issues/12123\n *\n * @param createImpl A factory function to create the actual constructor implementation. The returned\n * function should be a named function that calls `_super` internally.\n */\nexport function createErrorClass(createImpl: (_super: any) => any): T {\n const _super = (instance: any) => {\n Error.call(instance);\n instance.stack = new Error().stack;\n };\n\n const ctorFunc = createImpl(_super);\n ctorFunc.prototype = Object.create(Error.prototype);\n ctorFunc.prototype.constructor = ctorFunc;\n return ctorFunc;\n}\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface UnsubscriptionError extends Error {\n readonly errors: any[];\n}\n\nexport interface UnsubscriptionErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (errors: any[]): UnsubscriptionError;\n}\n\n/**\n * An error thrown when one or more errors have occurred during the\n * `unsubscribe` of a {@link Subscription}.\n */\nexport const UnsubscriptionError: UnsubscriptionErrorCtor = createErrorClass(\n (_super) =>\n function UnsubscriptionErrorImpl(this: any, errors: (Error | string)[]) {\n _super(this);\n this.message = errors\n ? `${errors.length} errors occurred during unsubscription:\n${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\\n ')}`\n : '';\n this.name = 'UnsubscriptionError';\n this.errors = errors;\n }\n);\n", "/**\n * Removes an item from an array, mutating it.\n * @param arr The array to remove the item from\n * @param item The item to remove\n */\nexport function arrRemove(arr: T[] | undefined | null, item: T) {\n if (arr) {\n const index = arr.indexOf(item);\n 0 <= index && arr.splice(index, 1);\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { UnsubscriptionError } from './util/UnsubscriptionError';\nimport { SubscriptionLike, TeardownLogic, Unsubscribable } from './types';\nimport { arrRemove } from './util/arrRemove';\n\n/**\n * Represents a disposable resource, such as the execution of an Observable. A\n * Subscription has one important method, `unsubscribe`, that takes no argument\n * and just disposes the resource held by the subscription.\n *\n * Additionally, subscriptions may be grouped together through the `add()`\n * method, which will attach a child Subscription to the current Subscription.\n * When a Subscription is unsubscribed, all its children (and its grandchildren)\n * will be unsubscribed as well.\n */\nexport class Subscription implements SubscriptionLike {\n public static EMPTY = (() => {\n const empty = new Subscription();\n empty.closed = true;\n return empty;\n })();\n\n /**\n * A flag to indicate whether this Subscription has already been unsubscribed.\n */\n public closed = false;\n\n private _parentage: Subscription[] | Subscription | null = null;\n\n /**\n * The list of registered finalizers to execute upon unsubscription. Adding and removing from this\n * list occurs in the {@link #add} and {@link #remove} methods.\n */\n private _finalizers: Exclude[] | null = null;\n\n /**\n * @param initialTeardown A function executed first as part of the finalization\n * process that is kicked off when {@link #unsubscribe} is called.\n */\n constructor(private initialTeardown?: () => void) {}\n\n /**\n * Disposes the resources held by the subscription. May, for instance, cancel\n * an ongoing Observable execution or cancel any other type of work that\n * started when the Subscription was created.\n */\n unsubscribe(): void {\n let errors: any[] | undefined;\n\n if (!this.closed) {\n this.closed = true;\n\n // Remove this from it's parents.\n const { _parentage } = this;\n if (_parentage) {\n this._parentage = null;\n if (Array.isArray(_parentage)) {\n for (const parent of _parentage) {\n parent.remove(this);\n }\n } else {\n _parentage.remove(this);\n }\n }\n\n const { initialTeardown: initialFinalizer } = this;\n if (isFunction(initialFinalizer)) {\n try {\n initialFinalizer();\n } catch (e) {\n errors = e instanceof UnsubscriptionError ? e.errors : [e];\n }\n }\n\n const { _finalizers } = this;\n if (_finalizers) {\n this._finalizers = null;\n for (const finalizer of _finalizers) {\n try {\n execFinalizer(finalizer);\n } catch (err) {\n errors = errors ?? [];\n if (err instanceof UnsubscriptionError) {\n errors = [...errors, ...err.errors];\n } else {\n errors.push(err);\n }\n }\n }\n }\n\n if (errors) {\n throw new UnsubscriptionError(errors);\n }\n }\n }\n\n /**\n * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called\n * when this subscription is unsubscribed. If this subscription is already {@link #closed},\n * because it has already been unsubscribed, then whatever finalizer is passed to it\n * will automatically be executed (unless the finalizer itself is also a closed subscription).\n *\n * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed\n * subscription to a any subscription will result in no operation. (A noop).\n *\n * Adding a subscription to itself, or adding `null` or `undefined` will not perform any\n * operation at all. (A noop).\n *\n * `Subscription` instances that are added to this instance will automatically remove themselves\n * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove\n * will need to be removed manually with {@link #remove}\n *\n * @param teardown The finalization logic to add to this subscription.\n */\n add(teardown: TeardownLogic): void {\n // Only add the finalizer if it's not undefined\n // and don't add a subscription to itself.\n if (teardown && teardown !== this) {\n if (this.closed) {\n // If this subscription is already closed,\n // execute whatever finalizer is handed to it automatically.\n execFinalizer(teardown);\n } else {\n if (teardown instanceof Subscription) {\n // We don't add closed subscriptions, and we don't add the same subscription\n // twice. Subscription unsubscribe is idempotent.\n if (teardown.closed || teardown._hasParent(this)) {\n return;\n }\n teardown._addParent(this);\n }\n (this._finalizers = this._finalizers ?? []).push(teardown);\n }\n }\n }\n\n /**\n * Checks to see if a this subscription already has a particular parent.\n * This will signal that this subscription has already been added to the parent in question.\n * @param parent the parent to check for\n */\n private _hasParent(parent: Subscription) {\n const { _parentage } = this;\n return _parentage === parent || (Array.isArray(_parentage) && _parentage.includes(parent));\n }\n\n /**\n * Adds a parent to this subscription so it can be removed from the parent if it\n * unsubscribes on it's own.\n *\n * NOTE: THIS ASSUMES THAT {@link _hasParent} HAS ALREADY BEEN CHECKED.\n * @param parent The parent subscription to add\n */\n private _addParent(parent: Subscription) {\n const { _parentage } = this;\n this._parentage = Array.isArray(_parentage) ? (_parentage.push(parent), _parentage) : _parentage ? [_parentage, parent] : parent;\n }\n\n /**\n * Called on a child when it is removed via {@link #remove}.\n * @param parent The parent to remove\n */\n private _removeParent(parent: Subscription) {\n const { _parentage } = this;\n if (_parentage === parent) {\n this._parentage = null;\n } else if (Array.isArray(_parentage)) {\n arrRemove(_parentage, parent);\n }\n }\n\n /**\n * Removes a finalizer from this subscription that was previously added with the {@link #add} method.\n *\n * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves\n * from every other `Subscription` they have been added to. This means that using the `remove` method\n * is not a common thing and should be used thoughtfully.\n *\n * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance\n * more than once, you will need to call `remove` the same number of times to remove all instances.\n *\n * All finalizer instances are removed to free up memory upon unsubscription.\n *\n * @param teardown The finalizer to remove from this subscription\n */\n remove(teardown: Exclude): void {\n const { _finalizers } = this;\n _finalizers && arrRemove(_finalizers, teardown);\n\n if (teardown instanceof Subscription) {\n teardown._removeParent(this);\n }\n }\n}\n\nexport const EMPTY_SUBSCRIPTION = Subscription.EMPTY;\n\nexport function isSubscription(value: any): value is Subscription {\n return (\n value instanceof Subscription ||\n (value && 'closed' in value && isFunction(value.remove) && isFunction(value.add) && isFunction(value.unsubscribe))\n );\n}\n\nfunction execFinalizer(finalizer: Unsubscribable | (() => void)) {\n if (isFunction(finalizer)) {\n finalizer();\n } else {\n finalizer.unsubscribe();\n }\n}\n", "import { Subscriber } from './Subscriber';\nimport { ObservableNotification } from './types';\n\n/**\n * The {@link GlobalConfig} object for RxJS. It is used to configure things\n * like how to react on unhandled errors.\n */\nexport const config: GlobalConfig = {\n onUnhandledError: null,\n onStoppedNotification: null,\n Promise: undefined,\n useDeprecatedSynchronousErrorHandling: false,\n useDeprecatedNextContext: false,\n};\n\n/**\n * The global configuration object for RxJS, used to configure things\n * like how to react on unhandled errors. Accessible via {@link config}\n * object.\n */\nexport interface GlobalConfig {\n /**\n * A registration point for unhandled errors from RxJS. These are errors that\n * cannot were not handled by consuming code in the usual subscription path. For\n * example, if you have this configured, and you subscribe to an observable without\n * providing an error handler, errors from that subscription will end up here. This\n * will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onUnhandledError: ((err: any) => void) | null;\n\n /**\n * A registration point for notifications that cannot be sent to subscribers because they\n * have completed, errored or have been explicitly unsubscribed. By default, next, complete\n * and error notifications sent to stopped subscribers are noops. However, sometimes callers\n * might want a different behavior. For example, with sources that attempt to report errors\n * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead.\n * This will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null;\n\n /**\n * The promise constructor used by default for {@link Observable#toPromise toPromise} and {@link Observable#forEach forEach}\n * methods.\n *\n * @deprecated As of version 8, RxJS will no longer support this sort of injection of a\n * Promise constructor. If you need a Promise implementation other than native promises,\n * please polyfill/patch Promise as you see appropriate. Will be removed in v8.\n */\n Promise?: PromiseConstructorLike;\n\n /**\n * If true, turns on synchronous error rethrowing, which is a deprecated behavior\n * in v6 and higher. This behavior enables bad patterns like wrapping a subscribe\n * call in a try/catch block. It also enables producer interference, a nasty bug\n * where a multicast can be broken for all observers by a downstream consumer with\n * an unhandled error. DO NOT USE THIS FLAG UNLESS IT'S NEEDED TO BUY TIME\n * FOR MIGRATION REASONS.\n *\n * @deprecated As of version 8, RxJS will no longer support synchronous throwing\n * of unhandled errors. All errors will be thrown on a separate call stack to prevent bad\n * behaviors described above. Will be removed in v8.\n */\n useDeprecatedSynchronousErrorHandling: boolean;\n\n /**\n * If true, enables an as-of-yet undocumented feature from v5: The ability to access\n * `unsubscribe()` via `this` context in `next` functions created in observers passed\n * to `subscribe`.\n *\n * This is being removed because the performance was severely problematic, and it could also cause\n * issues when types other than POJOs are passed to subscribe as subscribers, as they will likely have\n * their `this` context overwritten.\n *\n * @deprecated As of version 8, RxJS will no longer support altering the\n * context of next functions provided as part of an observer to Subscribe. Instead,\n * you will have access to a subscription or a signal or token that will allow you to do things like\n * unsubscribe and test closed status. Will be removed in v8.\n */\n useDeprecatedNextContext: boolean;\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetTimeoutFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearTimeoutFunction = (handle: TimerHandle) => void;\n\ninterface TimeoutProvider {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n delegate:\n | {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n }\n | undefined;\n}\n\nexport const timeoutProvider: TimeoutProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setTimeout(handler: () => void, timeout?: number, ...args) {\n const { delegate } = timeoutProvider;\n if (delegate?.setTimeout) {\n return delegate.setTimeout(handler, timeout, ...args);\n }\n return setTimeout(handler, timeout, ...args);\n },\n clearTimeout(handle) {\n const { delegate } = timeoutProvider;\n return (delegate?.clearTimeout || clearTimeout)(handle as any);\n },\n delegate: undefined,\n};\n", "import { config } from '../config';\nimport { timeoutProvider } from '../scheduler/timeoutProvider';\n\n/**\n * Handles an error on another job either with the user-configured {@link onUnhandledError},\n * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc.\n *\n * This should be called whenever there is an error that is out-of-band with the subscription\n * or when an error hits a terminal boundary of the subscription and no error handler was provided.\n *\n * @param err the error to report\n */\nexport function reportUnhandledError(err: any) {\n timeoutProvider.setTimeout(() => {\n const { onUnhandledError } = config;\n if (onUnhandledError) {\n // Execute the user-configured error handler.\n onUnhandledError(err);\n } else {\n // Throw so it is picked up by the runtime's uncaught error mechanism.\n throw err;\n }\n });\n}\n", "/* tslint:disable:no-empty */\nexport function noop() { }\n", "import { CompleteNotification, NextNotification, ErrorNotification } from './types';\n\n/**\n * A completion object optimized for memory use and created to be the\n * same \"shape\" as other notifications in v8.\n * @internal\n */\nexport const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)();\n\n/**\n * Internal use only. Creates an optimized error notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function errorNotification(error: any): ErrorNotification {\n return createNotification('E', undefined, error) as any;\n}\n\n/**\n * Internal use only. Creates an optimized next notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function nextNotification(value: T) {\n return createNotification('N', value, undefined) as NextNotification;\n}\n\n/**\n * Ensures that all notifications created internally have the same \"shape\" in v8.\n *\n * TODO: This is only exported to support a crazy legacy test in `groupBy`.\n * @internal\n */\nexport function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) {\n return {\n kind,\n value,\n error,\n };\n}\n", "import { config } from '../config';\n\nlet context: { errorThrown: boolean; error: any } | null = null;\n\n/**\n * Handles dealing with errors for super-gross mode. Creates a context, in which\n * any synchronously thrown errors will be passed to {@link captureError}. Which\n * will record the error such that it will be rethrown after the call back is complete.\n * TODO: Remove in v8\n * @param cb An immediately executed function.\n */\nexport function errorContext(cb: () => void) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n const isRoot = !context;\n if (isRoot) {\n context = { errorThrown: false, error: null };\n }\n cb();\n if (isRoot) {\n const { errorThrown, error } = context!;\n context = null;\n if (errorThrown) {\n throw error;\n }\n }\n } else {\n // This is the general non-deprecated path for everyone that\n // isn't crazy enough to use super-gross mode (useDeprecatedSynchronousErrorHandling)\n cb();\n }\n}\n\n/**\n * Captures errors only in super-gross mode.\n * @param err the error to capture\n */\nexport function captureError(err: any) {\n if (config.useDeprecatedSynchronousErrorHandling && context) {\n context.errorThrown = true;\n context.error = err;\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { Observer, ObservableNotification } from './types';\nimport { isSubscription, Subscription } from './Subscription';\nimport { config } from './config';\nimport { reportUnhandledError } from './util/reportUnhandledError';\nimport { noop } from './util/noop';\nimport { nextNotification, errorNotification, COMPLETE_NOTIFICATION } from './NotificationFactories';\nimport { timeoutProvider } from './scheduler/timeoutProvider';\nimport { captureError } from './util/errorContext';\n\n/**\n * Implements the {@link Observer} interface and extends the\n * {@link Subscription} class. While the {@link Observer} is the public API for\n * consuming the values of an {@link Observable}, all Observers get converted to\n * a Subscriber, in order to provide Subscription-like capabilities such as\n * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for\n * implementing operators, but it is rarely used as a public API.\n */\nexport class Subscriber extends Subscription implements Observer {\n /**\n * A static factory for a Subscriber, given a (potentially partial) definition\n * of an Observer.\n * @param next The `next` callback of an Observer.\n * @param error The `error` callback of an\n * Observer.\n * @param complete The `complete` callback of an\n * Observer.\n * @return A Subscriber wrapping the (partially defined)\n * Observer represented by the given arguments.\n * @deprecated Do not use. Will be removed in v8. There is no replacement for this\n * method, and there is no reason to be creating instances of `Subscriber` directly.\n * If you have a specific use case, please file an issue.\n */\n static create(next?: (x?: T) => void, error?: (e?: any) => void, complete?: () => void): Subscriber {\n return new SafeSubscriber(next, error, complete);\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected isStopped: boolean = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected destination: Subscriber | Observer; // this `any` is the escape hatch to erase extra type param (e.g. R)\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * There is no reason to directly create an instance of Subscriber. This type is exported for typings reasons.\n */\n constructor(destination?: Subscriber | Observer) {\n super();\n if (destination) {\n this.destination = destination;\n // Automatically chain subscriptions together here.\n // if destination is a Subscription, then it is a Subscriber.\n if (isSubscription(destination)) {\n destination.add(this);\n }\n } else {\n this.destination = EMPTY_OBSERVER;\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `next` from\n * the Observable, with a value. The Observable may call this method 0 or more\n * times.\n * @param value The `next` value.\n */\n next(value: T): void {\n if (this.isStopped) {\n handleStoppedNotification(nextNotification(value), this);\n } else {\n this._next(value!);\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `error` from\n * the Observable, with an attached `Error`. Notifies the Observer that\n * the Observable has experienced an error condition.\n * @param err The `error` exception.\n */\n error(err?: any): void {\n if (this.isStopped) {\n handleStoppedNotification(errorNotification(err), this);\n } else {\n this.isStopped = true;\n this._error(err);\n }\n }\n\n /**\n * The {@link Observer} callback to receive a valueless notification of type\n * `complete` from the Observable. Notifies the Observer that the Observable\n * has finished sending push-based notifications.\n */\n complete(): void {\n if (this.isStopped) {\n handleStoppedNotification(COMPLETE_NOTIFICATION, this);\n } else {\n this.isStopped = true;\n this._complete();\n }\n }\n\n unsubscribe(): void {\n if (!this.closed) {\n this.isStopped = true;\n super.unsubscribe();\n this.destination = null!;\n }\n }\n\n protected _next(value: T): void {\n this.destination.next(value);\n }\n\n protected _error(err: any): void {\n try {\n this.destination.error(err);\n } finally {\n this.unsubscribe();\n }\n }\n\n protected _complete(): void {\n try {\n this.destination.complete();\n } finally {\n this.unsubscribe();\n }\n }\n}\n\n/**\n * This bind is captured here because we want to be able to have\n * compatibility with monoid libraries that tend to use a method named\n * `bind`. In particular, a library called Monio requires this.\n */\nconst _bind = Function.prototype.bind;\n\nfunction bind any>(fn: Fn, thisArg: any): Fn {\n return _bind.call(fn, thisArg);\n}\n\n/**\n * Internal optimization only, DO NOT EXPOSE.\n * @internal\n */\nclass ConsumerObserver implements Observer {\n constructor(private partialObserver: Partial>) {}\n\n next(value: T): void {\n const { partialObserver } = this;\n if (partialObserver.next) {\n try {\n partialObserver.next(value);\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n\n error(err: any): void {\n const { partialObserver } = this;\n if (partialObserver.error) {\n try {\n partialObserver.error(err);\n } catch (error) {\n handleUnhandledError(error);\n }\n } else {\n handleUnhandledError(err);\n }\n }\n\n complete(): void {\n const { partialObserver } = this;\n if (partialObserver.complete) {\n try {\n partialObserver.complete();\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n}\n\nexport class SafeSubscriber extends Subscriber {\n constructor(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((e?: any) => void) | null,\n complete?: (() => void) | null\n ) {\n super();\n\n let partialObserver: Partial>;\n if (isFunction(observerOrNext) || !observerOrNext) {\n // The first argument is a function, not an observer. The next\n // two arguments *could* be observers, or they could be empty.\n partialObserver = {\n next: (observerOrNext ?? undefined) as ((value: T) => void) | undefined,\n error: error ?? undefined,\n complete: complete ?? undefined,\n };\n } else {\n // The first argument is a partial observer.\n let context: any;\n if (this && config.useDeprecatedNextContext) {\n // This is a deprecated path that made `this.unsubscribe()` available in\n // next handler functions passed to subscribe. This only exists behind a flag\n // now, as it is *very* slow.\n context = Object.create(observerOrNext);\n context.unsubscribe = () => this.unsubscribe();\n partialObserver = {\n next: observerOrNext.next && bind(observerOrNext.next, context),\n error: observerOrNext.error && bind(observerOrNext.error, context),\n complete: observerOrNext.complete && bind(observerOrNext.complete, context),\n };\n } else {\n // The \"normal\" path. Just use the partial observer directly.\n partialObserver = observerOrNext;\n }\n }\n\n // Wrap the partial observer to ensure it's a full observer, and\n // make sure proper error handling is accounted for.\n this.destination = new ConsumerObserver(partialObserver);\n }\n}\n\nfunction handleUnhandledError(error: any) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n captureError(error);\n } else {\n // Ideal path, we report this as an unhandled error,\n // which is thrown on a new call stack.\n reportUnhandledError(error);\n }\n}\n\n/**\n * An error handler used when no error handler was supplied\n * to the SafeSubscriber -- meaning no error handler was supplied\n * do the `subscribe` call on our observable.\n * @param err The error to handle\n */\nfunction defaultErrorHandler(err: any) {\n throw err;\n}\n\n/**\n * A handler for notifications that cannot be sent to a stopped subscriber.\n * @param notification The notification being sent.\n * @param subscriber The stopped subscriber.\n */\nfunction handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) {\n const { onStoppedNotification } = config;\n onStoppedNotification && timeoutProvider.setTimeout(() => onStoppedNotification(notification, subscriber));\n}\n\n/**\n * The observer used as a stub for subscriptions where the user did not\n * pass any arguments to `subscribe`. Comes with the default error handling\n * behavior.\n */\nexport const EMPTY_OBSERVER: Readonly> & { closed: true } = {\n closed: true,\n next: noop,\n error: defaultErrorHandler,\n complete: noop,\n};\n", "/**\n * Symbol.observable or a string \"@@observable\". Used for interop\n *\n * @deprecated We will no longer be exporting this symbol in upcoming versions of RxJS.\n * Instead polyfill and use Symbol.observable directly *or* use https://www.npmjs.com/package/symbol-observable\n */\nexport const observable: string | symbol = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')();\n", "/**\n * This function takes one parameter and just returns it. Simply put,\n * this is like `(x: T): T => x`.\n *\n * ## Examples\n *\n * This is useful in some cases when using things like `mergeMap`\n *\n * ```ts\n * import { interval, take, map, range, mergeMap, identity } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(5));\n *\n * const result$ = source$.pipe(\n * map(i => range(i)),\n * mergeMap(identity) // same as mergeMap(x => x)\n * );\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * Or when you want to selectively apply an operator\n *\n * ```ts\n * import { interval, take, identity } from 'rxjs';\n *\n * const shouldLimit = () => Math.random() < 0.5;\n *\n * const source$ = interval(1000);\n *\n * const result$ = source$.pipe(shouldLimit() ? take(5) : identity);\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * @param x Any value that is returned by this function\n * @returns The value passed as the first parameter to this function\n */\nexport function identity(x: T): T {\n return x;\n}\n", "import { identity } from './identity';\nimport { UnaryFunction } from '../types';\n\nexport function pipe(): typeof identity;\nexport function pipe(fn1: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction, fn3: UnaryFunction): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction,\n ...fns: UnaryFunction[]\n): UnaryFunction;\n\n/**\n * pipe() can be called on one or more functions, each of which can take one argument (\"UnaryFunction\")\n * and uses it to return a value.\n * It returns a function that takes one argument, passes it to the first UnaryFunction, and then\n * passes the result to the next one, passes that result to the next one, and so on. \n */\nexport function pipe(...fns: Array>): UnaryFunction {\n return pipeFromArray(fns);\n}\n\n/** @internal */\nexport function pipeFromArray(fns: Array>): UnaryFunction {\n if (fns.length === 0) {\n return identity as UnaryFunction;\n }\n\n if (fns.length === 1) {\n return fns[0];\n }\n\n return function piped(input: T): R {\n return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any);\n };\n}\n", "import { Operator } from './Operator';\nimport { SafeSubscriber, Subscriber } from './Subscriber';\nimport { isSubscription, Subscription } from './Subscription';\nimport { TeardownLogic, OperatorFunction, Subscribable, Observer } from './types';\nimport { observable as Symbol_observable } from './symbol/observable';\nimport { pipeFromArray } from './util/pipe';\nimport { config } from './config';\nimport { isFunction } from './util/isFunction';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A representation of any set of values over any amount of time. This is the most basic building block\n * of RxJS.\n */\nexport class Observable implements Subscribable {\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n source: Observable | undefined;\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n operator: Operator | undefined;\n\n /**\n * @param subscribe The function that is called when the Observable is\n * initially subscribed to. This function is given a Subscriber, to which new values\n * can be `next`ed, or an `error` method can be called to raise an error, or\n * `complete` can be called to notify of a successful completion.\n */\n constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) {\n if (subscribe) {\n this._subscribe = subscribe;\n }\n }\n\n // HACK: Since TypeScript inherits static properties too, we have to\n // fight against TypeScript here so Subject can have a different static create signature\n /**\n * Creates a new Observable by calling the Observable constructor\n * @param subscribe the subscriber function to be passed to the Observable constructor\n * @return A new observable.\n * @deprecated Use `new Observable()` instead. Will be removed in v8.\n */\n static create: (...args: any[]) => any = (subscribe?: (subscriber: Subscriber) => TeardownLogic) => {\n return new Observable(subscribe);\n };\n\n /**\n * Creates a new Observable, with this Observable instance as the source, and the passed\n * operator defined as the new observable's operator.\n * @param operator the operator defining the operation to take on the observable\n * @return A new observable with the Operator applied.\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * If you have implemented an operator using `lift`, it is recommended that you create an\n * operator by simply returning `new Observable()` directly. See \"Creating new operators from\n * scratch\" section here: https://rxjs.dev/guide/operators\n */\n lift(operator?: Operator): Observable {\n const observable = new Observable();\n observable.source = this;\n observable.operator = operator;\n return observable;\n }\n\n subscribe(observerOrNext?: Partial> | ((value: T) => void)): Subscription;\n /** @deprecated Instead of passing separate callback arguments, use an observer argument. Signatures taking separate callback arguments will be removed in v8. Details: https://rxjs.dev/deprecations/subscribe-arguments */\n subscribe(next?: ((value: T) => void) | null, error?: ((error: any) => void) | null, complete?: (() => void) | null): Subscription;\n /**\n * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit.\n *\n * Use it when you have all these Observables, but still nothing is happening.\n *\n * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It\n * might be for example a function that you passed to Observable's constructor, but most of the time it is\n * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means\n * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often\n * the thought.\n *\n * Apart from starting the execution of an Observable, this method allows you to listen for values\n * that an Observable emits, as well as for when it completes or errors. You can achieve this in two\n * of the following ways.\n *\n * The first way is creating an object that implements {@link Observer} interface. It should have methods\n * defined by that interface, but note that it should be just a regular JavaScript object, which you can create\n * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do\n * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also\n * that your object does not have to implement all methods. If you find yourself creating a method that doesn't\n * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens,\n * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead,\n * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or\n * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide\n * an `error` method to avoid missing thrown errors.\n *\n * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods.\n * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent\n * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer,\n * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`,\n * since `subscribe` recognizes these functions by where they were placed in function call. When it comes\n * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously.\n *\n * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events\n * and you also handled emissions internally by using operators (e.g. using `tap`).\n *\n * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object.\n * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean\n * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback\n * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable.\n *\n * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously.\n * It is an Observable itself that decides when these functions will be called. For example {@link of}\n * by default emits all its values synchronously. Always check documentation for how given Observable\n * will behave when subscribed and if its default behavior can be modified with a `scheduler`.\n *\n * #### Examples\n *\n * Subscribe with an {@link guide/observer Observer}\n *\n * ```ts\n * import { of } from 'rxjs';\n *\n * const sumObserver = {\n * sum: 0,\n * next(value) {\n * console.log('Adding: ' + value);\n * this.sum = this.sum + value;\n * },\n * error() {\n * // We actually could just remove this method,\n * // since we do not really care about errors right now.\n * },\n * complete() {\n * console.log('Sum equals: ' + this.sum);\n * }\n * };\n *\n * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes.\n * .subscribe(sumObserver);\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated})\n *\n * ```ts\n * import { of } from 'rxjs'\n *\n * let sum = 0;\n *\n * of(1, 2, 3).subscribe(\n * value => {\n * console.log('Adding: ' + value);\n * sum = sum + value;\n * },\n * undefined,\n * () => console.log('Sum equals: ' + sum)\n * );\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Cancel a subscription\n *\n * ```ts\n * import { interval } from 'rxjs';\n *\n * const subscription = interval(1000).subscribe({\n * next(num) {\n * console.log(num)\n * },\n * complete() {\n * // Will not be called, even when cancelling subscription.\n * console.log('completed!');\n * }\n * });\n *\n * setTimeout(() => {\n * subscription.unsubscribe();\n * console.log('unsubscribed!');\n * }, 2500);\n *\n * // Logs:\n * // 0 after 1s\n * // 1 after 2s\n * // 'unsubscribed!' after 2.5s\n * ```\n *\n * @param observerOrNext Either an {@link Observer} with some or all callback methods,\n * or the `next` handler that is called for each value emitted from the subscribed Observable.\n * @param error A handler for a terminal event resulting from an error. If no error handler is provided,\n * the error will be thrown asynchronously as unhandled.\n * @param complete A handler for a terminal event resulting from successful completion.\n * @return A subscription reference to the registered handlers.\n */\n subscribe(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((error: any) => void) | null,\n complete?: (() => void) | null\n ): Subscription {\n const subscriber = isSubscriber(observerOrNext) ? observerOrNext : new SafeSubscriber(observerOrNext, error, complete);\n\n errorContext(() => {\n const { operator, source } = this;\n subscriber.add(\n operator\n ? // We're dealing with a subscription in the\n // operator chain to one of our lifted operators.\n operator.call(subscriber, source)\n : source\n ? // If `source` has a value, but `operator` does not, something that\n // had intimate knowledge of our API, like our `Subject`, must have\n // set it. We're going to just call `_subscribe` directly.\n this._subscribe(subscriber)\n : // In all other cases, we're likely wrapping a user-provided initializer\n // function, so we need to catch errors and handle them appropriately.\n this._trySubscribe(subscriber)\n );\n });\n\n return subscriber;\n }\n\n /** @internal */\n protected _trySubscribe(sink: Subscriber): TeardownLogic {\n try {\n return this._subscribe(sink);\n } catch (err) {\n // We don't need to return anything in this case,\n // because it's just going to try to `add()` to a subscription\n // above.\n sink.error(err);\n }\n }\n\n /**\n * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with\n * APIs that expect promises, like `async/await`. You cannot unsubscribe from this.\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * #### Example\n *\n * ```ts\n * import { interval, take } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(4));\n *\n * async function getTotal() {\n * let total = 0;\n *\n * await source$.forEach(value => {\n * total += value;\n * console.log('observable -> ' + value);\n * });\n *\n * return total;\n * }\n *\n * getTotal().then(\n * total => console.log('Total: ' + total)\n * );\n *\n * // Expected:\n * // 'observable -> 0'\n * // 'observable -> 1'\n * // 'observable -> 2'\n * // 'observable -> 3'\n * // 'Total: 6'\n * ```\n *\n * @param next A handler for each value emitted by the observable.\n * @return A promise that either resolves on observable completion or\n * rejects with the handled error.\n */\n forEach(next: (value: T) => void): Promise;\n\n /**\n * @param next a handler for each value emitted by the observable\n * @param promiseCtor a constructor function used to instantiate the Promise\n * @return a promise that either resolves on observable completion or\n * rejects with the handled error\n * @deprecated Passing a Promise constructor will no longer be available\n * in upcoming versions of RxJS. This is because it adds weight to the library, for very\n * little benefit. If you need this functionality, it is recommended that you either\n * polyfill Promise, or you create an adapter to convert the returned native promise\n * to whatever promise implementation you wanted. Will be removed in v8.\n */\n forEach(next: (value: T) => void, promiseCtor: PromiseConstructorLike): Promise;\n\n forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n const subscriber = new SafeSubscriber({\n next: (value) => {\n try {\n next(value);\n } catch (err) {\n reject(err);\n subscriber.unsubscribe();\n }\n },\n error: reject,\n complete: resolve,\n });\n this.subscribe(subscriber);\n }) as Promise;\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): TeardownLogic {\n return this.source?.subscribe(subscriber);\n }\n\n /**\n * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable\n * @return This instance of the observable.\n */\n [Symbol_observable]() {\n return this;\n }\n\n /* tslint:disable:max-line-length */\n pipe(): Observable;\n pipe(op1: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction,\n ...operations: OperatorFunction[]\n ): Observable;\n /* tslint:enable:max-line-length */\n\n /**\n * Used to stitch together functional operators into a chain.\n *\n * ## Example\n *\n * ```ts\n * import { interval, filter, map, scan } from 'rxjs';\n *\n * interval(1000)\n * .pipe(\n * filter(x => x % 2 === 0),\n * map(x => x + x),\n * scan((acc, x) => acc + x)\n * )\n * .subscribe(x => console.log(x));\n * ```\n *\n * @return The Observable result of all the operators having been called\n * in the order they were passed in.\n */\n pipe(...operations: OperatorFunction[]): Observable {\n return pipeFromArray(operations)(this);\n }\n\n /* tslint:disable:max-line-length */\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: typeof Promise): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: PromiseConstructorLike): Promise;\n /* tslint:enable:max-line-length */\n\n /**\n * Subscribe to this Observable and get a Promise resolving on\n * `complete` with the last emission (if any).\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * @param [promiseCtor] a constructor function used to instantiate\n * the Promise\n * @return A Promise that resolves with the last value emit, or\n * rejects on an error. If there were no emissions, Promise\n * resolves with undefined.\n * @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise\n */\n toPromise(promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n let value: T | undefined;\n this.subscribe(\n (x: T) => (value = x),\n (err: any) => reject(err),\n () => resolve(value)\n );\n }) as Promise;\n }\n}\n\n/**\n * Decides between a passed promise constructor from consuming code,\n * A default configured promise constructor, and the native promise\n * constructor and returns it. If nothing can be found, it will throw\n * an error.\n * @param promiseCtor The optional promise constructor to passed by consuming code\n */\nfunction getPromiseCtor(promiseCtor: PromiseConstructorLike | undefined) {\n return promiseCtor ?? config.Promise ?? Promise;\n}\n\nfunction isObserver(value: any): value is Observer {\n return value && isFunction(value.next) && isFunction(value.error) && isFunction(value.complete);\n}\n\nfunction isSubscriber(value: any): value is Subscriber {\n return (value && value instanceof Subscriber) || (isObserver(value) && isSubscription(value));\n}\n", "import { Observable } from '../Observable';\nimport { Subscriber } from '../Subscriber';\nimport { OperatorFunction } from '../types';\nimport { isFunction } from './isFunction';\n\n/**\n * Used to determine if an object is an Observable with a lift function.\n */\nexport function hasLift(source: any): source is { lift: InstanceType['lift'] } {\n return isFunction(source?.lift);\n}\n\n/**\n * Creates an `OperatorFunction`. Used to define operators throughout the library in a concise way.\n * @param init The logic to connect the liftedSource to the subscriber at the moment of subscription.\n */\nexport function operate(\n init: (liftedSource: Observable, subscriber: Subscriber) => (() => void) | void\n): OperatorFunction {\n return (source: Observable) => {\n if (hasLift(source)) {\n return source.lift(function (this: Subscriber, liftedSource: Observable) {\n try {\n return init(liftedSource, this);\n } catch (err) {\n this.error(err);\n }\n });\n }\n throw new TypeError('Unable to lift unknown Observable type');\n };\n}\n", "import { Subscriber } from '../Subscriber';\n\n/**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional teardown logic here. This will only be called on teardown if the\n * subscriber itself is not already closed. This is called after all other teardown logic is executed.\n */\nexport function createOperatorSubscriber(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n onFinalize?: () => void\n): Subscriber {\n return new OperatorSubscriber(destination, onNext, onComplete, onError, onFinalize);\n}\n\n/**\n * A generic helper for allowing operators to be created with a Subscriber and\n * use closures to capture necessary state from the operator function itself.\n */\nexport class OperatorSubscriber extends Subscriber {\n /**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional finalization logic here. This will only be called on finalization if the\n * subscriber itself is not already closed. This is called after all other finalization logic is executed.\n * @param shouldUnsubscribe An optional check to see if an unsubscribe call should truly unsubscribe.\n * NOTE: This currently **ONLY** exists to support the strange behavior of {@link groupBy}, where unsubscription\n * to the resulting observable does not actually disconnect from the source if there are active subscriptions\n * to any grouped observable. (DO NOT EXPOSE OR USE EXTERNALLY!!!)\n */\n constructor(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n private onFinalize?: () => void,\n private shouldUnsubscribe?: () => boolean\n ) {\n // It's important - for performance reasons - that all of this class's\n // members are initialized and that they are always initialized in the same\n // order. This will ensure that all OperatorSubscriber instances have the\n // same hidden class in V8. This, in turn, will help keep the number of\n // hidden classes involved in property accesses within the base class as\n // low as possible. If the number of hidden classes involved exceeds four,\n // the property accesses will become megamorphic and performance penalties\n // will be incurred - i.e. inline caches won't be used.\n //\n // The reasons for ensuring all instances have the same hidden class are\n // further discussed in this blog post from Benedikt Meurer:\n // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/\n super(destination);\n this._next = onNext\n ? function (this: OperatorSubscriber, value: T) {\n try {\n onNext(value);\n } catch (err) {\n destination.error(err);\n }\n }\n : super._next;\n this._error = onError\n ? function (this: OperatorSubscriber, err: any) {\n try {\n onError(err);\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._error;\n this._complete = onComplete\n ? function (this: OperatorSubscriber) {\n try {\n onComplete();\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._complete;\n }\n\n unsubscribe() {\n if (!this.shouldUnsubscribe || this.shouldUnsubscribe()) {\n const { closed } = this;\n super.unsubscribe();\n // Execute additional teardown if we have any and we didn't already do so.\n !closed && this.onFinalize?.();\n }\n }\n}\n", "import { Subscription } from '../Subscription';\n\ninterface AnimationFrameProvider {\n schedule(callback: FrameRequestCallback): Subscription;\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n delegate:\n | {\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n }\n | undefined;\n}\n\nexport const animationFrameProvider: AnimationFrameProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n schedule(callback) {\n let request = requestAnimationFrame;\n let cancel: typeof cancelAnimationFrame | undefined = cancelAnimationFrame;\n const { delegate } = animationFrameProvider;\n if (delegate) {\n request = delegate.requestAnimationFrame;\n cancel = delegate.cancelAnimationFrame;\n }\n const handle = request((timestamp) => {\n // Clear the cancel function. The request has been fulfilled, so\n // attempting to cancel the request upon unsubscription would be\n // pointless.\n cancel = undefined;\n callback(timestamp);\n });\n return new Subscription(() => cancel?.(handle));\n },\n requestAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);\n },\n cancelAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);\n },\n delegate: undefined,\n};\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface ObjectUnsubscribedError extends Error {}\n\nexport interface ObjectUnsubscribedErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (): ObjectUnsubscribedError;\n}\n\n/**\n * An error thrown when an action is invalid because the object has been\n * unsubscribed.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n *\n * @class ObjectUnsubscribedError\n */\nexport const ObjectUnsubscribedError: ObjectUnsubscribedErrorCtor = createErrorClass(\n (_super) =>\n function ObjectUnsubscribedErrorImpl(this: any) {\n _super(this);\n this.name = 'ObjectUnsubscribedError';\n this.message = 'object unsubscribed';\n }\n);\n", "import { Operator } from './Operator';\nimport { Observable } from './Observable';\nimport { Subscriber } from './Subscriber';\nimport { Subscription, EMPTY_SUBSCRIPTION } from './Subscription';\nimport { Observer, SubscriptionLike, TeardownLogic } from './types';\nimport { ObjectUnsubscribedError } from './util/ObjectUnsubscribedError';\nimport { arrRemove } from './util/arrRemove';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A Subject is a special type of Observable that allows values to be\n * multicasted to many Observers. Subjects are like EventEmitters.\n *\n * Every Subject is an Observable and an Observer. You can subscribe to a\n * Subject, and you can call next to feed values as well as error and complete.\n */\nexport class Subject extends Observable implements SubscriptionLike {\n closed = false;\n\n private currentObservers: Observer[] | null = null;\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n observers: Observer[] = [];\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n isStopped = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n hasError = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n thrownError: any = null;\n\n /**\n * Creates a \"subject\" by basically gluing an observer to an observable.\n *\n * @deprecated Recommended you do not use. Will be removed at some point in the future. Plans for replacement still under discussion.\n */\n static create: (...args: any[]) => any = (destination: Observer, source: Observable): AnonymousSubject => {\n return new AnonymousSubject(destination, source);\n };\n\n constructor() {\n // NOTE: This must be here to obscure Observable's constructor.\n super();\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n lift(operator: Operator): Observable {\n const subject = new AnonymousSubject(this, this);\n subject.operator = operator as any;\n return subject as any;\n }\n\n /** @internal */\n protected _throwIfClosed() {\n if (this.closed) {\n throw new ObjectUnsubscribedError();\n }\n }\n\n next(value: T) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n if (!this.currentObservers) {\n this.currentObservers = Array.from(this.observers);\n }\n for (const observer of this.currentObservers) {\n observer.next(value);\n }\n }\n });\n }\n\n error(err: any) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.hasError = this.isStopped = true;\n this.thrownError = err;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.error(err);\n }\n }\n });\n }\n\n complete() {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.isStopped = true;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.complete();\n }\n }\n });\n }\n\n unsubscribe() {\n this.isStopped = this.closed = true;\n this.observers = this.currentObservers = null!;\n }\n\n get observed() {\n return this.observers?.length > 0;\n }\n\n /** @internal */\n protected _trySubscribe(subscriber: Subscriber): TeardownLogic {\n this._throwIfClosed();\n return super._trySubscribe(subscriber);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._checkFinalizedStatuses(subscriber);\n return this._innerSubscribe(subscriber);\n }\n\n /** @internal */\n protected _innerSubscribe(subscriber: Subscriber) {\n const { hasError, isStopped, observers } = this;\n if (hasError || isStopped) {\n return EMPTY_SUBSCRIPTION;\n }\n this.currentObservers = null;\n observers.push(subscriber);\n return new Subscription(() => {\n this.currentObservers = null;\n arrRemove(observers, subscriber);\n });\n }\n\n /** @internal */\n protected _checkFinalizedStatuses(subscriber: Subscriber) {\n const { hasError, thrownError, isStopped } = this;\n if (hasError) {\n subscriber.error(thrownError);\n } else if (isStopped) {\n subscriber.complete();\n }\n }\n\n /**\n * Creates a new Observable with this Subject as the source. You can do this\n * to create custom Observer-side logic of the Subject and conceal it from\n * code that uses the Observable.\n * @return Observable that this Subject casts to.\n */\n asObservable(): Observable {\n const observable: any = new Observable();\n observable.source = this;\n return observable;\n }\n}\n\nexport class AnonymousSubject extends Subject {\n constructor(\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n public destination?: Observer,\n source?: Observable\n ) {\n super();\n this.source = source;\n }\n\n next(value: T) {\n this.destination?.next?.(value);\n }\n\n error(err: any) {\n this.destination?.error?.(err);\n }\n\n complete() {\n this.destination?.complete?.();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n return this.source?.subscribe(subscriber) ?? EMPTY_SUBSCRIPTION;\n }\n}\n", "import { Subject } from './Subject';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\n\n/**\n * A variant of Subject that requires an initial value and emits its current\n * value whenever it is subscribed to.\n */\nexport class BehaviorSubject extends Subject {\n constructor(private _value: T) {\n super();\n }\n\n get value(): T {\n return this.getValue();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n const subscription = super._subscribe(subscriber);\n !subscription.closed && subscriber.next(this._value);\n return subscription;\n }\n\n getValue(): T {\n const { hasError, thrownError, _value } = this;\n if (hasError) {\n throw thrownError;\n }\n this._throwIfClosed();\n return _value;\n }\n\n next(value: T): void {\n super.next((this._value = value));\n }\n}\n", "import { TimestampProvider } from '../types';\n\ninterface DateTimestampProvider extends TimestampProvider {\n delegate: TimestampProvider | undefined;\n}\n\nexport const dateTimestampProvider: DateTimestampProvider = {\n now() {\n // Use the variable rather than `this` so that the function can be called\n // without being bound to the provider.\n return (dateTimestampProvider.delegate || Date).now();\n },\n delegate: undefined,\n};\n", "import { Subject } from './Subject';\nimport { TimestampProvider } from './types';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * A variant of {@link Subject} that \"replays\" old values to new subscribers by emitting them when they first subscribe.\n *\n * `ReplaySubject` has an internal buffer that will store a specified number of values that it has observed. Like `Subject`,\n * `ReplaySubject` \"observes\" values by having them passed to its `next` method. When it observes a value, it will store that\n * value for a time determined by the configuration of the `ReplaySubject`, as passed to its constructor.\n *\n * When a new subscriber subscribes to the `ReplaySubject` instance, it will synchronously emit all values in its buffer in\n * a First-In-First-Out (FIFO) manner. The `ReplaySubject` will also complete, if it has observed completion; and it will\n * error if it has observed an error.\n *\n * There are two main configuration items to be concerned with:\n *\n * 1. `bufferSize` - This will determine how many items are stored in the buffer, defaults to infinite.\n * 2. `windowTime` - The amount of time to hold a value in the buffer before removing it from the buffer.\n *\n * Both configurations may exist simultaneously. So if you would like to buffer a maximum of 3 values, as long as the values\n * are less than 2 seconds old, you could do so with a `new ReplaySubject(3, 2000)`.\n *\n * ### Differences with BehaviorSubject\n *\n * `BehaviorSubject` is similar to `new ReplaySubject(1)`, with a couple of exceptions:\n *\n * 1. `BehaviorSubject` comes \"primed\" with a single value upon construction.\n * 2. `ReplaySubject` will replay values, even after observing an error, where `BehaviorSubject` will not.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n * @see {@link shareReplay}\n */\nexport class ReplaySubject extends Subject {\n private _buffer: (T | number)[] = [];\n private _infiniteTimeWindow = true;\n\n /**\n * @param _bufferSize The size of the buffer to replay on subscription\n * @param _windowTime The amount of time the buffered items will stay buffered\n * @param _timestampProvider An object with a `now()` method that provides the current timestamp. This is used to\n * calculate the amount of time something has been buffered.\n */\n constructor(\n private _bufferSize = Infinity,\n private _windowTime = Infinity,\n private _timestampProvider: TimestampProvider = dateTimestampProvider\n ) {\n super();\n this._infiniteTimeWindow = _windowTime === Infinity;\n this._bufferSize = Math.max(1, _bufferSize);\n this._windowTime = Math.max(1, _windowTime);\n }\n\n next(value: T): void {\n const { isStopped, _buffer, _infiniteTimeWindow, _timestampProvider, _windowTime } = this;\n if (!isStopped) {\n _buffer.push(value);\n !_infiniteTimeWindow && _buffer.push(_timestampProvider.now() + _windowTime);\n }\n this._trimBuffer();\n super.next(value);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._trimBuffer();\n\n const subscription = this._innerSubscribe(subscriber);\n\n const { _infiniteTimeWindow, _buffer } = this;\n // We use a copy here, so reentrant code does not mutate our array while we're\n // emitting it to a new subscriber.\n const copy = _buffer.slice();\n for (let i = 0; i < copy.length && !subscriber.closed; i += _infiniteTimeWindow ? 1 : 2) {\n subscriber.next(copy[i] as T);\n }\n\n this._checkFinalizedStatuses(subscriber);\n\n return subscription;\n }\n\n private _trimBuffer() {\n const { _bufferSize, _timestampProvider, _buffer, _infiniteTimeWindow } = this;\n // If we don't have an infinite buffer size, and we're over the length,\n // use splice to truncate the old buffer values off. Note that we have to\n // double the size for instances where we're not using an infinite time window\n // because we're storing the values and the timestamps in the same array.\n const adjustedBufferSize = (_infiniteTimeWindow ? 1 : 2) * _bufferSize;\n _bufferSize < Infinity && adjustedBufferSize < _buffer.length && _buffer.splice(0, _buffer.length - adjustedBufferSize);\n\n // Now, if we're not in an infinite time window, remove all values where the time is\n // older than what is allowed.\n if (!_infiniteTimeWindow) {\n const now = _timestampProvider.now();\n let last = 0;\n // Search the array for the first timestamp that isn't expired and\n // truncate the buffer up to that point.\n for (let i = 1; i < _buffer.length && (_buffer[i] as number) <= now; i += 2) {\n last = i;\n }\n last && _buffer.splice(0, last + 1);\n }\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Subscription } from '../Subscription';\nimport { SchedulerAction } from '../types';\n\n/**\n * A unit of work to be executed in a `scheduler`. An action is typically\n * created from within a {@link SchedulerLike} and an RxJS user does not need to concern\n * themselves about creating and manipulating an Action.\n *\n * ```ts\n * class Action extends Subscription {\n * new (scheduler: Scheduler, work: (state?: T) => void);\n * schedule(state?: T, delay: number = 0): Subscription;\n * }\n * ```\n */\nexport class Action extends Subscription {\n constructor(scheduler: Scheduler, work: (this: SchedulerAction, state?: T) => void) {\n super();\n }\n /**\n * Schedules this action on its parent {@link SchedulerLike} for execution. May be passed\n * some context object, `state`. May happen at some point in the future,\n * according to the `delay` parameter, if specified.\n * @param state Some contextual data that the `work` function uses when called by the\n * Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is implicit\n * and defined by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(state?: T, delay: number = 0): Subscription {\n return this;\n }\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetIntervalFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearIntervalFunction = (handle: TimerHandle) => void;\n\ninterface IntervalProvider {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n delegate:\n | {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n }\n | undefined;\n}\n\nexport const intervalProvider: IntervalProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setInterval(handler: () => void, timeout?: number, ...args) {\n const { delegate } = intervalProvider;\n if (delegate?.setInterval) {\n return delegate.setInterval(handler, timeout, ...args);\n }\n return setInterval(handler, timeout, ...args);\n },\n clearInterval(handle) {\n const { delegate } = intervalProvider;\n return (delegate?.clearInterval || clearInterval)(handle as any);\n },\n delegate: undefined,\n};\n", "import { Action } from './Action';\nimport { SchedulerAction } from '../types';\nimport { Subscription } from '../Subscription';\nimport { AsyncScheduler } from './AsyncScheduler';\nimport { intervalProvider } from './intervalProvider';\nimport { arrRemove } from '../util/arrRemove';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncAction extends Action {\n public id: TimerHandle | undefined;\n public state?: T;\n // @ts-ignore: Property has no initializer and is not definitely assigned\n public delay: number;\n protected pending: boolean = false;\n\n constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (this.closed) {\n return this;\n }\n\n // Always replace the current state with the new state.\n this.state = state;\n\n const id = this.id;\n const scheduler = this.scheduler;\n\n //\n // Important implementation note:\n //\n // Actions only execute once by default, unless rescheduled from within the\n // scheduled callback. This allows us to implement single and repeat\n // actions via the same code path, without adding API surface area, as well\n // as mimic traditional recursion but across asynchronous boundaries.\n //\n // However, JS runtimes and timers distinguish between intervals achieved by\n // serial `setTimeout` calls vs. a single `setInterval` call. An interval of\n // serial `setTimeout` calls can be individually delayed, which delays\n // scheduling the next `setTimeout`, and so on. `setInterval` attempts to\n // guarantee the interval callback will be invoked more precisely to the\n // interval period, regardless of load.\n //\n // Therefore, we use `setInterval` to schedule single and repeat actions.\n // If the action reschedules itself with the same delay, the interval is not\n // canceled. If the action doesn't reschedule, or reschedules with a\n // different delay, the interval will be canceled after scheduled callback\n // execution.\n //\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, delay);\n }\n\n // Set the pending flag indicating that this action has been scheduled, or\n // has recursively rescheduled itself.\n this.pending = true;\n\n this.delay = delay;\n // If this action has already an async Id, don't request a new one.\n this.id = this.id ?? this.requestAsyncId(scheduler, this.id, delay);\n\n return this;\n }\n\n protected requestAsyncId(scheduler: AsyncScheduler, _id?: TimerHandle, delay: number = 0): TimerHandle {\n return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);\n }\n\n protected recycleAsyncId(_scheduler: AsyncScheduler, id?: TimerHandle, delay: number | null = 0): TimerHandle | undefined {\n // If this action is rescheduled with the same delay time, don't clear the interval id.\n if (delay != null && this.delay === delay && this.pending === false) {\n return id;\n }\n // Otherwise, if the action's delay time is different from the current delay,\n // or the action has been rescheduled before it's executed, clear the interval id\n if (id != null) {\n intervalProvider.clearInterval(id);\n }\n\n return undefined;\n }\n\n /**\n * Immediately executes this action and the `work` it contains.\n */\n public execute(state: T, delay: number): any {\n if (this.closed) {\n return new Error('executing a cancelled action');\n }\n\n this.pending = false;\n const error = this._execute(state, delay);\n if (error) {\n return error;\n } else if (this.pending === false && this.id != null) {\n // Dequeue if the action didn't reschedule itself. Don't call\n // unsubscribe(), because the action could reschedule later.\n // For example:\n // ```\n // scheduler.schedule(function doWork(counter) {\n // /* ... I'm a busy worker bee ... */\n // var originalAction = this;\n // /* wait 100ms before rescheduling the action */\n // setTimeout(function () {\n // originalAction.schedule(counter + 1);\n // }, 100);\n // }, 1000);\n // ```\n this.id = this.recycleAsyncId(this.scheduler, this.id, null);\n }\n }\n\n protected _execute(state: T, _delay: number): any {\n let errored: boolean = false;\n let errorValue: any;\n try {\n this.work(state);\n } catch (e) {\n errored = true;\n // HACK: Since code elsewhere is relying on the \"truthiness\" of the\n // return here, we can't have it return \"\" or 0 or false.\n // TODO: Clean this up when we refactor schedulers mid-version-8 or so.\n errorValue = e ? e : new Error('Scheduled action threw falsy error');\n }\n if (errored) {\n this.unsubscribe();\n return errorValue;\n }\n }\n\n unsubscribe() {\n if (!this.closed) {\n const { id, scheduler } = this;\n const { actions } = scheduler;\n\n this.work = this.state = this.scheduler = null!;\n this.pending = false;\n\n arrRemove(actions, this);\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, null);\n }\n\n this.delay = null!;\n super.unsubscribe();\n }\n }\n}\n", "import { Action } from './scheduler/Action';\nimport { Subscription } from './Subscription';\nimport { SchedulerLike, SchedulerAction } from './types';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * An execution context and a data structure to order tasks and schedule their\n * execution. Provides a notion of (potentially virtual) time, through the\n * `now()` getter method.\n *\n * Each unit of work in a Scheduler is called an `Action`.\n *\n * ```ts\n * class Scheduler {\n * now(): number;\n * schedule(work, delay?, state?): Subscription;\n * }\n * ```\n *\n * @deprecated Scheduler is an internal implementation detail of RxJS, and\n * should not be used directly. Rather, create your own class and implement\n * {@link SchedulerLike}. Will be made internal in v8.\n */\nexport class Scheduler implements SchedulerLike {\n public static now: () => number = dateTimestampProvider.now;\n\n constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {\n this.now = now;\n }\n\n /**\n * A getter method that returns a number representing the current time\n * (at the time this function was called) according to the scheduler's own\n * internal clock.\n * @return A number that represents the current time. May or may not\n * have a relation to wall-clock time. May or may not refer to a time unit\n * (e.g. milliseconds).\n */\n public now: () => number;\n\n /**\n * Schedules a function, `work`, for execution. May happen at some point in\n * the future, according to the `delay` parameter, if specified. May be passed\n * some context object, `state`, which will be passed to the `work` function.\n *\n * The given arguments will be processed an stored as an Action object in a\n * queue of actions.\n *\n * @param work A function representing a task, or some unit of work to be\n * executed by the Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is\n * implicit and defined by the Scheduler itself.\n * @param state Some contextual data that the `work` function uses when called\n * by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription {\n return new this.schedulerActionCtor(this, work).schedule(state, delay);\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Action } from './Action';\nimport { AsyncAction } from './AsyncAction';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncScheduler extends Scheduler {\n public actions: Array> = [];\n /**\n * A flag to indicate whether the Scheduler is currently executing a batch of\n * queued actions.\n * @internal\n */\n public _active: boolean = false;\n /**\n * An internal ID used to track the latest asynchronous task such as those\n * coming from `setTimeout`, `setInterval`, `requestAnimationFrame`, and\n * others.\n * @internal\n */\n public _scheduled: TimerHandle | undefined;\n\n constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {\n super(SchedulerAction, now);\n }\n\n public flush(action: AsyncAction): void {\n const { actions } = this;\n\n if (this._active) {\n actions.push(action);\n return;\n }\n\n let error: any;\n this._active = true;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions.shift()!)); // exhaust the scheduler queue\n\n this._active = false;\n\n if (error) {\n while ((action = actions.shift()!)) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\n/**\n *\n * Async Scheduler\n *\n * Schedule task as if you used setTimeout(task, duration)\n *\n * `async` scheduler schedules tasks asynchronously, by putting them on the JavaScript\n * event loop queue. It is best used to delay tasks in time or to schedule tasks repeating\n * in intervals.\n *\n * If you just want to \"defer\" task, that is to perform it right after currently\n * executing synchronous code ends (commonly achieved by `setTimeout(deferredTask, 0)`),\n * better choice will be the {@link asapScheduler} scheduler.\n *\n * ## Examples\n * Use async scheduler to delay task\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * const task = () => console.log('it works!');\n *\n * asyncScheduler.schedule(task, 2000);\n *\n * // After 2 seconds logs:\n * // \"it works!\"\n * ```\n *\n * Use async scheduler to repeat task in intervals\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * function task(state) {\n * console.log(state);\n * this.schedule(state + 1, 1000); // `this` references currently executing Action,\n * // which we reschedule with new state and delay\n * }\n *\n * asyncScheduler.schedule(task, 3000, 0);\n *\n * // Logs:\n * // 0 after 3s\n * // 1 after 4s\n * // 2 after 5s\n * // 3 after 6s\n * ```\n */\n\nexport const asyncScheduler = new AsyncScheduler(AsyncAction);\n\n/**\n * @deprecated Renamed to {@link asyncScheduler}. Will be removed in v8.\n */\nexport const async = asyncScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { Subscription } from '../Subscription';\nimport { QueueScheduler } from './QueueScheduler';\nimport { SchedulerAction } from '../types';\nimport { TimerHandle } from './timerHandle';\n\nexport class QueueAction extends AsyncAction {\n constructor(protected scheduler: QueueScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (delay > 0) {\n return super.schedule(state, delay);\n }\n this.delay = delay;\n this.state = state;\n this.scheduler.flush(this);\n return this;\n }\n\n public execute(state: T, delay: number): any {\n return delay > 0 || this.closed ? super.execute(state, delay) : this._execute(state, delay);\n }\n\n protected requestAsyncId(scheduler: QueueScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n\n if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n\n // Otherwise flush the scheduler starting with this action.\n scheduler.flush(this);\n\n // HACK: In the past, this was returning `void`. However, `void` isn't a valid\n // `TimerHandle`, and generally the return value here isn't really used. So the\n // compromise is to return `0` which is both \"falsy\" and a valid `TimerHandle`,\n // as opposed to refactoring every other instanceo of `requestAsyncId`.\n return 0;\n }\n}\n", "import { AsyncScheduler } from './AsyncScheduler';\n\nexport class QueueScheduler extends AsyncScheduler {\n}\n", "import { QueueAction } from './QueueAction';\nimport { QueueScheduler } from './QueueScheduler';\n\n/**\n *\n * Queue Scheduler\n *\n * Put every next task on a queue, instead of executing it immediately\n *\n * `queue` scheduler, when used with delay, behaves the same as {@link asyncScheduler} scheduler.\n *\n * When used without delay, it schedules given task synchronously - executes it right when\n * it is scheduled. However when called recursively, that is when inside the scheduled task,\n * another task is scheduled with queue scheduler, instead of executing immediately as well,\n * that task will be put on a queue and wait for current one to finish.\n *\n * This means that when you execute task with `queue` scheduler, you are sure it will end\n * before any other task scheduled with that scheduler will start.\n *\n * ## Examples\n * Schedule recursively first, then do something\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(() => {\n * queueScheduler.schedule(() => console.log('second')); // will not happen now, but will be put on a queue\n *\n * console.log('first');\n * });\n *\n * // Logs:\n * // \"first\"\n * // \"second\"\n * ```\n *\n * Reschedule itself recursively\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(function(state) {\n * if (state !== 0) {\n * console.log('before', state);\n * this.schedule(state - 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * console.log('after', state);\n * }\n * }, 0, 3);\n *\n * // In scheduler that runs recursively, you would expect:\n * // \"before\", 3\n * // \"before\", 2\n * // \"before\", 1\n * // \"after\", 1\n * // \"after\", 2\n * // \"after\", 3\n *\n * // But with queue it logs:\n * // \"before\", 3\n * // \"after\", 3\n * // \"before\", 2\n * // \"after\", 2\n * // \"before\", 1\n * // \"after\", 1\n * ```\n */\n\nexport const queueScheduler = new QueueScheduler(QueueAction);\n\n/**\n * @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.\n */\nexport const queue = queueScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\nimport { SchedulerAction } from '../types';\nimport { animationFrameProvider } from './animationFrameProvider';\nimport { TimerHandle } from './timerHandle';\n\nexport class AnimationFrameAction extends AsyncAction {\n constructor(protected scheduler: AnimationFrameScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay is greater than 0, request as an async action.\n if (delay !== null && delay > 0) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n // Push the action to the end of the scheduler queue.\n scheduler.actions.push(this);\n // If an animation frame has already been requested, don't request another\n // one. If an animation frame hasn't been requested yet, request one. Return\n // the current animation frame request id.\n return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)));\n }\n\n protected recycleAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle | undefined {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n if (delay != null ? delay > 0 : this.delay > 0) {\n return super.recycleAsyncId(scheduler, id, delay);\n }\n // If the scheduler queue has no remaining actions with the same async id,\n // cancel the requested animation frame and set the scheduled flag to\n // undefined so the next AnimationFrameAction will request its own.\n const { actions } = scheduler;\n if (id != null && id === scheduler._scheduled && actions[actions.length - 1]?.id !== id) {\n animationFrameProvider.cancelAnimationFrame(id as number);\n scheduler._scheduled = undefined;\n }\n // Return undefined so the action knows to request a new async id if it's rescheduled.\n return undefined;\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\nexport class AnimationFrameScheduler extends AsyncScheduler {\n public flush(action?: AsyncAction): void {\n this._active = true;\n // The async id that effects a call to flush is stored in _scheduled.\n // Before executing an action, it's necessary to check the action's async\n // id to determine whether it's supposed to be executed in the current\n // flush.\n // Previous implementations of this method used a count to determine this,\n // but that was unsound, as actions that are unsubscribed - i.e. cancelled -\n // are removed from the actions array and that can shift actions that are\n // scheduled to be executed in a subsequent flush into positions at which\n // they are executed within the current flush.\n let flushId;\n if (action) {\n flushId = action.id;\n } else {\n flushId = this._scheduled;\n this._scheduled = undefined;\n }\n\n const { actions } = this;\n let error: any;\n action = action || actions.shift()!;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions[0]) && action.id === flushId && actions.shift());\n\n this._active = false;\n\n if (error) {\n while ((action = actions[0]) && action.id === flushId && actions.shift()) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AnimationFrameAction } from './AnimationFrameAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\n\n/**\n *\n * Animation Frame Scheduler\n *\n * Perform task when `window.requestAnimationFrame` would fire\n *\n * When `animationFrame` scheduler is used with delay, it will fall back to {@link asyncScheduler} scheduler\n * behaviour.\n *\n * Without delay, `animationFrame` scheduler can be used to create smooth browser animations.\n * It makes sure scheduled task will happen just before next browser content repaint,\n * thus performing animations as efficiently as possible.\n *\n * ## Example\n * Schedule div height animation\n * ```ts\n * // html:
\n * import { animationFrameScheduler } from 'rxjs';\n *\n * const div = document.querySelector('div');\n *\n * animationFrameScheduler.schedule(function(height) {\n * div.style.height = height + \"px\";\n *\n * this.schedule(height + 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * }, 0, 0);\n *\n * // You will see a div element growing in height\n * ```\n */\n\nexport const animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);\n\n/**\n * @deprecated Renamed to {@link animationFrameScheduler}. Will be removed in v8.\n */\nexport const animationFrame = animationFrameScheduler;\n", "import { Observable } from '../Observable';\nimport { SchedulerLike } from '../types';\n\n/**\n * A simple Observable that emits no items to the Observer and immediately\n * emits a complete notification.\n *\n * Just emits 'complete', and nothing else.\n *\n * ![](empty.png)\n *\n * A simple Observable that only emits the complete notification. It can be used\n * for composing with other Observables, such as in a {@link mergeMap}.\n *\n * ## Examples\n *\n * Log complete notification\n *\n * ```ts\n * import { EMPTY } from 'rxjs';\n *\n * EMPTY.subscribe({\n * next: () => console.log('Next'),\n * complete: () => console.log('Complete!')\n * });\n *\n * // Outputs\n * // Complete!\n * ```\n *\n * Emit the number 7, then complete\n *\n * ```ts\n * import { EMPTY, startWith } from 'rxjs';\n *\n * const result = EMPTY.pipe(startWith(7));\n * result.subscribe(x => console.log(x));\n *\n * // Outputs\n * // 7\n * ```\n *\n * Map and flatten only odd numbers to the sequence `'a'`, `'b'`, `'c'`\n *\n * ```ts\n * import { interval, mergeMap, of, EMPTY } from 'rxjs';\n *\n * const interval$ = interval(1000);\n * const result = interval$.pipe(\n * mergeMap(x => x % 2 === 1 ? of('a', 'b', 'c') : EMPTY),\n * );\n * result.subscribe(x => console.log(x));\n *\n * // Results in the following to the console:\n * // x is equal to the count on the interval, e.g. (0, 1, 2, 3, ...)\n * // x will occur every 1000ms\n * // if x % 2 is equal to 1, print a, b, c (each on its own)\n * // if x % 2 is not equal to 1, nothing will be output\n * ```\n *\n * @see {@link Observable}\n * @see {@link NEVER}\n * @see {@link of}\n * @see {@link throwError}\n */\nexport const EMPTY = new Observable((subscriber) => subscriber.complete());\n\n/**\n * @param scheduler A {@link SchedulerLike} to use for scheduling\n * the emission of the complete notification.\n * @deprecated Replaced with the {@link EMPTY} constant or {@link scheduled} (e.g. `scheduled([], scheduler)`). Will be removed in v8.\n */\nexport function empty(scheduler?: SchedulerLike) {\n return scheduler ? emptyScheduled(scheduler) : EMPTY;\n}\n\nfunction emptyScheduled(scheduler: SchedulerLike) {\n return new Observable((subscriber) => scheduler.schedule(() => subscriber.complete()));\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport function isScheduler(value: any): value is SchedulerLike {\n return value && isFunction(value.schedule);\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\nimport { isScheduler } from './isScheduler';\n\nfunction last(arr: T[]): T | undefined {\n return arr[arr.length - 1];\n}\n\nexport function popResultSelector(args: any[]): ((...args: unknown[]) => unknown) | undefined {\n return isFunction(last(args)) ? args.pop() : undefined;\n}\n\nexport function popScheduler(args: any[]): SchedulerLike | undefined {\n return isScheduler(last(args)) ? args.pop() : undefined;\n}\n\nexport function popNumber(args: any[], defaultValue: number): number {\n return typeof last(args) === 'number' ? args.pop()! : defaultValue;\n}\n", "export const isArrayLike = ((x: any): x is ArrayLike => x && typeof x.length === 'number' && typeof x !== 'function');", "import { isFunction } from \"./isFunction\";\n\n/**\n * Tests to see if the object is \"thennable\".\n * @param value the object to test\n */\nexport function isPromise(value: any): value is PromiseLike {\n return isFunction(value?.then);\n}\n", "import { InteropObservable } from '../types';\nimport { observable as Symbol_observable } from '../symbol/observable';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being Observable (but not necessary an Rx Observable) */\nexport function isInteropObservable(input: any): input is InteropObservable {\n return isFunction(input[Symbol_observable]);\n}\n", "import { isFunction } from './isFunction';\n\nexport function isAsyncIterable(obj: any): obj is AsyncIterable {\n return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]);\n}\n", "/**\n * Creates the TypeError to throw if an invalid object is passed to `from` or `scheduled`.\n * @param input The object that was passed.\n */\nexport function createInvalidObservableTypeError(input: any) {\n // TODO: We should create error codes that can be looked up, so this can be less verbose.\n return new TypeError(\n `You provided ${\n input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'`\n } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`\n );\n}\n", "export function getSymbolIterator(): symbol {\n if (typeof Symbol !== 'function' || !Symbol.iterator) {\n return '@@iterator' as any;\n }\n\n return Symbol.iterator;\n}\n\nexport const iterator = getSymbolIterator();\n", "import { iterator as Symbol_iterator } from '../symbol/iterator';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being an Iterable */\nexport function isIterable(input: any): input is Iterable {\n return isFunction(input?.[Symbol_iterator]);\n}\n", "import { ReadableStreamLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator {\n const reader = readableStream.getReader();\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {\n return;\n }\n yield value!;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\nexport function isReadableStreamLike(obj: any): obj is ReadableStreamLike {\n // We don't want to use instanceof checks because they would return\n // false for instances from another Realm, like an