diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0e23616..14d97f5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -27,6 +27,25 @@ git push origin main **NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch. **IMMER auf `main` pushen** — sowohl origin als auch gitea. +### TEMPORAER: Compliance-Repo Refactoring (Stand 2026-04-12) + +**Das Compliance-Repo wird aktuell auf Production (gitea) refakturiert.** + +- **Core + Lehrer:** Normal auf `main` pushen (origin + gitea) ✅ +- **Compliance auf Mac Mini (origin):** Normal auf `main` pushen ✅ +- **Compliance auf Production (gitea):** **NUR Feature Branches**, NICHT auf `main` pushen! ⚠️ + +```bash +# Compliance-Repo — RICHTIG: +git push origin main # Mac Mini OK +git push gitea feature/mein-feature # Production: nur Feature Branch! + +# Compliance-Repo — FALSCH (waehrend Refactoring): +# git push gitea main # NICHT MACHEN! +``` + +**Nach Abschluss des Refactorings:** Gesamten Compliance-Code einmalig von Production auf Mac Mini uebernehmen. User sagt Bescheid wann es soweit ist. + ### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea) **IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:** @@ -318,6 +337,46 @@ npx tsc --noEmit && npm run lint && npm run build --- +## Code-Qualitaet Guardrails (NON-NEGOTIABLE) + +> Vollstaendige Details: `.claude/rules/architecture.md` +> Ausnahmen: `.claude/rules/loc-exceptions.txt` + +### File Size Budget + +- **Hard Cap: 500 LOC** pro Datei +- Wenn eine Aenderung eine Datei ueber 500 LOC bringen wuerde: **erst splitten, dann aendern** +- Ausnahmen nur mit Begruendung in `loc-exceptions.txt` + `[guardrail-change]` Commit-Marker + +### Architektur + +- **Go:** Handler ≤40 LOC → Service-Layer → Repository-Pattern +- **Python:** Routes duenn → Business Logic in Services → Persistenz in Repositories +- **TypeScript/Next.js:** page.tsx duenn → _components/, _hooks/ auslagern + +### FINGER WEG (laufende RAG Pipeline) + +Diese Verzeichnisse duerfen NICHT refaktoriert werden: +- `control-pipeline/` — RAG/Control-Extraction Pipeline +- `rag-service/` — Semantische Suche +- `embedding-service/` — Text-Embeddings +- `voice-service/bqas/` — RAG Quality Assessment + +### LOC-Check ausfuehren + +```bash +bash scripts/check-loc.sh --changed # nur geaenderte Dateien +bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations) +``` + +### Commit-Marker + +- `[split-required]` — Aenderung beginnt mit Datei-Split +- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh +- `[interface-change]` — Public API Contracts geaendert + +--- + ## Kernprinzipien ### 1. Open Source Policy diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000..4f97b45 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,79 @@ +# Architecture Rule — BreakPilot Core + +## File Size Budget + +Hard default: **500 LOC max** per file. +Soft targets: +- Handler/Router/Service: 300-400 LOC +- Models/Schemas/Types: 200-300 LOC +- Utilities: 100-200 LOC + +Ausnahmen nur in `.claude/rules/loc-exceptions.txt` mit Begruendung. + +## Split-Trigger + +Sofort splitten wenn: +- Datei ueberschreitet 500 LOC +- Datei wuerde nach Aenderung 500 LOC ueberschreiten +- Datei mischt Transport + Business Logic + Persistence +- Datei enthaelt mehrere unabhaengig testbare Verantwortlichkeiten + +## Go (consent-service, billing-service) + +- Handler duenn halten (≤40 LOC pro Handler-Funktion) +- Business Logic in Services/Use-Cases +- Transport/Request-Decoding getrennt von Domain-Logik +- Dateien im gleichen Package teilen Typen automatisch — kein Re-Export noetig +- Models nach Domain splitten (user, consent, school, document, etc.) + +## Python (backend-core, night-scheduler) + +- Routes duenn halten — Business Logic in Services +- Persistenz in Repositories/Data-Access-Module +- Pydantic Schemas nach Domain splitten +- Zirkulaere Imports vermeiden + +## TypeScript / Next.js (admin-core, pitch-deck) + +- page.tsx duenn halten — Server Actions, Queries, Components auslagern +- _components/ + _hooks/ Konvention fuer Route-lokale Extracts +- .ts Dateien mit JSX muessen .tsx heissen (Turbopack!) +- Monolithische types.ts frueh splitten +- types.ts + types/ Shadowing vermeiden + +## Entscheidungsreihenfolge + +1. Bestehendes kleines kohaeesives Modul wiederverwenden +2. Neues Modul in der Naehe erstellen +3. Ueberfuellte Datei splitten, neues Verhalten in richtiges Split-Modul +4. Nur als letzter Ausweg: Grosse bestehende Datei erweitern + +## FINGER WEG (laufende RAG Pipeline) + +Diese Verzeichnisse duerfen NICHT refaktoriert werden: +- `control-pipeline/` — RAG/Control-Extraction Pipeline +- `rag-service/` — Semantische Suche +- `embedding-service/` — Text-Embeddings +- `voice-service/bqas/` — RAG Quality Assessment + +## Workflow (bei jeder Aenderung) + +1. Datei lesen + LOC pruefen +2. Wenn nahe am Budget → erst splitten +3. Minimale kohaerente Aenderung +4. Verifikation (Tests + Lint) +5. Zusammenfassung: Was geaendert, was verifiziert, Restrisiko + +## LOC-Check ausfuehren + +```bash +bash scripts/check-loc.sh --changed # nur geaenderte Dateien +bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations) +``` + +## Commit-Marker + +- `[split-required]` — Aenderung beginnt mit Datei-Split +- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh +- `[interface-change]` — Public API Contracts geaendert +- `[migration-approved]` — Schema-/Migrations-Aenderungen diff --git a/.claude/rules/loc-exceptions.txt b/.claude/rules/loc-exceptions.txt new file mode 100644 index 0000000..9e4bb87 --- /dev/null +++ b/.claude/rules/loc-exceptions.txt @@ -0,0 +1,35 @@ +# LOC Exceptions — BreakPilot Core +# Format: | owner= | reason= | review= +# +# Jede Ausnahme braucht Begruendung und Review-Datum. +# Temporaere Ausnahmen muessen mit [guardrail-change] Commit-Marker versehen werden. + +# Generated / Build Artifacts +**/node_modules/** | owner=infra | reason=npm packages | review=permanent +**/.next/** | owner=infra | reason=Next.js build output | review=permanent +**/__pycache__/** | owner=infra | reason=Python bytecode | review=permanent +**/venv/** | owner=infra | reason=Python virtualenv | review=permanent + +# Test-Dateien (duerfen groesser sein fuer Table-Driven Tests) +**/*test*.py | owner=all | reason=Tests mit Table-Driven Patterns duerfen groesser sein | review=permanent +**/*test*.go | owner=all | reason=Go Tests mit Table-Driven Patterns | review=permanent +**/*test*.ts | owner=all | reason=TypeScript Tests | review=permanent +**/tests/** | owner=all | reason=Test-Verzeichnisse | review=permanent + +# FINGER WEG — Laufende RAG Pipeline (NICHT anfassen!) +control-pipeline/** | owner=pipeline | reason=Laufende RAG Pipeline, parallele Jobs aktiv | review=permanent +rag-service/** | owner=pipeline | reason=Semantische Suche, produktiv | review=permanent +embedding-service/** | owner=pipeline | reason=Text-Embeddings, produktiv | review=permanent +voice-service/bqas/** | owner=pipeline | reason=RAG Quality Assessment, produktiv | review=permanent + +# Seed/Helper Scripts (keine Service-Logik) +scripts/seed-demo-and-screenshot.py | owner=infra | reason=Einmaliges Seed-Script, kein Service-Code | review=permanent +pitch-deck/scripts/import-finanzplan.py | owner=pitch-deck | reason=583 LOC, einmaliges Excel-Import-Script (9 Sheet-Importer), hardcodierte Row/Col-Mappings fuer eine Finanzplan-.xlsm-Datei, keine wiederverwendbare Logik | review=2027-01 + +# PDF Templates (reine statische HTML/CSS Strings, keine Logik) +backend-core/services/pdf_templates.py | owner=all | reason=519 LOC, rein statische Jinja2-HTML-Templates + CSS, keine Logik | review=2026-07 + +# Pitch Deck — pure data files (static text, translations, no logic) +pitch-deck/lib/presenter/presenter-faq.ts | owner=pitch-deck | reason=973 LOC, pure static FAQ array (questions/answers/keywords), no logic | review=2027-01 +pitch-deck/lib/presenter/presenter-script.ts | owner=pitch-deck | reason=608 LOC, pure static presenter script data + 3 trivial lookup functions | review=2027-01 +pitch-deck/lib/i18n.ts | owner=pitch-deck | reason=620 LOC, pure DE/EN translation dictionaries + 3 small format helpers | review=2027-01 diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/_components/DeploymentsTab.tsx b/admin-core/app/(admin)/infrastructure/ci-cd/_components/DeploymentsTab.tsx new file mode 100644 index 0000000..b99efd8 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/ci-cd/_components/DeploymentsTab.tsx @@ -0,0 +1,154 @@ +'use client' + +import type { ContainerInfo, DockerStats } from '../types' + +export function DeploymentsTab({ + dockerStats, + containerFilter, + setContainerFilter, + filteredContainers, + onContainerAction, + actionLoading, + onRefresh, +}: { + dockerStats: DockerStats | null + containerFilter: 'all' | 'running' | 'stopped' + setContainerFilter: (filter: 'all' | 'running' | 'stopped') => void + filteredContainers: ContainerInfo[] + onContainerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => void + actionLoading: string | null + onRefresh: () => void +}) { + const getStateColor = (state: string) => { + switch (state) { + case 'running': return 'bg-green-100 text-green-800' + case 'exited': + case 'dead': return 'bg-red-100 text-red-800' + case 'paused': return 'bg-yellow-100 text-yellow-800' + case 'restarting': return 'bg-blue-100 text-blue-800' + default: return 'bg-slate-100 text-slate-600' + } + } + + return ( +
+ {/* Header */} +
+
+

Docker Container

+ {dockerStats && ( +

+ {dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt +

+ )} +
+
+ + +
+
+ + {/* Container List */} + {filteredContainers.length === 0 ? ( +
Keine Container gefunden
+ ) : ( +
+ {filteredContainers.map((container) => ( +
+
+
+
+ {container.name} + + {container.state} + +
+
+ {container.image} + {container.ports.length > 0 && ( + + | {container.ports.slice(0, 2).join(', ')} + {container.ports.length > 2 && ` +${container.ports.length - 2}`} + + )} +
+ + {container.state === 'running' && ( +
+
+ CPU: + 80 ? 'text-red-600' : 'text-slate-700'}`}> + {container.cpu_percent.toFixed(1)}% + +
+
+ RAM: + 80 ? 'text-red-600' : 'text-slate-700'}`}> + {container.memory_usage} + + ({container.memory_percent.toFixed(1)}%) +
+
+ Net: + {container.network_rx} / {container.network_tx} +
+
+ )} +
+ +
+ {container.state === 'running' ? ( + <> + + + + ) : ( + + )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/_components/OverviewTab.tsx b/admin-core/app/(admin)/infrastructure/ci-cd/_components/OverviewTab.tsx new file mode 100644 index 0000000..39f17ed --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/ci-cd/_components/OverviewTab.tsx @@ -0,0 +1,168 @@ +'use client' + +import type { PipelineStatus, PipelineRun, SystemStats, DockerStats } from '../types' + +function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) { + const getColor = () => { + if (percent > 90) return 'bg-red-500' + if (percent > 70) return 'bg-yellow-500' + if (color === 'green') return 'bg-green-500' + if (color === 'purple') return 'bg-purple-500' + return 'bg-blue-500' + } + + return ( +
+
+
+ ) +} + +export function OverviewTab({ + pipelineStatus, + pipelineHistory, + systemStats, + dockerStats, +}: { + pipelineStatus: PipelineStatus | null + pipelineHistory: PipelineRun[] + systemStats: SystemStats | null + dockerStats: DockerStats | null +}) { + return ( +
+ {/* Status Cards */} +
+
+
+ + Gitea Status +
+

+ {pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'} +

+

http://macmini:3003

+
+ +
+
+ + + + Pipeline Runs +
+

{pipelineStatus?.total_runs || 0}

+

{pipelineStatus?.successful_runs || 0} erfolgreich

+
+ +
+
+ + + + Container +
+

{dockerStats?.running_containers || 0}

+

von {dockerStats?.total_containers || 0} laufend

+
+ +
+
+ + + + Letztes Update +
+

+ {pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'} +

+

+ {pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'} +

+
+
+ + {/* System Resources */} + {systemStats && ( +
+

+ + + + Server Ressourcen ({systemStats.hostname}) +

+
+
+
+ CPU + 80 ? 'text-red-600' : 'text-slate-900'}`}> + {systemStats.cpu.usage_percent.toFixed(1)}% + +
+ +
+
+
+ RAM + 80 ? 'text-red-600' : 'text-slate-900'}`}> + {systemStats.memory.usage_percent.toFixed(1)}% + +
+ +
+
+
+ Disk + 80 ? 'text-red-600' : 'text-slate-900'}`}> + {systemStats.disk.usage_percent.toFixed(1)}% + +
+ +
+
+
+ )} + + {/* Recent Pipeline Runs */} + {pipelineHistory.length > 0 && ( +
+

Letzte Pipeline Runs

+
+ {pipelineHistory.slice(0, 5).map((run) => ( +
+
+ +
+

{run.workflow || 'SBOM Pipeline'}

+

{run.branch} - {run.commit_sha.substring(0, 8)}

+
+
+
+

+ {run.status === 'success' ? 'Erfolgreich' : + run.status === 'failed' ? 'Fehlgeschlagen' : + run.status === 'running' ? 'Laeuft...' : run.status} +

+

+ {new Date(run.started_at).toLocaleString('de-DE')} +

+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/_components/PipelinesTab.tsx b/admin-core/app/(admin)/infrastructure/ci-cd/_components/PipelinesTab.tsx new file mode 100644 index 0000000..24a93a5 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/ci-cd/_components/PipelinesTab.tsx @@ -0,0 +1,143 @@ +'use client' + +import type { PipelineRun } from '../types' + +export function PipelinesTab({ + pipelineHistory, + triggeringPipeline, + onTriggerPipeline, +}: { + pipelineHistory: PipelineRun[] + triggeringPipeline: boolean + onTriggerPipeline: () => void +}) { + return ( +
+ {/* Pipeline Controls */} +
+
+

Gitea Actions Pipelines

+

Workflows werden bei Push auf main/develop automatisch ausgefuehrt

+
+ +
+ + {/* Available Pipelines */} +
+
+
+ + SBOM Pipeline +
+

Generiert Software Bill of Materials

+

5 Jobs: generate, scan, license, upload, summary

+
+
+
+ + Test Pipeline +
+

Unit & Integration Tests

+

Geplant

+
+
+
+ + Security Pipeline +
+

SAST, SCA, Secrets Scan

+

Geplant

+
+
+ + {/* Pipeline History */} +
+

Pipeline Historie

+ {pipelineHistory.length === 0 ? ( +
+ Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline! +
+ ) : ( +
+ + + + + + + + + + + + + {pipelineHistory.map((run) => ( + + + + + + + + + ))} + +
StatusWorkflowBranchCommitGestartetDauer
+ + + {run.status} + + {run.workflow || 'SBOM Pipeline'}{run.branch}{run.commit_sha.substring(0, 8)}{new Date(run.started_at).toLocaleString('de-DE')} + {run.duration_seconds ? `${run.duration_seconds}s` : '-'} +
+
+ )} +
+ + {/* Pipeline Architecture */} +
+

SBOM Pipeline Architektur

+
+{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
+     │
+     ├── 1. generate-sbom     → Syft generiert CycloneDX SBOM
+     │
+     ├── 2. vulnerability-scan → Grype scannt auf CVEs
+     │
+     ├── 3. license-check     → Prueft GPL/AGPL Lizenzen
+     │
+     ├── 4. upload-dashboard  → POST /api/v1/security/sbom/upload
+     │
+     └── 5. summary           → Job Summary generieren`}
+        
+
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/_components/SchedulerTab.tsx b/admin-core/app/(admin)/infrastructure/ci-cd/_components/SchedulerTab.tsx new file mode 100644 index 0000000..89ca306 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/ci-cd/_components/SchedulerTab.tsx @@ -0,0 +1,187 @@ +'use client' + +export function SchedulerTab() { + return ( +
+ {/* Status Overview */} +
+
+
+
+ + + +
+
+
+

launchd Job

+ +
+

Taeglich um 07:00 Uhr automatisch

+
+
+
+
+
+
+ + + +
+
+
+

Git Hook

+ +
+

Quick Tests bei voice-service Aenderungen

+
+
+
+
+
+
+ + + +
+
+
+

Benachrichtigungen

+ +
+

Desktop-Alerts bei Fehlern aktiviert

+
+
+
+
+ + {/* Quick Actions */} +
+

Quick Actions (BQAS)

+
+ + + + + + Test Dashboard oeffnen + + Starte Tests direkt im BQAS Dashboard +
+
+ + {/* GitHub Actions vs Local - Comparison */} +
+

GitHub Actions Alternative

+

+ Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung. +

+
+ + + + + + + + + + {[ + { feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'bg-emerald-100 text-emerald-700' }, + { feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'bg-emerald-100 text-emerald-700' }, + { feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'bg-emerald-100 text-emerald-700', local: 'Nicht moeglich', localColor: 'bg-amber-100 text-amber-700' }, + { feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'bg-amber-100 text-amber-700', local: '100% lokal', localColor: 'bg-emerald-100 text-emerald-700' }, + { feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'bg-red-100 text-red-700', local: 'Ja', localColor: 'bg-emerald-100 text-emerald-700' }, + ].map((row) => ( + + + + + + ))} + +
FeatureGitHub ActionsLokaler Scheduler
{row.feature} + {row.gh} + + {row.local} +
+
+
+ + {/* Configuration Details */} +
+

Konfiguration

+
+
+

launchd Job

+
+
{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
+Label: com.breakpilot.bqas
+Schedule: 07:00 taeglich
+Script: /voice-service/scripts/run_bqas.sh
+Logs: /var/log/bqas/`}
+
+
+
+

Umgebungsvariablen

+
+
+ BQAS_SERVICE_URL + http://localhost:8091 +
+
+ BQAS_REGRESSION_THRESHOLD + 0.1 +
+
+ BQAS_NOTIFY_DESKTOP + true +
+
+ BQAS_NOTIFY_SLACK + false +
+
+
+
+
+ + {/* Detailed Explanation */} +
+

+ + + + Detaillierte Erklaerung +

+
+

Warum ein lokaler Scheduler?

+

+ Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten, + aber mit dem entscheidenden Vorteil, dass alle Daten zu 100% auf dem lokalen Mac Mini verbleiben. + Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden. +

+

Komponenten

+
    +
  • run_bqas.sh - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
  • +
  • launchd Job - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
  • +
  • Git Hook - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
  • +
  • Notifier - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet
  • +
+

Installation

+
+ ./voice-service/scripts/install_bqas_scheduler.sh install +
+

Vorteile gegenueber GitHub Actions

+
    +
  • 100% DSGVO-konform - alle Daten bleiben lokal
  • +
  • Keine Internet-Abhaengigkeit - funktioniert auch offline
  • +
  • Keine GitHub-Kosten fuer private Repositories
  • +
  • Schnellere Ausfuehrung ohne Cloud-Overhead
  • +
  • Volle Kontrolle ueber Scheduling und Benachrichtigungen
  • +
+
+
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/_components/SetupTab.tsx b/admin-core/app/(admin)/infrastructure/ci-cd/_components/SetupTab.tsx new file mode 100644 index 0000000..c9a6981 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/ci-cd/_components/SetupTab.tsx @@ -0,0 +1,152 @@ +'use client' + +import type { PipelineStatus } from '../types' + +export function SetupTab({ pipelineStatus }: { pipelineStatus: PipelineStatus | null }) { + return ( +
+
+

Erstkonfiguration - Gitea CI/CD

+

+ Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server. +

+
+ + {/* Gitea Server Info */} +
+

+ + + + Gitea Server +

+
+
+

Web-URL

+

http://macmini:3003

+
+
+

SSH

+

macmini:2222

+
+
+

Status

+

+ {pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'} +

+
+
+
+ + {/* Implementierte Komponenten */} +
+

Implementierte Komponenten

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KomponentePfadBeschreibung
Gitea Servicedocker-compose.ymlGitea 1.22 mit Actions enabled
Gitea Runnerdocker-compose.ymlact_runner fuer Job-Ausfuehrung
SBOM Workflow.gitea/workflows/sbom.yaml5 Jobs: generate, scan, license, upload, summary
Backend APIbackend/security_api.pySBOM Upload, Pipeline Status, History
Runner Configgitea/runner-config.yamlLabels: ubuntu-latest, self-hosted
+
+
+ + {/* Setup Steps */} +
+

+ + + + Setup-Schritte +

+
+
+
1. Gitea oeffnen
+ http://macmini:3003 +
+
+
2. Admin-Account erstellen
+

Username: admin, Email: admin@breakpilot.de

+
+
+
3. Repository erstellen
+

Name: breakpilot-pwa, Visibility: Private

+
+
+
4. Actions aktivieren
+

Repository Settings → Actions → Enable Repository Actions

+
+
+
5. Runner Token erstellen & starten
+
+{`export GITEA_RUNNER_TOKEN=
+docker compose up -d gitea-runner`}
+            
+
+
+
6. Repository pushen
+
+{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
+git push gitea main`}
+            
+
+
+
+ + {/* Quick Links */} + +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/page.tsx b/admin-core/app/(admin)/infrastructure/ci-cd/page.tsx index f30ba01..9d01018 100644 --- a/admin-core/app/(admin)/infrastructure/ci-cd/page.tsx +++ b/admin-core/app/(admin)/infrastructure/ci-cd/page.tsx @@ -13,115 +13,12 @@ import { useState, useEffect, useCallback } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar' - -// ============================================================================ -// Types -// ============================================================================ - -interface PipelineStatus { - gitea_connected: boolean - gitea_url: string - last_sbom_update: string | null - total_runs: number - successful_runs: number - failed_runs: number -} - -interface PipelineRun { - id: string - workflow: string - branch: string - commit_sha: string - status: 'success' | 'failed' | 'running' | 'pending' - started_at: string - finished_at: string | null - duration_seconds: number | null -} - -interface ContainerInfo { - id: string - name: string - image: string - status: string - state: string - created: string - ports: string[] - cpu_percent: number - memory_usage: string - memory_limit: string - memory_percent: number - network_rx: string - network_tx: string -} - -interface SystemStats { - hostname: string - platform: string - arch: string - uptime: number - cpu: { - model: string - cores: number - usage_percent: number - } - memory: { - total: string - used: string - free: string - usage_percent: number - } - disk: { - total: string - used: string - free: string - usage_percent: number - } -} - -interface DockerStats { - containers: ContainerInfo[] - total_containers: number - running_containers: number - stopped_containers: number -} - -type TabType = 'overview' | 'pipelines' | 'deployments' | 'setup' | 'scheduler' - -// ============================================================================ -// Helper Components -// ============================================================================ - -function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) { - const getColor = () => { - if (percent > 90) return 'bg-red-500' - if (percent > 70) return 'bg-yellow-500' - if (color === 'green') return 'bg-green-500' - if (color === 'purple') return 'bg-purple-500' - return 'bg-blue-500' - } - - return ( -
-
-
- ) -} - -function formatUptime(seconds: number): string { - const days = Math.floor(seconds / 86400) - const hours = Math.floor((seconds % 86400) / 3600) - const minutes = Math.floor((seconds % 3600) / 60) - if (days > 0) return `${days}d ${hours}h ${minutes}m` - if (hours > 0) return `${hours}h ${minutes}m` - return `${minutes}m` -} - -// ============================================================================ -// Main Component -// ============================================================================ +import type { PipelineStatus, PipelineRun, SystemStats, DockerStats, TabType } from './types' +import { OverviewTab } from './_components/OverviewTab' +import { PipelinesTab } from './_components/PipelinesTab' +import { DeploymentsTab } from './_components/DeploymentsTab' +import { SetupTab } from './_components/SetupTab' +import { SchedulerTab } from './_components/SchedulerTab' export default function CICDPage() { const [activeTab, setActiveTab] = useState('overview') @@ -144,23 +41,15 @@ export default function CICDPage() { const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '' - // ============================================================================ // Data Loading - // ============================================================================ - const loadPipelineData = useCallback(async () => { try { const [statusRes, historyRes] = await Promise.all([ fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/status`), fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/history`), ]) - - if (statusRes.ok) { - setPipelineStatus(await statusRes.json()) - } - if (historyRes.ok) { - setPipelineHistory(await historyRes.json()) - } + if (statusRes.ok) setPipelineStatus(await statusRes.json()) + if (historyRes.ok) setPipelineHistory(await historyRes.json()) } catch (err) { console.error('Failed to load pipeline data:', err) } @@ -186,26 +75,17 @@ export default function CICDPage() { setLoading(false) }, [loadPipelineData, loadContainerData]) - useEffect(() => { - loadAllData() - }, [loadAllData]) - - // Auto-refresh every 30 seconds + useEffect(() => { loadAllData() }, [loadAllData]) useEffect(() => { const interval = setInterval(loadAllData, 30000) return () => clearInterval(interval) }, [loadAllData]) - // ============================================================================ // Actions - // ============================================================================ - const triggerPipeline = async () => { setTriggeringPipeline(true) try { - const response = await fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/trigger`, { - method: 'POST', - }) + const response = await fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/trigger`, { method: 'POST' }) if (response.ok) { setMessage('Pipeline gestartet!') setTimeout(loadPipelineData, 2000) @@ -221,18 +101,13 @@ export default function CICDPage() { const containerAction = async (containerId: string, action: 'start' | 'stop' | 'restart') => { setActionLoading(`${containerId}-${action}`) setMessage(null) - try { const response = await fetch('/api/admin/infrastructure/mac-mini', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ container_id: containerId, action }), }) - - if (!response.ok) { - throw new Error('Aktion fehlgeschlagen') - } - + if (!response.ok) throw new Error('Aktion fehlgeschlagen') setMessage(`Container ${action} erfolgreich`) setTimeout(loadContainerData, 1000) setTimeout(loadContainerData, 3000) @@ -243,21 +118,6 @@ export default function CICDPage() { } } - // ============================================================================ - // Helpers - // ============================================================================ - - const getStateColor = (state: string) => { - switch (state) { - case 'running': return 'bg-green-100 text-green-800' - case 'exited': - case 'dead': return 'bg-red-100 text-red-800' - case 'paused': return 'bg-yellow-100 text-yellow-800' - case 'restarting': return 'bg-blue-100 text-blue-800' - default: return 'bg-slate-100 text-slate-600' - } - } - const filteredContainers = dockerStats?.containers.filter(c => { if (containerFilter === 'all') return true if (containerFilter === 'running') return c.state === 'running' @@ -265,9 +125,13 @@ export default function CICDPage() { return true }) || [] - // ============================================================================ - // Render - // ============================================================================ + const TAB_CONFIG = [ + { id: 'overview' as const, name: 'Uebersicht', icon: }, + { id: 'pipelines' as const, name: 'Gitea Pipelines', icon: }, + { id: 'deployments' as const, name: 'Deployments', icon: }, + { id: 'setup' as const, name: 'Konfiguration', icon: }, + { id: 'scheduler' as const, name: 'BQAS Scheduler', icon: }, + ] return (
@@ -275,10 +139,7 @@ export default function CICDPage() { title="CI/CD Dashboard" purpose="Zentrale Uebersicht fuer Gitea Actions Pipelines, Runner-Status und Container-Deployments. Starten Sie Pipelines manuell und verwalten Sie Docker-Container." audience={['DevOps', 'Entwickler']} - architecture={{ - services: ['Gitea Actions', 'act_runner', 'Docker'], - databases: [], - }} + architecture={{ services: ['Gitea Actions', 'act_runner', 'Docker'], databases: [] }} relatedPages={[ { name: 'SBOM', href: '/infrastructure/sbom', description: 'Software Bill of Materials' }, { name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' }, @@ -288,10 +149,8 @@ export default function CICDPage() { defaultCollapsed={true} /> - {/* DevOps Pipeline Sidebar */} - {/* Messages */} {error && (
@@ -314,42 +173,13 @@ export default function CICDPage() {
)} - {/* Main Content */}
- {/* Tabs */}
- {/* Tab Content */}
{loading ? (
@@ -371,797 +200,11 @@ export default function CICDPage() {
) : ( <> - {/* ================================================================ */} - {/* Overview Tab */} - {/* ================================================================ */} - {activeTab === 'overview' && ( -
- {/* Status Cards */} -
-
-
- - Gitea Status -
-

- {pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'} -

-

http://macmini:3003

-
- -
-
- - - - Pipeline Runs -
-

{pipelineStatus?.total_runs || 0}

-

{pipelineStatus?.successful_runs || 0} erfolgreich

-
- -
-
- - - - Container -
-

{dockerStats?.running_containers || 0}

-

von {dockerStats?.total_containers || 0} laufend

-
- -
-
- - - - Letztes Update -
-

- {pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'} -

-

- {pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'} -

-
-
- - {/* System Resources */} - {systemStats && ( -
-

- - - - Server Ressourcen ({systemStats.hostname}) -

-
-
-
- CPU - 80 ? 'text-red-600' : 'text-slate-900'}`}> - {systemStats.cpu.usage_percent.toFixed(1)}% - -
- -
-
-
- RAM - 80 ? 'text-red-600' : 'text-slate-900'}`}> - {systemStats.memory.usage_percent.toFixed(1)}% - -
- -
-
-
- Disk - 80 ? 'text-red-600' : 'text-slate-900'}`}> - {systemStats.disk.usage_percent.toFixed(1)}% - -
- -
-
-
- )} - - {/* Recent Pipeline Runs */} - {pipelineHistory.length > 0 && ( -
-

Letzte Pipeline Runs

-
- {pipelineHistory.slice(0, 5).map((run) => ( -
-
- -
-

{run.workflow || 'SBOM Pipeline'}

-

{run.branch} - {run.commit_sha.substring(0, 8)}

-
-
-
-

- {run.status === 'success' ? 'Erfolgreich' : - run.status === 'failed' ? 'Fehlgeschlagen' : - run.status === 'running' ? 'Laeuft...' : run.status} -

-

- {new Date(run.started_at).toLocaleString('de-DE')} -

-
-
- ))} -
-
- )} -
- )} - - {/* ================================================================ */} - {/* Pipelines Tab */} - {/* ================================================================ */} - {activeTab === 'pipelines' && ( -
- {/* Pipeline Controls */} -
-
-

Gitea Actions Pipelines

-

Workflows werden bei Push auf main/develop automatisch ausgefuehrt

-
- -
- - {/* Available Pipelines */} -
-
-
- - SBOM Pipeline -
-

Generiert Software Bill of Materials

-

5 Jobs: generate, scan, license, upload, summary

-
-
-
- - Test Pipeline -
-

Unit & Integration Tests

-

Geplant

-
-
-
- - Security Pipeline -
-

SAST, SCA, Secrets Scan

-

Geplant

-
-
- - {/* Pipeline History */} -
-

Pipeline Historie

- {pipelineHistory.length === 0 ? ( -
- Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline! -
- ) : ( -
- - - - - - - - - - - - - {pipelineHistory.map((run) => ( - - - - - - - - - ))} - -
StatusWorkflowBranchCommitGestartetDauer
- - - {run.status} - - {run.workflow || 'SBOM Pipeline'}{run.branch}{run.commit_sha.substring(0, 8)}{new Date(run.started_at).toLocaleString('de-DE')} - {run.duration_seconds ? `${run.duration_seconds}s` : '-'} -
-
- )} -
- - {/* Pipeline Architecture */} -
-

SBOM Pipeline Architektur

-
-{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
-     │
-     ├── 1. generate-sbom     → Syft generiert CycloneDX SBOM
-     │
-     ├── 2. vulnerability-scan → Grype scannt auf CVEs
-     │
-     ├── 3. license-check     → Prueft GPL/AGPL Lizenzen
-     │
-     ├── 4. upload-dashboard  → POST /api/v1/security/sbom/upload
-     │
-     └── 5. summary           → Job Summary generieren`}
-                    
-
-
- )} - - {/* ================================================================ */} - {/* Deployments Tab */} - {/* ================================================================ */} - {activeTab === 'deployments' && ( -
- {/* Header */} -
-
-

Docker Container

- {dockerStats && ( -

- {dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt -

- )} -
-
- - -
-
- - {/* Container List */} - {filteredContainers.length === 0 ? ( -
Keine Container gefunden
- ) : ( -
- {filteredContainers.map((container) => ( -
-
-
-
- {container.name} - - {container.state} - -
-
- {container.image} - {container.ports.length > 0 && ( - - | {container.ports.slice(0, 2).join(', ')} - {container.ports.length > 2 && ` +${container.ports.length - 2}`} - - )} -
- - {container.state === 'running' && ( -
-
- CPU: - 80 ? 'text-red-600' : 'text-slate-700'}`}> - {container.cpu_percent.toFixed(1)}% - -
-
- RAM: - 80 ? 'text-red-600' : 'text-slate-700'}`}> - {container.memory_usage} - - ({container.memory_percent.toFixed(1)}%) -
-
- Net: - {container.network_rx} / {container.network_tx} -
-
- )} -
- -
- {container.state === 'running' ? ( - <> - - - - ) : ( - - )} -
-
-
- ))} -
- )} -
- )} - - {/* ================================================================ */} - {/* Setup Tab */} - {/* ================================================================ */} - {activeTab === 'setup' && ( -
-
-

Erstkonfiguration - Gitea CI/CD

-

- Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server. -

-
- - {/* Gitea Server Info */} -
-

- - - - Gitea Server -

-
-
-

Web-URL

-

http://macmini:3003

-
-
-

SSH

-

macmini:2222

-
-
-

Status

-

- {pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'} -

-
-
-
- - {/* Implementierte Komponenten */} -
-

Implementierte Komponenten

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KomponentePfadBeschreibung
Gitea Servicedocker-compose.ymlGitea 1.22 mit Actions enabled
Gitea Runnerdocker-compose.ymlact_runner fuer Job-Ausfuehrung
SBOM Workflow.gitea/workflows/sbom.yaml5 Jobs: generate, scan, license, upload, summary
Backend APIbackend/security_api.pySBOM Upload, Pipeline Status, History
Runner Configgitea/runner-config.yamlLabels: ubuntu-latest, self-hosted
-
-
- - {/* Setup Steps */} -
-

- - - - Setup-Schritte -

-
-
-
1. Gitea oeffnen
- http://macmini:3003 -
-
-
2. Admin-Account erstellen
-

Username: admin, Email: admin@breakpilot.de

-
-
-
3. Repository erstellen
-

Name: breakpilot-pwa, Visibility: Private

-
-
-
4. Actions aktivieren
-

Repository Settings → Actions → Enable Repository Actions

-
-
-
5. Runner Token erstellen & starten
-
-{`export GITEA_RUNNER_TOKEN=
-docker compose up -d gitea-runner`}
-                        
-
-
-
6. Repository pushen
-
-{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
-git push gitea main`}
-                        
-
-
-
- - {/* Quick Links */} - -
- )} - - {/* ================================================================ */} - {/* Scheduler Tab (BQAS) */} - {/* ================================================================ */} - {activeTab === 'scheduler' && ( -
- {/* Status Overview */} -
-
-
-
- - - -
-
-
-

launchd Job

- -
-

Taeglich um 07:00 Uhr automatisch

-
-
-
-
-
-
- - - -
-
-
-

Git Hook

- -
-

Quick Tests bei voice-service Aenderungen

-
-
-
-
-
-
- - - -
-
-
-

Benachrichtigungen

- -
-

Desktop-Alerts bei Fehlern aktiviert

-
-
-
-
- - {/* Quick Actions */} -
-

Quick Actions (BQAS)

-
- - - - - - Test Dashboard oeffnen - - - Starte Tests direkt im BQAS Dashboard - -
-
- - {/* GitHub Actions vs Local - Comparison */} -
-

GitHub Actions Alternative

-

- Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung. -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FeatureGitHub ActionsLokaler Scheduler
Taegliche Tests (07:00) - schedule: cron - - macOS launchd -
Push-basierte Tests - on: push - - Git post-commit Hook -
PR-basierte Tests - on: pull_request - - Nicht moeglich -
DSGVO-Konformitaet - Daten bei GitHub (US) - - 100% lokal -
Offline-Faehig - Nein - - Ja -
-
-
- - {/* Configuration Details */} -
-

Konfiguration

- -
- {/* launchd Configuration */} -
-

launchd Job

-
-
{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
-Label: com.breakpilot.bqas
-Schedule: 07:00 taeglich
-Script: /voice-service/scripts/run_bqas.sh
-Logs: /var/log/bqas/`}
-
-
- - {/* Environment Variables */} -
-

Umgebungsvariablen

-
-
- BQAS_SERVICE_URL - http://localhost:8091 -
-
- BQAS_REGRESSION_THRESHOLD - 0.1 -
-
- BQAS_NOTIFY_DESKTOP - true -
-
- BQAS_NOTIFY_SLACK - false -
-
-
-
-
- - {/* Detailed Explanation */} -
-

- - - - Detaillierte Erklaerung -

- -
-

Warum ein lokaler Scheduler?

-

- Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten, - aber mit dem entscheidenden Vorteil, dass alle Daten zu 100% auf dem lokalen Mac Mini verbleiben. - Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden. -

- -

Komponenten

-
    -
  • - run_bqas.sh - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet -
  • -
  • - launchd Job - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet -
  • -
  • - Git Hook - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet -
  • -
  • - Notifier - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet -
  • -
- -

Installation

-
- ./voice-service/scripts/install_bqas_scheduler.sh install -
- -

Vorteile gegenueber GitHub Actions

-
    -
  • 100% DSGVO-konform - alle Daten bleiben lokal
  • -
  • Keine Internet-Abhaengigkeit - funktioniert auch offline
  • -
  • Keine GitHub-Kosten fuer private Repositories
  • -
  • Schnellere Ausfuehrung ohne Cloud-Overhead
  • -
  • Volle Kontrolle ueber Scheduling und Benachrichtigungen
  • -
-
-
-
- )} + {activeTab === 'overview' && } + {activeTab === 'pipelines' && } + {activeTab === 'deployments' && } + {activeTab === 'setup' && } + {activeTab === 'scheduler' && } )}
diff --git a/admin-core/app/(admin)/infrastructure/ci-cd/types.ts b/admin-core/app/(admin)/infrastructure/ci-cd/types.ts new file mode 100644 index 0000000..eb4f6dc --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/ci-cd/types.ts @@ -0,0 +1,72 @@ +/** + * Types for CI/CD Dashboard + */ + +export interface PipelineStatus { + gitea_connected: boolean + gitea_url: string + last_sbom_update: string | null + total_runs: number + successful_runs: number + failed_runs: number +} + +export interface PipelineRun { + id: string + workflow: string + branch: string + commit_sha: string + status: 'success' | 'failed' | 'running' | 'pending' + started_at: string + finished_at: string | null + duration_seconds: number | null +} + +export interface ContainerInfo { + id: string + name: string + image: string + status: string + state: string + created: string + ports: string[] + cpu_percent: number + memory_usage: string + memory_limit: string + memory_percent: number + network_rx: string + network_tx: string +} + +export interface SystemStats { + hostname: string + platform: string + arch: string + uptime: number + cpu: { + model: string + cores: number + usage_percent: number + } + memory: { + total: string + used: string + free: string + usage_percent: number + } + disk: { + total: string + used: string + free: string + usage_percent: number + } +} + +export interface DockerStats { + containers: ContainerInfo[] + total_containers: number + running_containers: number + stopped_containers: number +} + +export type TabType = 'overview' | 'pipelines' | 'deployments' | 'setup' | 'scheduler' diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/ConfigTab.tsx b/admin-core/app/(admin)/infrastructure/middleware/_components/ConfigTab.tsx new file mode 100644 index 0000000..15f0b06 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/ConfigTab.tsx @@ -0,0 +1,61 @@ +import type { MiddlewareConfig } from '../types' +import { getMiddlewareDescription } from './helpers' + +interface ConfigTabProps { + configs: MiddlewareConfig[] + actionLoading: string | null + onToggle: (name: string, enabled: boolean) => void +} + +export function ConfigTab({ configs, actionLoading, onToggle }: ConfigTabProps) { + return ( +
+ {configs.map(config => { + const info = getMiddlewareDescription(config.middleware_name) + return ( +
+
+
+

+ {info.icon} + {config.middleware_name.replace('_', ' ')} +

+

{info.desc}

+
+
+ + {config.enabled ? 'Aktiviert' : 'Deaktiviert'} + + +
+
+ {Object.keys(config.config).length > 0 && ( +
+
+ Konfiguration +
+
+                  {JSON.stringify(config.config, null, 2)}
+                
+
+ )} +
+ ) + })} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/EventsTab.tsx b/admin-core/app/(admin)/infrastructure/middleware/_components/EventsTab.tsx new file mode 100644 index 0000000..fbc62a6 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/EventsTab.tsx @@ -0,0 +1,69 @@ +import type { MiddlewareEvent } from '../types' +import { getEventTypeColor } from './helpers' + +interface EventsTabProps { + events: MiddlewareEvent[] +} + +export function EventsTab({ events }: EventsTabProps) { + return ( +
+ + + + + + + + + + + + {events.length === 0 ? ( + + + + ) : ( + events.map(event => ( + + + + + + + + )) + )} + +
+ Zeit + + Middleware + + Event + + IP + + Pfad +
+ Keine Events vorhanden. +
+ {new Date(event.created_at).toLocaleString('de-DE')} + + {event.middleware_name.replace('_', ' ')} + + + {event.event_type} + + + {event.ip_address || '-'} + + {event.request_method && event.request_path + ? `${event.request_method} ${event.request_path}` + : '-'} +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/IpListTab.tsx b/admin-core/app/(admin)/infrastructure/middleware/_components/IpListTab.tsx new file mode 100644 index 0000000..a944791 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/IpListTab.tsx @@ -0,0 +1,131 @@ +import type { RateLimitIP } from '../types' + +interface IpListTabProps { + ipList: RateLimitIP[] + actionLoading: string | null + newIP: string + newIPType: 'whitelist' | 'blacklist' + newIPReason: string + onNewIPChange: (value: string) => void + onNewIPTypeChange: (value: 'whitelist' | 'blacklist') => void + onNewIPReasonChange: (value: string) => void + onAddIP: (e: React.FormEvent) => void + onRemoveIP: (id: string) => void +} + +export function IpListTab({ + ipList, + actionLoading, + newIP, + newIPType, + newIPReason, + onNewIPChange, + onNewIPTypeChange, + onNewIPReasonChange, + onAddIP, + onRemoveIP, +}: IpListTabProps) { + return ( +
+ {/* Add IP Form */} +
+

IP hinzufuegen

+
+ onNewIPChange(e.target.value)} + placeholder="IP-Adresse (z.B. 192.168.1.1)" + className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500" + /> + + onNewIPReasonChange(e.target.value)} + placeholder="Grund (optional)" + className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500" + /> + +
+
+ + {/* IP List Table */} +
+ + + + + + + + + + + + {ipList.length === 0 ? ( + + + + ) : ( + ipList.map(ip => ( + + + + + + + + )) + )} + +
+ IP-Adresse + + Typ + + Grund + + Hinzugefuegt + + Aktion +
+ Keine IP-Eintraege vorhanden. +
{ip.ip_address} + + {ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'} + + {ip.reason || '-'} + {new Date(ip.created_at).toLocaleString('de-DE')} + + +
+
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/OverviewTab.tsx b/admin-core/app/(admin)/infrastructure/middleware/_components/OverviewTab.tsx new file mode 100644 index 0000000..9b3bd9b --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/OverviewTab.tsx @@ -0,0 +1,58 @@ +import type { MiddlewareConfig } from '../types' +import { getMiddlewareDescription } from './helpers' + +interface OverviewTabProps { + configs: MiddlewareConfig[] + actionLoading: string | null + onToggle: (name: string, enabled: boolean) => void +} + +export function OverviewTab({ configs, actionLoading, onToggle }: OverviewTabProps) { + return ( +
+ {configs.map(config => { + const info = getMiddlewareDescription(config.middleware_name) + return ( +
+
+
+ {info.icon} + + {config.middleware_name.replace('_', ' ')} + +
+ +
+

{info.desc}

+ {config.updated_at && ( +
+ Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')} +
+ )} +
+ ) + })} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/StatsTab.tsx b/admin-core/app/(admin)/infrastructure/middleware/_components/StatsTab.tsx new file mode 100644 index 0000000..d79baa1 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/StatsTab.tsx @@ -0,0 +1,66 @@ +import type { MiddlewareStats } from '../types' +import { getMiddlewareDescription, getEventTypeColor } from './helpers' + +interface StatsTabProps { + stats: MiddlewareStats[] +} + +export function StatsTab({ stats }: StatsTabProps) { + return ( +
+ {stats.map(stat => { + const info = getMiddlewareDescription(stat.middleware_name) + return ( +
+

+ {info.icon} + {stat.middleware_name.replace('_', ' ')} +

+
+
+
{stat.total_events}
+
Gesamt
+
+
+
{stat.events_last_hour}
+
Letzte Stunde
+
+
+
{stat.events_last_24h}
+
24 Stunden
+
+
+ {stat.top_event_types.length > 0 && ( +
+
+ Top Event-Typen +
+
+ {stat.top_event_types.slice(0, 3).map(et => ( + + {et.event_type} ({et.count}) + + ))} +
+
+ )} + {stat.top_ips.length > 0 && ( +
+
Top IPs
+
+ {stat.top_ips + .slice(0, 3) + .map(ip => `${ip.ip_address} (${ip.count})`) + .join(', ')} +
+
+ )} +
+ ) + })} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/StatusOverview.tsx b/admin-core/app/(admin)/infrastructure/middleware/_components/StatusOverview.tsx new file mode 100644 index 0000000..0ae099a --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/StatusOverview.tsx @@ -0,0 +1,51 @@ +interface StatusOverviewProps { + configCount: number + whitelistCount: number + blacklistCount: number + eventCount: number + loading: boolean + onRefresh: () => void +} + +export function StatusOverview({ + configCount, + whitelistCount, + blacklistCount, + eventCount, + loading, + onRefresh, +}: StatusOverviewProps) { + return ( +
+
+

Middleware Status

+ +
+ +
+
+
{configCount}
+
Middleware
+
+
+
{whitelistCount}
+
Whitelist IPs
+
+
+
{blacklistCount}
+
Blacklist IPs
+
+
+
{eventCount}
+
Recent Events
+
+
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/_components/helpers.ts b/admin-core/app/(admin)/infrastructure/middleware/_components/helpers.ts new file mode 100644 index 0000000..3b15444 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/_components/helpers.ts @@ -0,0 +1,24 @@ +export function getMiddlewareDescription(name: string): { icon: string; desc: string } { + const descriptions: Record = { + request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' }, + security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' }, + cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' }, + rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' }, + pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' }, + input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' }, + } + return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' } +} + +export function getEventTypeColor(eventType: string) { + if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) { + return 'bg-red-100 text-red-800' + } + if (eventType.includes('warning') || eventType.includes('rate_limit')) { + return 'bg-yellow-100 text-yellow-800' + } + if (eventType.includes('success') || eventType.includes('whitelist')) { + return 'bg-green-100 text-green-800' + } + return 'bg-slate-100 text-slate-800' +} diff --git a/admin-core/app/(admin)/infrastructure/middleware/page.tsx b/admin-core/app/(admin)/infrastructure/middleware/page.tsx index 7757a6f..6c381fd 100644 --- a/admin-core/app/(admin)/infrastructure/middleware/page.tsx +++ b/admin-core/app/(admin)/infrastructure/middleware/page.tsx @@ -9,44 +9,13 @@ import { useEffect, useState, useCallback } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' - -interface MiddlewareConfig { - id: string - middleware_name: string - enabled: boolean - config: Record - updated_at: string | null -} - -interface RateLimitIP { - id: string - ip_address: string - list_type: 'whitelist' | 'blacklist' - reason: string | null - expires_at: string | null - created_at: string -} - -interface MiddlewareEvent { - id: string - middleware_name: string - event_type: string - ip_address: string | null - user_id: string | null - request_path: string | null - request_method: string | null - details: Record | null - created_at: string -} - -interface MiddlewareStats { - middleware_name: string - total_events: number - events_last_hour: number - events_last_24h: number - top_event_types: Array<{ event_type: string; count: number }> - top_ips: Array<{ ip_address: string; count: number }> -} +import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats } from './types' +import { StatusOverview } from './_components/StatusOverview' +import { OverviewTab } from './_components/OverviewTab' +import { ConfigTab } from './_components/ConfigTab' +import { IpListTab } from './_components/IpListTab' +import { EventsTab } from './_components/EventsTab' +import { StatsTab } from './_components/StatsTab' export default function MiddlewareAdminPage() { const [configs, setConfigs] = useState([]) @@ -184,31 +153,6 @@ export default function MiddlewareAdminPage() { } } - const getMiddlewareDescription = (name: string): { icon: string; desc: string } => { - const descriptions: Record = { - request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' }, - security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' }, - cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' }, - rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' }, - pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' }, - input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' }, - } - return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' } - } - - const getEventTypeColor = (eventType: string) => { - if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) { - return 'bg-red-100 text-red-800' - } - if (eventType.includes('warning') || eventType.includes('rate_limit')) { - return 'bg-yellow-100 text-yellow-800' - } - if (eventType.includes('success') || eventType.includes('whitelist')) { - return 'bg-green-100 text-green-800' - } - return 'bg-slate-100 text-slate-800' - } - const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length @@ -232,38 +176,14 @@ export default function MiddlewareAdminPage() { defaultCollapsed={true} /> - {/* Stats Overview */} -
-
-

Middleware Status

- -
- -
-
-
{configs.length}
-
Middleware
-
-
-
{whitelistCount}
-
Whitelist IPs
-
-
-
{blacklistCount}
-
Blacklist IPs
-
-
-
{events.length}
-
Recent Events
-
-
-
+ {/* Tabs */}
@@ -298,332 +218,28 @@ export default function MiddlewareAdminPage() {
) : ( <> - {/* Overview Tab */} {activeTab === 'overview' && ( -
- {configs.map(config => { - const info = getMiddlewareDescription(config.middleware_name) - return ( -
-
-
- {info.icon} - - {config.middleware_name.replace('_', ' ')} - -
- -
-

{info.desc}

- {config.updated_at && ( -
- Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')} -
- )} -
- ) - })} -
+ )} - - {/* Config Tab */} {activeTab === 'config' && ( -
- {configs.map(config => { - const info = getMiddlewareDescription(config.middleware_name) - return ( -
-
-
-

- {info.icon} - {config.middleware_name.replace('_', ' ')} -

-

{info.desc}

-
-
- - {config.enabled ? 'Aktiviert' : 'Deaktiviert'} - - -
-
- {Object.keys(config.config).length > 0 && ( -
-
- Konfiguration -
-
-                              {JSON.stringify(config.config, null, 2)}
-                            
-
- )} -
- ) - })} -
+ )} - - {/* IP List Tab */} {activeTab === 'ip-list' && ( -
- {/* Add IP Form */} -
-

IP hinzufuegen

-
- setNewIP(e.target.value)} - placeholder="IP-Adresse (z.B. 192.168.1.1)" - className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500" - /> - - setNewIPReason(e.target.value)} - placeholder="Grund (optional)" - className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500" - /> - -
-
- - {/* IP List Table */} -
- - - - - - - - - - - - {ipList.length === 0 ? ( - - - - ) : ( - ipList.map(ip => ( - - - - - - - - )) - )} - -
- IP-Adresse - - Typ - - Grund - - Hinzugefuegt - - Aktion -
- Keine IP-Eintraege vorhanden. -
{ip.ip_address} - - {ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'} - - {ip.reason || '-'} - {new Date(ip.created_at).toLocaleString('de-DE')} - - -
-
-
- )} - - {/* Events Tab */} - {activeTab === 'events' && ( -
- - - - - - - - - - - - {events.length === 0 ? ( - - - - ) : ( - events.map(event => ( - - - - - - - - )) - )} - -
- Zeit - - Middleware - - Event - - IP - - Pfad -
- Keine Events vorhanden. -
- {new Date(event.created_at).toLocaleString('de-DE')} - - {event.middleware_name.replace('_', ' ')} - - - {event.event_type} - - - {event.ip_address || '-'} - - {event.request_method && event.request_path - ? `${event.request_method} ${event.request_path}` - : '-'} -
-
- )} - - {/* Stats Tab */} - {activeTab === 'stats' && ( -
- {stats.map(stat => { - const info = getMiddlewareDescription(stat.middleware_name) - return ( -
-

- {info.icon} - {stat.middleware_name.replace('_', ' ')} -

-
-
-
{stat.total_events}
-
Gesamt
-
-
-
{stat.events_last_hour}
-
Letzte Stunde
-
-
-
{stat.events_last_24h}
-
24 Stunden
-
-
- {stat.top_event_types.length > 0 && ( -
-
- Top Event-Typen -
-
- {stat.top_event_types.slice(0, 3).map(et => ( - - {et.event_type} ({et.count}) - - ))} -
-
- )} - {stat.top_ips.length > 0 && ( -
-
Top IPs
-
- {stat.top_ips - .slice(0, 3) - .map(ip => `${ip.ip_address} (${ip.count})`) - .join(', ')} -
-
- )} -
- ) - })} -
+ )} + {activeTab === 'events' && } + {activeTab === 'stats' && } )}
diff --git a/admin-core/app/(admin)/infrastructure/middleware/types.ts b/admin-core/app/(admin)/infrastructure/middleware/types.ts new file mode 100644 index 0000000..b19742c --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/middleware/types.ts @@ -0,0 +1,37 @@ +export interface MiddlewareConfig { + id: string + middleware_name: string + enabled: boolean + config: Record + updated_at: string | null +} + +export interface RateLimitIP { + id: string + ip_address: string + list_type: 'whitelist' | 'blacklist' + reason: string | null + expires_at: string | null + created_at: string +} + +export interface MiddlewareEvent { + id: string + middleware_name: string + event_type: string + ip_address: string | null + user_id: string | null + request_path: string | null + request_method: string | null + details: Record | null + created_at: string +} + +export interface MiddlewareStats { + middleware_name: string + total_events: number + events_last_hour: number + events_last_24h: number + top_event_types: Array<{ event_type: string; count: number }> + top_ips: Array<{ ip_address: string; count: number }> +} diff --git a/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data-libs.ts b/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data-libs.ts new file mode 100644 index 0000000..c4ed00f --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data-libs.ts @@ -0,0 +1,46 @@ +import type { Component } from '../types' + +export const GO_MODULES: Component[] = [ + { type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' }, + { type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' }, + { type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' }, + { type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' }, + { type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' }, + { type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' }, + { type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' }, +] + +export const NODE_PACKAGES: Component[] = [ + { type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' }, + { type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' }, + { type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' }, + { type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' }, + { type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' }, + { type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' }, + { type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' }, + { type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' }, + { type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' }, + { type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' }, + { type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' }, + { type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' }, + { type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' }, + { type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' }, + { type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' }, + { type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' }, +] + +export const UNITY_PACKAGES: Component[] = [ + { type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' }, + { type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' }, + { type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' }, + { type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' }, + { type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' }, + { type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' }, + { type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' }, +] + +export const CSHARP_PACKAGES: Component[] = [ + { type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' }, + { type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' }, + { type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' }, +] diff --git a/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data-python.ts b/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data-python.ts new file mode 100644 index 0000000..a6d9159 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data-python.ts @@ -0,0 +1,52 @@ +import type { Component } from '../types' + +export const PYTHON_PACKAGES: Component[] = [ + { type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' }, + { type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' }, + { type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' }, + { type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' }, + { type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' }, + { type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' }, + { type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' }, + { type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' }, + { type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' }, + { type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' }, + { type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' }, + { type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' }, + { type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' }, + { type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' }, + { type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' }, + { type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' }, + { type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' }, + { type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' }, + { type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' }, + { type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' }, + { type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' }, + { type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' }, + { type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' }, + { type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' }, + { type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' }, + { type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' }, + { type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' }, + { type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' }, + { type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' }, + { type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' }, + { type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing, Compliance Scraper)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' }, + { type: 'library', name: 'lxml', version: '5.x', category: 'python', description: 'XML/HTML Parser (EUR-Lex Scraping)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/lxml/lxml' }, + { type: 'library', name: 'PyMuPDF', version: '1.24+', category: 'python', description: 'PDF Parser (BSI-TR Extraction)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/pymupdf/PyMuPDF' }, + { type: 'library', name: 'pdfplumber', version: '0.11+', category: 'python', description: 'PDF Table Extraction (Compliance Docs)', license: 'MIT', sourceUrl: 'https://github.com/jsvine/pdfplumber' }, + { type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' }, + { type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' }, + { type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' }, + { type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' }, + { type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' }, + { type: 'library', name: 'pyspellchecker', version: '0.8.1+', category: 'python', description: 'Regel-basierte OCR-Korrektur (klausur-service Schritt 6)', license: 'MIT', sourceUrl: 'https://github.com/barrust/pyspellchecker' }, + { type: 'library', name: 'pytesseract', version: '0.3.10+', category: 'python', description: 'Tesseract OCR Engine Wrapper (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/madmaze/pytesseract' }, + { type: 'library', name: 'opencv-python-headless', version: '4.8+', category: 'python', description: 'Bildverarbeitung, Projektionsprofile, Inpainting (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opencv/opencv-python' }, + { type: 'library', name: 'rapidocr-onnxruntime', version: 'latest', category: 'python', description: 'Schnelles OCR ARM64 via ONNX (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/RapidAI/RapidOCR' }, + { type: 'library', name: 'onnxruntime', version: 'latest', category: 'python', description: 'ONNX-Inferenz fuer RapidOCR (klausur-service)', license: 'MIT', sourceUrl: 'https://github.com/microsoft/onnxruntime' }, + { type: 'library', name: 'eng-to-ipa', version: 'latest', category: 'python', description: 'IPA-Lautschrift-Lookup (klausur-service Vokabel-Pipeline)', license: 'MIT', sourceUrl: 'https://github.com/mphilli/English-to-IPA' }, + { type: 'library', name: 'sentence-transformers', version: '2.2+', category: 'python', description: 'Lokale Embeddings (klausur-service, rag-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/UKPLab/sentence-transformers' }, + { type: 'library', name: 'torch', version: '2.0+', category: 'python', description: 'ML-Framework CPU/MPS (TrOCR, klausur-service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pytorch/pytorch' }, + { type: 'library', name: 'transformers', version: '4.x', category: 'python', description: 'HuggingFace Transformers (TrOCR, Handschrift-HTR)', license: 'Apache-2.0', sourceUrl: 'https://github.com/huggingface/transformers' }, +] diff --git a/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data.ts b/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data.ts new file mode 100644 index 0000000..3227769 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/sbom/_components/sbom-data.ts @@ -0,0 +1,76 @@ +/** + * Static SBOM component data + * Extracted from page.tsx to keep file sizes manageable + */ + +import type { Component } from '../types' + +// Infrastructure components from docker-compose.yml and project analysis +export const INFRASTRUCTURE_COMPONENTS: Component[] = [ + { type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' }, + { type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' }, + { type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' }, + { type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' }, + { type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' }, + { type: 'service', name: 'ERPNext Valkey Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' }, + { type: 'service', name: 'ERPNext Valkey Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' }, + { type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' }, + { type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' }, + { type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' }, + { type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' }, + { type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' }, + { type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' }, + { type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' }, + { type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' }, + { type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' }, + { type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' }, + { type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' }, + { type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' }, + { type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' }, + { type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' }, + { type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API, Studio & Alerts Agent', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Compliance Module', version: '2.0', category: 'application', port: '8000', description: 'GRC Framework (19 Regulations, 558 Requirements, AI)', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' }, + { type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' }, + { type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' }, + { type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' }, + { type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service with Actions CI/CD', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' }, + { type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' }, + { type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' }, + { type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' }, + { type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' }, +] + +export const SECURITY_TOOLS: Component[] = [ + { type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' }, + { type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' }, + { type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' }, + { type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' }, + { type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' }, + { type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' }, + { type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' }, + { type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' }, + { type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' }, + { type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' }, + { type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' }, + { type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' }, + { type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' }, + { type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' }, +] + +export { PYTHON_PACKAGES } from './sbom-data-python' +export { GO_MODULES, NODE_PACKAGES, UNITY_PACKAGES, CSHARP_PACKAGES } from './sbom-data-libs' diff --git a/admin-core/app/(admin)/infrastructure/sbom/page.tsx b/admin-core/app/(admin)/infrastructure/sbom/page.tsx index a35dc10..e43f188 100644 --- a/admin-core/app/(admin)/infrastructure/sbom/page.tsx +++ b/admin-core/app/(admin)/infrastructure/sbom/page.tsx @@ -3,249 +3,51 @@ /** * SBOM (Software Bill of Materials) Admin Page * - * Migriert von /admin/sbom (website) nach /infrastructure/sbom (admin-v2) - * - * Displays: - * - All infrastructure components (Docker services) - * - Python/Go dependencies - * - Node.js packages - * - License information - * - Version tracking + * Displays all infrastructure components, dependencies, and license information. */ import { useState, useEffect } from 'react' import Link from 'next/link' import { PagePurpose } from '@/components/common/PagePurpose' import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar' +import type { Component, SBOMData, CategoryType, InfoTabType } from './types' +import { + INFRASTRUCTURE_COMPONENTS, SECURITY_TOOLS, + PYTHON_PACKAGES, GO_MODULES, NODE_PACKAGES, UNITY_PACKAGES, CSHARP_PACKAGES, +} from './_components/sbom-data' -interface Component { - type: string - name: string - version: string - purl?: string - licenses?: { license: { id: string } }[] - category?: string - port?: string - description?: string - license?: string - sourceUrl?: string -} +// ============================================================================= +// Helpers +// ============================================================================= -interface SBOMData { - bomFormat?: string - specVersion?: string - version?: number - metadata?: { - timestamp?: string - tools?: { vendor: string; name: string; version: string }[] - component?: { type: string; name: string; version: string } +const getCategoryColor = (category?: string) => { + const map: Record = { + database: 'bg-blue-100 text-blue-800', security: 'bg-purple-100 text-purple-800', + 'security-tool': 'bg-red-100 text-red-800', application: 'bg-green-100 text-green-800', + communication: 'bg-yellow-100 text-yellow-800', storage: 'bg-orange-100 text-orange-800', + search: 'bg-pink-100 text-pink-800', erp: 'bg-indigo-100 text-indigo-800', + cache: 'bg-cyan-100 text-cyan-800', ai: 'bg-violet-100 text-violet-800', + development: 'bg-gray-100 text-gray-800', cicd: 'bg-orange-100 text-orange-800', + python: 'bg-emerald-100 text-emerald-800', go: 'bg-sky-100 text-sky-800', + nodejs: 'bg-lime-100 text-lime-800', unity: 'bg-amber-100 text-amber-800', + csharp: 'bg-fuchsia-100 text-fuchsia-800', game: 'bg-rose-100 text-rose-800', + voice: 'bg-teal-100 text-teal-800', qa: 'bg-blue-100 text-blue-800', } - components?: Component[] + return map[category || ''] || 'bg-slate-100 text-slate-800' } -type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp' -type InfoTabType = 'audit' | 'documentation' +const getLicenseColor = (license?: string) => { + if (!license) return 'bg-gray-100 text-gray-600' + if (license.includes('MIT')) return 'bg-green-100 text-green-700' + if (license.includes('Apache')) return 'bg-blue-100 text-blue-700' + if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700' + if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700' + return 'bg-gray-100 text-gray-600' +} -// Infrastructure components from docker-compose.yml and project analysis -const INFRASTRUCTURE_COMPONENTS: Component[] = [ - // ===== DATABASES ===== - { type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' }, - { type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' }, - { type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' }, - { type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' }, - - // ===== CACHE & QUEUE ===== - { type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' }, - { type: 'service', name: 'ERPNext Valkey Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' }, - { type: 'service', name: 'ERPNext Valkey Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' }, - - // ===== SEARCH ENGINES ===== - { type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' }, - { type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' }, - { type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' }, - - // ===== OBJECT STORAGE ===== - { type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' }, - { type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' }, - { type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' }, - - // ===== SECURITY ===== - { type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' }, - { type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' }, - { type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' }, - - // ===== COMMUNICATION ===== - { type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' }, - { type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' }, - { type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' }, - { type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' }, - { type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' }, - { type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' }, - - // ===== APPLICATION SERVICES (Python) ===== - { type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API, Studio & Alerts Agent', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'Compliance Module', version: '2.0', category: 'application', port: '8000', description: 'GRC Framework (19 Regulations, 558 Requirements, AI)', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' }, - - // ===== APPLICATION SERVICES (Go) ===== - { type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' }, - - // ===== APPLICATION SERVICES (Node.js) ===== - { type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' }, - { type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' }, - - // ===== APPLICATION SERVICES (Vue) ===== - { type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' }, - - // ===== AI/LLM SERVICES ===== - { type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' }, - { type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' }, - - // ===== ERP ===== - { type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' }, - - // ===== CI/CD & VERSION CONTROL ===== - { type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service with Actions CI/CD', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' }, - { type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' }, - - // ===== DEVELOPMENT ===== - { type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' }, - - // ===== GAME (Breakpilot Drive) ===== - { type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' }, - - - // ===== BQAS (Quality Assurance) ===== - { type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' }, - { type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' }, -] - -// Security Tools discovered in project -const SECURITY_TOOLS: Component[] = [ - { type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' }, - { type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' }, - { type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' }, - { type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' }, - { type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' }, - { type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' }, - { type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' }, - { type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' }, - { type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' }, - { type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' }, - { type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' }, - { type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' }, - { type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' }, - { type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' }, -] - -// Key Python packages (from requirements.txt) -const PYTHON_PACKAGES: Component[] = [ - { type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' }, - { type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' }, - { type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' }, - { type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' }, - { type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' }, - { type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' }, - { type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' }, - { type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' }, - { type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' }, - { type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' }, - { type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' }, - { type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' }, - { type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' }, - { type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' }, - { type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' }, - { type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' }, - { type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' }, - { type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' }, - { type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' }, - { type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' }, - { type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' }, - { type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' }, - { type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' }, - { type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' }, - { type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' }, - { type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' }, - { type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' }, - { type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' }, - { type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' }, - { type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' }, - { type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing, Compliance Scraper)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' }, - { type: 'library', name: 'lxml', version: '5.x', category: 'python', description: 'XML/HTML Parser (EUR-Lex Scraping)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/lxml/lxml' }, - { type: 'library', name: 'PyMuPDF', version: '1.24+', category: 'python', description: 'PDF Parser (BSI-TR Extraction)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/pymupdf/PyMuPDF' }, - { type: 'library', name: 'pdfplumber', version: '0.11+', category: 'python', description: 'PDF Table Extraction (Compliance Docs)', license: 'MIT', sourceUrl: 'https://github.com/jsvine/pdfplumber' }, - { type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' }, - { type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' }, - { type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' }, - { type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' }, - { type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' }, - { type: 'library', name: 'pyspellchecker', version: '0.8.1+', category: 'python', description: 'Regel-basierte OCR-Korrektur (klausur-service Schritt 6)', license: 'MIT', sourceUrl: 'https://github.com/barrust/pyspellchecker' }, - { type: 'library', name: 'pytesseract', version: '0.3.10+', category: 'python', description: 'Tesseract OCR Engine Wrapper (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/madmaze/pytesseract' }, - { type: 'library', name: 'opencv-python-headless', version: '4.8+', category: 'python', description: 'Bildverarbeitung, Projektionsprofile, Inpainting (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opencv/opencv-python' }, - { type: 'library', name: 'rapidocr-onnxruntime', version: 'latest', category: 'python', description: 'Schnelles OCR ARM64 via ONNX (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/RapidAI/RapidOCR' }, - { type: 'library', name: 'onnxruntime', version: 'latest', category: 'python', description: 'ONNX-Inferenz für RapidOCR (klausur-service)', license: 'MIT', sourceUrl: 'https://github.com/microsoft/onnxruntime' }, - { type: 'library', name: 'eng-to-ipa', version: 'latest', category: 'python', description: 'IPA-Lautschrift-Lookup (klausur-service Vokabel-Pipeline)', license: 'MIT', sourceUrl: 'https://github.com/mphilli/English-to-IPA' }, - { type: 'library', name: 'sentence-transformers', version: '2.2+', category: 'python', description: 'Lokale Embeddings (klausur-service, rag-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/UKPLab/sentence-transformers' }, - { type: 'library', name: 'torch', version: '2.0+', category: 'python', description: 'ML-Framework CPU/MPS (TrOCR, klausur-service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pytorch/pytorch' }, - { type: 'library', name: 'transformers', version: '4.x', category: 'python', description: 'HuggingFace Transformers (TrOCR, Handschrift-HTR)', license: 'Apache-2.0', sourceUrl: 'https://github.com/huggingface/transformers' }, -] - -// Key Go modules (from go.mod files) -const GO_MODULES: Component[] = [ - { type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' }, - { type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' }, - { type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' }, - { type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' }, - { type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' }, - { type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' }, - { type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' }, -] - -// Key Node.js packages (from package.json files) -const NODE_PACKAGES: Component[] = [ - { type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' }, - { type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' }, - { type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' }, - { type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' }, - { type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' }, - { type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' }, - { type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' }, - { type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' }, - { type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' }, - { type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' }, - { type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' }, - { type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' }, - { type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' }, - { type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' }, - { type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' }, - { type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' }, -] - -// Unity packages (Breakpilot Drive game engine) -const UNITY_PACKAGES: Component[] = [ - { type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' }, - { type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' }, - { type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' }, - { type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' }, - { type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' }, - { type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' }, - { type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' }, -] - -// C# dependencies (Breakpilot Drive) -const CSHARP_PACKAGES: Component[] = [ - { type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' }, - { type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' }, - { type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' }, -] +// ============================================================================= +// Component +// ============================================================================= export default function SBOMPage() { const [sbomData, setSbomData] = useState(null) @@ -256,136 +58,45 @@ export default function SBOMPage() { const [showFullDocs, setShowFullDocs] = useState(false) useEffect(() => { - loadSBOM() + (async () => { + setLoading(true) + try { + const res = await fetch('/api/v1/security/sbom') + if (res.ok) setSbomData(await res.json()) + } catch (error) { + console.error('Failed to load SBOM:', error) + } finally { + setLoading(false) + } + })() }, []) - const loadSBOM = async () => { - setLoading(true) - try { - const res = await fetch('/api/v1/security/sbom') - if (res.ok) { - const data = await res.json() - setSbomData(data) - } - } catch (error) { - console.error('Failed to load SBOM:', error) - } finally { - setLoading(false) - } - } - - const getAllComponents = (): Component[] => { - const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({ - ...c, - category: c.category || 'infrastructure' - })) - - const securityToolsComponents = SECURITY_TOOLS.map(c => ({ - ...c, - category: c.category || 'security-tool' - })) - - const pythonComponents = PYTHON_PACKAGES.map(c => ({ - ...c, - category: 'python' - })) - - const goComponents = GO_MODULES.map(c => ({ - ...c, - category: 'go' - })) - - const nodeComponents = NODE_PACKAGES.map(c => ({ - ...c, - category: 'nodejs' - })) - - const unityComponents = UNITY_PACKAGES.map(c => ({ - ...c, - category: 'unity' - })) - - const csharpComponents = CSHARP_PACKAGES.map(c => ({ - ...c, - category: 'csharp' - })) - - // Add dynamic SBOM data from backend if available - const dynamicPython = (sbomData?.components || []).map(c => ({ - ...c, - category: 'python' - })) - - return [...infraComponents, ...securityToolsComponents, ...pythonComponents, ...goComponents, ...nodeComponents, ...unityComponents, ...csharpComponents, ...dynamicPython] - } - - const getFilteredComponents = () => { - let components = getAllComponents() - - if (activeCategory !== 'all') { - if (activeCategory === 'infrastructure') { - components = INFRASTRUCTURE_COMPONENTS - } else if (activeCategory === 'security-tools') { - components = SECURITY_TOOLS - } else if (activeCategory === 'python') { - components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])] - } else if (activeCategory === 'go') { - components = GO_MODULES - } else if (activeCategory === 'nodejs') { - components = NODE_PACKAGES - } else if (activeCategory === 'unity') { - components = UNITY_PACKAGES - } else if (activeCategory === 'csharp') { - components = CSHARP_PACKAGES - } - } + const getFilteredComponents = (): Component[] => { + let components: Component[] + if (activeCategory === 'all') { + components = [ + ...INFRASTRUCTURE_COMPONENTS, ...SECURITY_TOOLS, ...PYTHON_PACKAGES, + ...GO_MODULES, ...NODE_PACKAGES, ...UNITY_PACKAGES, ...CSHARP_PACKAGES, + ...(sbomData?.components || []).map(c => ({ ...c, category: 'python' })), + ] + } else if (activeCategory === 'infrastructure') components = INFRASTRUCTURE_COMPONENTS + else if (activeCategory === 'security-tools') components = SECURITY_TOOLS + else if (activeCategory === 'python') components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])] + else if (activeCategory === 'go') components = GO_MODULES + else if (activeCategory === 'nodejs') components = NODE_PACKAGES + else if (activeCategory === 'unity') components = UNITY_PACKAGES + else if (activeCategory === 'csharp') components = CSHARP_PACKAGES + else components = [] if (searchTerm) { + const term = searchTerm.toLowerCase() components = components.filter(c => - c.name.toLowerCase().includes(searchTerm.toLowerCase()) || - c.version.toLowerCase().includes(searchTerm.toLowerCase()) || - (c.description?.toLowerCase().includes(searchTerm.toLowerCase())) + c.name.toLowerCase().includes(term) || c.version.toLowerCase().includes(term) || (c.description?.toLowerCase().includes(term)) ) } - return components } - const getCategoryColor = (category?: string) => { - switch (category) { - case 'database': return 'bg-blue-100 text-blue-800' - case 'security': return 'bg-purple-100 text-purple-800' - case 'security-tool': return 'bg-red-100 text-red-800' - case 'application': return 'bg-green-100 text-green-800' - case 'communication': return 'bg-yellow-100 text-yellow-800' - case 'storage': return 'bg-orange-100 text-orange-800' - case 'search': return 'bg-pink-100 text-pink-800' - case 'erp': return 'bg-indigo-100 text-indigo-800' - case 'cache': return 'bg-cyan-100 text-cyan-800' - case 'ai': return 'bg-violet-100 text-violet-800' - case 'development': return 'bg-gray-100 text-gray-800' - case 'cicd': return 'bg-orange-100 text-orange-800' - case 'python': return 'bg-emerald-100 text-emerald-800' - case 'go': return 'bg-sky-100 text-sky-800' - case 'nodejs': return 'bg-lime-100 text-lime-800' - case 'unity': return 'bg-amber-100 text-amber-800' - case 'csharp': return 'bg-fuchsia-100 text-fuchsia-800' - case 'game': return 'bg-rose-100 text-rose-800' - case 'voice': return 'bg-teal-100 text-teal-800' - case 'qa': return 'bg-blue-100 text-blue-800' - default: return 'bg-slate-100 text-slate-800' - } - } - - const getLicenseColor = (license?: string) => { - if (!license) return 'bg-gray-100 text-gray-600' - if (license.includes('MIT')) return 'bg-green-100 text-green-700' - if (license.includes('Apache')) return 'bg-blue-100 text-blue-700' - if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700' - if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700' - return 'bg-gray-100 text-gray-600' - } - const stats = { totalInfra: INFRASTRUCTURE_COMPONENTS.length, totalSecurityTools: SECURITY_TOOLS.length, @@ -396,8 +107,6 @@ export default function SBOMPage() { totalCsharp: CSHARP_PACKAGES.length, totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + UNITY_PACKAGES.length + CSHARP_PACKAGES.length + (sbomData?.components?.length || 0), databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length, - services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length, - communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length, game: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'game').length, } @@ -416,112 +125,55 @@ export default function SBOMPage() { return (
- + - {/* DevOps Pipeline Sidebar */} - {/* Wizard Link */}
- - - - + + Lern-Wizard
{/* Stats Cards */}
-
-
{stats.totalAll}
-
Komponenten Total
-
-
-
{stats.totalInfra}
-
Docker Services
-
-
-
{stats.totalSecurityTools}
-
Security Tools
-
-
-
{stats.totalPython}
-
Python
-
-
-
{stats.totalGo}
-
Go
-
-
-
{stats.totalNode}
-
Node.js
-
-
-
{stats.totalUnity}
-
Unity
-
-
-
{stats.totalCsharp}
-
C#
-
-
-
{stats.databases}
-
Datenbanken
-
-
-
{stats.game}
-
Game
-
+ {[ + { value: stats.totalAll, label: 'Komponenten Total', color: 'text-slate-800' }, + { value: stats.totalInfra, label: 'Docker Services', color: 'text-purple-600' }, + { value: stats.totalSecurityTools, label: 'Security Tools', color: 'text-red-600' }, + { value: stats.totalPython, label: 'Python', color: 'text-emerald-600' }, + { value: stats.totalGo, label: 'Go', color: 'text-sky-600' }, + { value: stats.totalNode, label: 'Node.js', color: 'text-lime-600' }, + { value: stats.totalUnity, label: 'Unity', color: 'text-amber-600' }, + { value: stats.totalCsharp, label: 'C#', color: 'text-fuchsia-600' }, + { value: stats.databases, label: 'Datenbanken', color: 'text-blue-600' }, + { value: stats.game, label: 'Game', color: 'text-rose-600' }, + ].map((s) => ( +
+
{s.value}
+
{s.label}
+
+ ))}
{/* Filters */}
- {/* Category Tabs */}
{categories.map((cat) => ( - ))}
- - {/* Search */}
- setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent" - /> + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent" /> @@ -533,22 +185,9 @@ export default function SBOMPage() { {sbomData?.metadata && (
-
- Format: - {sbomData.bomFormat} {sbomData.specVersion} -
-
- Generiert: - - {sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'} - -
-
- Anwendung: - - {sbomData.metadata.component?.name} v{sbomData.metadata.component?.version} - -
+
Format:{sbomData.bomFormat} {sbomData.specVersion}
+
Generiert:{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
+
Anwendung:{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
)} @@ -575,96 +214,38 @@ export default function SBOMPage() { {filteredComponents.map((component, idx) => { - // Get license from either the new license field or the old licenses array const licenseId = component.license || component.licenses?.[0]?.license?.id - return ( - -
{component.name}
- - - {component.version} - - - - {component.category || component.type} - - - - {component.description ? ( -
{component.description}
- ) : ( - Keine Beschreibung - )} - - - {component.port ? ( - {component.port} - ) : ( - - - )} - - - {licenseId ? ( - - {licenseId} - - ) : ( - - - )} - - - {component.sourceUrl && component.sourceUrl !== '-' ? ( - - - - - GitHub - - ) : ( - - - )} - +
{component.name}
+ {component.version} + {component.category || component.type} + {component.description ?
{component.description}
: Keine Beschreibung} + {component.port ? {component.port} : -} + {licenseId ? {licenseId} : -} + {component.sourceUrl && component.sourceUrl !== '-' ? ( + + GitHub + + ) : -} ) })} - - {filteredComponents.length === 0 && ( -
- Keine Komponenten gefunden. -
- )} + {filteredComponents.length === 0 &&
Keine Komponenten gefunden.
}
)} {/* Export Button */}
-
@@ -672,237 +253,85 @@ export default function SBOMPage() { {/* Info Tabs Section */}
- {/* Tab Headers */}
- - {/* Tab Content */}
- {/* Audit Tab */} {activeInfoTab === 'audit' && (
- {/* SBOM Status */}
-

- - - - SBOM Status -

+

SBOM Status

-
- Letzte Generierung - - - CI/CD - -
-
- Format - - - CycloneDX 1.5 - -
-
- Komponenten - - - Alle erfasst - -
-
- Transitive Deps - - - Inkludiert - -
+ {['Letzte Generierung', 'Format', 'Komponenten', 'Transitive Deps'].map((label) => ( +
+ {label} + {label === 'Letzte Generierung' ? 'CI/CD' : label === 'Format' ? 'CycloneDX 1.5' : label === 'Komponenten' ? 'Alle erfasst' : 'Inkludiert'} +
+ ))}
- - {/* License Compliance */}
-

- - - - License Compliance -

+

License Compliance

-
- Erlaubte Lizenzen - - - MIT, Apache, BSD - -
-
- Copyleft (GPL) - - - 0 - -
-
- Unbekannte Lizenzen - - - 0 - -
-
- Kommerzielle - - - Review erforderlich - -
+ {[{ label: 'Erlaubte Lizenzen', value: 'MIT, Apache, BSD', ok: true }, { label: 'Copyleft (GPL)', value: '0', ok: true }, { label: 'Unbekannte Lizenzen', value: '0', ok: true }, { label: 'Kommerzielle', value: 'Review erforderlich', ok: false }].map((item) => ( +
+ {item.label} + {item.value} +
+ ))}
)} - - {/* Documentation Tab */} {activeInfoTab === 'documentation' && (

SBOM Dokumentation

-
- - {!showFullDocs ? ( -
-

- Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten. - Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz. -

-
-
-

Generator

-

Syft (Primary), Trivy (Validation)

-
-
-

Format

-

CycloneDX 1.5, SPDX

-
-
-

Retention

-

5 Jahre (Compliance)

-
-
+
+

Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.

+
+

Generator

Syft (Primary), Trivy (Validation)

+

Format

CycloneDX 1.5, SPDX

+

Retention

5 Jahre (Compliance)

- ) : ( -
+
+ {showFullDocs && ( +

Software Bill of Materials (SBOM)

-

1. Uebersicht

-

Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten. Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.

- -

2. SBOM-Generierung

-
-{`Source Code
-     │
-     v
-┌───────────────────────────────────────────────────────────────┐
-│                     SBOM Generators                           │
-│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐   │
-│  │    Syft     │  │   Trivy     │  │  Native Tooling     │   │
-│  │  (Primary)  │  │ (Validation)│  │ (npm, go mod, pip)  │   │
-│  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘   │
-└─────────┼────────────────┼────────────────────┼───────────────┘
-          │                │                    │
-          └────────────────┴────────────────────┘
-                           │
-                           v
-                  ┌────────────────┐
-                  │   CycloneDX    │
-                  │    Format      │
-                  └────────────────┘`}
-                    
- -

3. Erfasste Komponenten

- - - - - - - - +

Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.

+

2. Erfasste Komponenten

+
TypQuelleBeispiele
-
TypQuelleBeispiele
npm packagespackage-lock.jsonreact, next, tailwindcss
Go modulesgo.sumgin, gorm, jwt-go
Python packagesrequirements.txtfastapi, pydantic, httpx
Container ImagesDockerfilenode:20-alpine, postgres:16
OS Packagesapk, aptopenssl, libpq
- -

4. License Compliance

- - - - - - - - +

3. License Compliance

+
KategorieLizenzenStatus
- + -
KategorieLizenzenStatus
Permissive (erlaubt)MIT, Apache 2.0, BSD, ISCOK
PermissiveMIT, Apache 2.0, BSD, ISCOK
Weak CopyleftLGPL, MPLReview
Strong CopyleftGPL, AGPLNicht erlaubt
ProprietaerCommercialGenehmigung
- -

5. Aufbewahrung & Compliance

-
    -
  • Retention: 5 Jahre (Compliance)
  • -
  • Format: JSON + PDF Report
  • -
  • Signierung: Digital signiert
  • -
  • Audit: Jederzeit abrufbar
  • -
)}
diff --git a/admin-core/app/(admin)/infrastructure/sbom/types.ts b/admin-core/app/(admin)/infrastructure/sbom/types.ts new file mode 100644 index 0000000..8afc1fa --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/sbom/types.ts @@ -0,0 +1,31 @@ +/** + * Types for SBOM page + */ + +export interface Component { + type: string + name: string + version: string + purl?: string + licenses?: { license: { id: string } }[] + category?: string + port?: string + description?: string + license?: string + sourceUrl?: string +} + +export interface SBOMData { + bomFormat?: string + specVersion?: string + version?: number + metadata?: { + timestamp?: string + tools?: { vendor: string; name: string; version: string }[] + component?: { type: string; name: string; version: string } + } + components?: Component[] +} + +export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp' +export type InfoTabType = 'audit' | 'documentation' diff --git a/admin-core/app/(admin)/infrastructure/security/_components/FindingsTab.tsx b/admin-core/app/(admin)/infrastructure/security/_components/FindingsTab.tsx new file mode 100644 index 0000000..a2bd872 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/_components/FindingsTab.tsx @@ -0,0 +1,102 @@ +'use client' + +import type { Finding } from '../types' + +export function FindingsTab({ + findings, + severityFilter, + setSeverityFilter, + toolFilter, + setToolFilter, + getSeverityBadge, +}: { + findings: Finding[] + severityFilter: string | null + setSeverityFilter: (v: string | null) => void + toolFilter: string | null + setToolFilter: (v: string | null) => void + getSeverityBadge: (severity: string) => string +}) { + const filteredFindings = findings.filter(f => { + if (severityFilter && f.severity.toUpperCase() !== severityFilter.toUpperCase()) return false + if (toolFilter && f.tool.toLowerCase() !== toolFilter.toLowerCase()) return false + return true + }) + + return ( +
+ {/* Filters */} +
+ + {['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].map(sev => ( + + ))} + + {['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype'].map(t => ( + + ))} +
+ + {filteredFindings.length === 0 ? ( +
+ Keine Findings mit diesem Filter gefunden. +
+ ) : ( +
+ + + + + + + + + + + + + {filteredFindings.map((finding, idx) => ( + + + + + + + + + ))} + +
SeverityToolFindingDateiZeileGefunden
+ {finding.severity} + {finding.tool} +
{finding.title}
+ {finding.message && ( +
{finding.message}
+ )} +
+ {finding.file || '-'} + {finding.line || '-'} + {finding.found_at ? new Date(finding.found_at).toLocaleDateString('de-DE') : '-'} +
+
+ )} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/security/_components/HistoryTab.tsx b/admin-core/app/(admin)/infrastructure/security/_components/HistoryTab.tsx new file mode 100644 index 0000000..3677c22 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/_components/HistoryTab.tsx @@ -0,0 +1,44 @@ +'use client' + +import type { HistoryItem } from '../types' + +export function HistoryTab({ history }: { history: HistoryItem[] }) { + const getHistoryStatusColor = (status: string) => { + switch (status) { + case 'success': return 'bg-green-500' + case 'warning': return 'bg-yellow-500' + case 'error': return 'bg-red-500' + default: return 'bg-slate-400' + } + } + + if (history.length === 0) { + return ( +
+ Keine Scan-Historie vorhanden. +
+ ) + } + + return ( +
+
+
+ {history.map((item, idx) => ( +
+
+
+
+ {item.title} + + {new Date(item.timestamp).toLocaleString('de-DE')} + +
+

{item.description}

+
+
+ ))} +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/security/_components/MonitoringTab.tsx b/admin-core/app/(admin)/infrastructure/security/_components/MonitoringTab.tsx new file mode 100644 index 0000000..1f4b6da --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/_components/MonitoringTab.tsx @@ -0,0 +1,172 @@ +'use client' + +import type { MonitoringMetric, ActiveAlert } from '../types' + +export function MonitoringTab({ + monitoringMetrics, + activeAlerts, +}: { + monitoringMetrics: MonitoringMetric[] + activeAlerts: ActiveAlert[] +}) { + return ( +
+ {/* Real-time Metrics */} +
+

+ + + + Security Metriken +

+
+ {monitoringMetrics.map((metric, idx) => ( +
+
+ {metric.name} + + {metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'} + +
+
+ {metric.value} + {metric.unit} +
+
+ ))} +
+
+ + {/* Active Alerts */} +
+

+ + + + Aktive Alerts + {activeAlerts.filter(a => !a.acknowledged).length > 0 && ( + + {activeAlerts.filter(a => !a.acknowledged).length} + + )} +

+ {activeAlerts.length === 0 ? ( +
+ + Keine aktiven Security-Alerts +
+ ) : ( +
+ {activeAlerts.map((alert) => ( +
+
+ + {alert.severity} + +
+
{alert.title}
+
+ {alert.source} • {new Date(alert.timestamp).toLocaleString('de-DE')} +
+
+
+ {!alert.acknowledged && ( + + )} +
+ ))} +
+ )} +
+ + {/* Security Overview Cards */} +
+
+

+ + + + Authentifizierung +

+
+
Aktive Sessions24
+
Fehlgeschlagene Logins (24h)0
+
2FA-Quote100%
+
+
+
+

+ + + + SSL/TLS +

+
+
Zertifikate5 aktiv
+
Naechster Ablauf45 Tage
+
TLS Version1.3
+
+
+
+

+ + + + Firewall +

+
+
Blockierte IPs (24h)12
+
Rate Limit Hits7
+
WAF StatusAktiv
+
+
+
+ + {/* Link to CI/CD */} +
+
+ + + +
+
Pipeline Security
+
Security-Scans in CI/CD Pipelines und Container-Status
+
+
+ + CI/CD Dashboard → + +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/security/_components/SecurityDocsSection.tsx b/admin-core/app/(admin)/infrastructure/security/_components/SecurityDocsSection.tsx new file mode 100644 index 0000000..a26f866 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/_components/SecurityDocsSection.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState } from 'react' + +export function SecurityDocsSection() { + const [showFullDocs, setShowFullDocs] = useState(false) + + return ( +
+
+
+

+ + + + Security Dokumentation +

+ +
+ + {/* Short Description */} +
+

+ Das Security Dashboard bietet einen zentralen Ueberblick ueber alle DevSecOps-Aktivitaeten. + Es integriert 6 Security-Tools fuer umfassende Code- und Infrastruktur-Sicherheit: + Secrets Detection, Static Analysis (SAST), Dependency Scanning und SBOM-Generierung. +

+
+ + {/* Tool Quick Reference */} +
+ {[ + { bg: 'bg-red-50', icon: '🔑', name: 'Gitleaks', label: 'Secrets', color: 'text-red-800', labelColor: 'text-red-600' }, + { bg: 'bg-blue-50', icon: '🔍', name: 'Semgrep', label: 'SAST', color: 'text-blue-800', labelColor: 'text-blue-600' }, + { bg: 'bg-yellow-50', icon: '🐍', name: 'Bandit', label: 'Python', color: 'text-yellow-800', labelColor: 'text-yellow-600' }, + { bg: 'bg-purple-50', icon: '🔒', name: 'Trivy', label: 'Container', color: 'text-purple-800', labelColor: 'text-purple-600' }, + { bg: 'bg-green-50', icon: '🐛', name: 'Grype', label: 'Dependencies', color: 'text-green-800', labelColor: 'text-green-600' }, + { bg: 'bg-orange-50', icon: '📦', name: 'Syft', label: 'SBOM', color: 'text-orange-800', labelColor: 'text-orange-600' }, + ].map((tool) => ( +
+ {tool.icon} +

{tool.name}

+

{tool.label}

+
+ ))} +
+ + {/* Full Documentation (Expandable) */} + {showFullDocs && ( +
+
+

1. Security Tools Uebersicht

+

🔑 Gitleaks - Secrets Detection

+

Durchsucht die gesamte Git-Historie nach versehentlich eingecheckten Secrets wie API-Keys, Passwoertern und Tokens.

+
    +
  • Scan-Bereich: Git-Historie, Commits, Branches
  • +
  • Erkannte Secrets: AWS Keys, GitHub Tokens, Private Keys, Passwoerter
  • +
  • Ausgabe: JSON-Report mit Fundstelle, Commit-Hash, Autor
  • +
+

🔍 Semgrep - Static Application Security Testing

+

Fuehrt regelbasierte statische Code-Analyse durch, um Sicherheitsluecken und Anti-Patterns zu finden.

+
    +
  • Unterstuetzte Sprachen: Python, JavaScript, TypeScript, Go, Java
  • +
  • Regelsets: OWASP Top 10, CWE, Security Best Practices
  • +
  • Findings: SQL Injection, XSS, Path Traversal, Insecure Deserialization
  • +
+

2. Severity-Klassifizierung

+ + + + + + + + +
SeverityCVSS ScoreReaktionszeit
CRITICAL9.0 - 10.0Sofort (24h)
HIGH7.0 - 8.91-3 Tage
MEDIUM4.0 - 6.91-2 Wochen
LOW0.1 - 3.9Naechster Sprint
+

3. Scan-Workflow

+
+{`1. Secrets Detection (Gitleaks)
+2. Static Analysis (Semgrep + Bandit)
+3. Dependency Scan (Trivy + Grype)
+4. SBOM Generation (Syft)
+5. Report & Dashboard`}
+              
+

4. API-Endpunkte

+ + + + + + + + +
MethodeEndpointBeschreibung
GET/api/v1/security/toolsTool-Status abrufen
GET/api/v1/security/findingsAlle Findings abrufen
POST/api/v1/security/scan/allFull Scan starten
POST/api/v1/security/scan/[tool]Einzelnes Tool scannen
+
+
+ )} +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/security/_components/SecurityOverviewTab.tsx b/admin-core/app/(admin)/infrastructure/security/_components/SecurityOverviewTab.tsx new file mode 100644 index 0000000..08bfc11 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/_components/SecurityOverviewTab.tsx @@ -0,0 +1,122 @@ +'use client' + +import type { ToolStatus, Finding, ScanType } from '../types' + +export function SecurityOverviewTab({ + tools, + findings, + scanning, + onRunScan, + onShowAllFindings, + toolDescriptions, + toolToScanType, + getSeverityBadge, + getStatusBadge, +}: { + tools: ToolStatus[] + findings: Finding[] + scanning: string | null + onRunScan: (scanType: ScanType) => void + onShowAllFindings: () => void + toolDescriptions: Record + toolToScanType: Record + getSeverityBadge: (severity: string) => string + getStatusBadge: (installed: boolean) => string +}) { + return ( +
+ {/* Tools Grid */} +
+

DevSecOps Tools

+
+ {tools.map(tool => { + const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' } + return ( +
+
+
+ {info.icon} + {tool.name} +
+ + {tool.installed ? 'Installiert' : 'Nicht installiert'} + +
+

{info.desc}

+
+ {tool.version || '-'} + Letzter Scan: {tool.last_run || 'Nie'} +
+ +
+ ) + })} +
+
+ + {/* Recent Findings */} +
+

Aktuelle Findings

+ {findings.length === 0 ? ( +
+ 🎉 + Keine Findings gefunden. Das ist gut! +
+ ) : ( +
+ + + + + + + + + + + + {findings.slice(0, 10).map((finding, idx) => ( + + + + + + + + ))} + +
SeverityToolFindingDateiGefunden
+ {finding.severity} + {finding.tool}{finding.title}{finding.file || '-'} + {finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' + }) : '-'} +
+ {findings.length > 10 && ( + + )} +
+ )} +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/security/_components/ToolsTab.tsx b/admin-core/app/(admin)/infrastructure/security/_components/ToolsTab.tsx new file mode 100644 index 0000000..fb2db4c --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/_components/ToolsTab.tsx @@ -0,0 +1,83 @@ +'use client' + +import type { ToolStatus, ScanType } from '../types' + +export function ToolsTab({ + tools, + scanning, + onRunScan, + toolDescriptions, + toolToScanType, + getStatusBadge, +}: { + tools: ToolStatus[] + scanning: string | null + onRunScan: (scanType: ScanType) => void + toolDescriptions: Record + toolToScanType: Record + getStatusBadge: (installed: boolean) => string +}) { + return ( +
+ {tools.map(tool => { + const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' } + return ( +
+
+
+
+ {info.icon} +

{tool.name}

+
+

{info.desc}

+
+ + {tool.installed ? 'Installiert' : 'Nicht installiert'} + +
+
+
+ Version: + {tool.version || '-'} +
+
+ Letzter Scan: + {tool.last_run || 'Nie'} +
+
+ Findings: + {tool.last_findings} +
+
+
+ + +
+
+ ) + })} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/security/page.tsx b/admin-core/app/(admin)/infrastructure/security/page.tsx index 26e38e2..6097ce2 100644 --- a/admin-core/app/(admin)/infrastructure/security/page.tsx +++ b/admin-core/app/(admin)/infrastructure/security/page.tsx @@ -4,65 +4,45 @@ * Security Dashboard - DevSecOps * * Security scan results, vulnerability tracking, and compliance status - * Migrated from old admin (/admin/security) */ import { useEffect, useState, useCallback } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar' +import type { ToolStatus, Finding, SeveritySummary, HistoryItem, ScanType, MonitoringMetric, ActiveAlert } from './types' -interface ToolStatus { - name: string - installed: boolean - version: string | null - last_run: string | null - last_findings: number +import { SecurityOverviewTab } from './_components/SecurityOverviewTab' +import { FindingsTab } from './_components/FindingsTab' +import { ToolsTab } from './_components/ToolsTab' +import { HistoryTab } from './_components/HistoryTab' +import { MonitoringTab } from './_components/MonitoringTab' +import { SecurityDocsSection } from './_components/SecurityDocsSection' + +const TOOL_DESCRIPTIONS: Record = { + gitleaks: { icon: '🔑', desc: 'Secrets Detection in Git History' }, + semgrep: { icon: '🔍', desc: 'Static Application Security Testing (SAST)' }, + bandit: { icon: '🐍', desc: 'Python Security Linter' }, + trivy: { icon: '🔒', desc: 'Container & Filesystem Vulnerability Scanner' }, + grype: { icon: '🐛', desc: 'Vulnerability Scanner for Dependencies' }, + syft: { icon: '📦', desc: 'SBOM Generator (CycloneDX/SPDX)' }, } -interface Finding { - id: string - tool: string - severity: string - title: string - message: string | null - file: string | null - line: number | null - found_at: string +const TOOL_TO_SCAN_TYPE: Record = { + gitleaks: 'secrets', + semgrep: 'sast', + bandit: 'sast', + trivy: 'deps', + grype: 'deps', + syft: 'sbom', } -interface SeveritySummary { - critical: number - high: number - medium: number - low: number - info: number - total: number -} - -interface HistoryItem { - timestamp: string - title: string - description: string - status: string -} - -type ScanType = 'secrets' | 'sast' | 'deps' | 'containers' | 'sbom' | 'all' - -interface MonitoringMetric { - name: string - value: number - unit: string - status: 'ok' | 'warning' | 'critical' - trend: 'up' | 'down' | 'stable' -} - -interface ActiveAlert { - id: string - severity: 'critical' | 'high' | 'medium' | 'low' - title: string - source: string - timestamp: string - acknowledged: boolean +const SCAN_TYPE_LABELS: Record = { + secrets: 'Secrets (Gitleaks)', + sast: 'SAST (Semgrep + Bandit)', + deps: 'Dependencies (Trivy + Grype)', + containers: 'Container', + sbom: 'SBOM (Syft)', + all: 'Full Scan', } export default function SecurityDashboardPage() { @@ -78,49 +58,28 @@ export default function SecurityDashboardPage() { const [activeAlerts, setActiveAlerts] = useState([]) const [severityFilter, setSeverityFilter] = useState(null) const [toolFilter, setToolFilter] = useState(null) - const [showFullDocs, setShowFullDocs] = useState(false) - const [isInitialLoad, setIsInitialLoad] = useState(true) const [scanMessage, setScanMessage] = useState(null) const [lastScanTime, setLastScanTime] = useState(null) const fetchData = useCallback(async (showLoadingSpinner = false) => { - // Only show loading spinner on initial load, not on auto-refresh - if (showLoadingSpinner) { - setLoading(true) - } + if (showLoadingSpinner) setLoading(true) setError(null) - try { const [toolsRes, findingsRes, summaryRes, historyRes] = await Promise.all([ - fetch('/api/v1/security/tools'), - fetch('/api/v1/security/findings'), - fetch('/api/v1/security/summary'), - fetch('/api/v1/security/history'), + fetch('/api/v1/security/tools'), fetch('/api/v1/security/findings'), + fetch('/api/v1/security/summary'), fetch('/api/v1/security/history'), ]) + if (toolsRes.ok) setTools(await toolsRes.json()) + if (findingsRes.ok) setFindings(await findingsRes.json()) + if (summaryRes.ok) setSummary(await summaryRes.json()) + if (historyRes.ok) setHistory(await historyRes.json()) - if (toolsRes.ok) { - setTools(await toolsRes.json()) - } - if (findingsRes.ok) { - setFindings(await findingsRes.json()) - } - if (summaryRes.ok) { - setSummary(await summaryRes.json()) - } - if (historyRes.ok) { - setHistory(await historyRes.json()) - } - - // Fetch monitoring data const [metricsRes, alertsRes] = await Promise.all([ - fetch('/api/v1/security/monitoring/metrics'), - fetch('/api/v1/security/monitoring/alerts'), + fetch('/api/v1/security/monitoring/metrics'), fetch('/api/v1/security/monitoring/alerts'), ]) - if (metricsRes.ok) { setMonitoringMetrics(await metricsRes.json()) } else { - // Default metrics if API not available setMonitoringMetrics([ { name: 'API Latency', value: 45, unit: 'ms', status: 'ok', trend: 'stable' }, { name: 'Auth Failures', value: 3, unit: '/h', status: 'ok', trend: 'down' }, @@ -130,80 +89,35 @@ export default function SecurityDashboardPage() { { name: 'Open Ports', value: 8, unit: '', status: 'ok', trend: 'stable' }, ]) } - if (alertsRes.ok) { - setActiveAlerts(await alertsRes.json()) - } else { - setActiveAlerts([]) - } + if (alertsRes.ok) setActiveAlerts(await alertsRes.json()) + else setActiveAlerts([]) } catch (err) { setError(err instanceof Error ? err.message : 'Verbindung zum Backend fehlgeschlagen') } finally { setLoading(false) - setIsInitialLoad(false) } }, []) - // Initial load only - runs once - useEffect(() => { - fetchData(true) // Show loading spinner on initial load - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Auto-refresh every 60 seconds (without loading spinner) + useEffect(() => { fetchData(true) }, []) useEffect(() => { const interval = setInterval(() => fetchData(false), 60000) return () => clearInterval(interval) - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const scanTypeLabels: Record = { - secrets: 'Secrets (Gitleaks)', - sast: 'SAST (Semgrep + Bandit)', - deps: 'Dependencies (Trivy + Grype)', - containers: 'Container', - sbom: 'SBOM (Syft)', - all: 'Full Scan', - } - const runScan = async (scanType: ScanType) => { - console.log(`Starting scan: ${scanType}`) setScanning(scanType) setError(null) - setScanMessage(`${scanTypeLabels[scanType]} wird gestartet...`) - + setScanMessage(`${SCAN_TYPE_LABELS[scanType]} wird gestartet...`) try { - const response = await fetch(`/api/v1/security/scan/${scanType}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }) - - console.log(`Scan response status: ${response.status}`) - - if (!response.ok) { - const errorText = await response.text() - console.error(`Scan error: ${errorText}`) - throw new Error(`Scan fehlgeschlagen: ${response.status} - ${errorText}`) - } - - const result = await response.json() - console.log('Scan result:', result) - - // Show success message - setScanMessage(`${scanTypeLabels[scanType]} laeuft im Hintergrund. Ergebnisse werden in wenigen Sekunden aktualisiert.`) + const response = await fetch(`/api/v1/security/scan/${scanType}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) + if (!response.ok) throw new Error(`Scan fehlgeschlagen: ${response.status}`) + setScanMessage(`${SCAN_TYPE_LABELS[scanType]} laeuft im Hintergrund. Ergebnisse werden in wenigen Sekunden aktualisiert.`) setLastScanTime(new Date().toLocaleTimeString('de-DE')) - - // Refresh data after scan completes - setTimeout(() => { - fetchData(false) - setScanMessage(null) - }, 5000) + setTimeout(() => { fetchData(false); setScanMessage(null) }, 5000) setTimeout(() => fetchData(false), 15000) setTimeout(() => fetchData(false), 30000) } catch (err) { - console.error('Scan error:', err) - setError(err instanceof Error ? err.message : 'Scan fehlgeschlagen - Pruefe Browser-Konsole') + setError(err instanceof Error ? err.message : 'Scan fehlgeschlagen') setScanMessage(null) } finally { setScanning(null) @@ -213,99 +127,38 @@ export default function SecurityDashboardPage() { const getSeverityBadge = (severity: string) => { const base = 'px-3 py-1 rounded-full text-xs font-semibold uppercase' switch (severity.toUpperCase()) { - case 'CRITICAL': - return `${base} bg-red-100 text-red-800` - case 'HIGH': - return `${base} bg-orange-100 text-orange-800` - case 'MEDIUM': - return `${base} bg-yellow-100 text-yellow-800` - case 'LOW': - return `${base} bg-green-100 text-green-800` - default: - return `${base} bg-blue-100 text-blue-800` + case 'CRITICAL': return `${base} bg-red-100 text-red-800` + case 'HIGH': return `${base} bg-orange-100 text-orange-800` + case 'MEDIUM': return `${base} bg-yellow-100 text-yellow-800` + case 'LOW': return `${base} bg-green-100 text-green-800` + default: return `${base} bg-blue-100 text-blue-800` } } - const getStatusBadge = (installed: boolean) => { - return installed - ? 'px-2 py-1 rounded text-xs font-semibold bg-green-100 text-green-800' - : 'px-2 py-1 rounded text-xs font-semibold bg-red-100 text-red-800' - } + const getStatusBadge = (installed: boolean) => installed + ? 'px-2 py-1 rounded text-xs font-semibold bg-green-100 text-green-800' + : 'px-2 py-1 rounded text-xs font-semibold bg-red-100 text-red-800' - const getHistoryStatusColor = (status: string) => { - switch (status) { - case 'success': - return 'bg-green-500' - case 'warning': - return 'bg-yellow-500' - case 'error': - return 'bg-red-500' - default: - return 'bg-slate-400' - } - } - - const getOverallStatus = () => { - if (summary.critical > 0) { - return { label: 'Critical Issues', color: 'bg-red-100 text-red-800' } - } - if (summary.high > 0) { - return { label: 'High Issues', color: 'bg-orange-100 text-orange-800' } - } - if (summary.medium > 0) { - return { label: 'Warnings', color: 'bg-yellow-100 text-yellow-800' } - } - return { label: 'Secure', color: 'bg-green-100 text-green-800' } - } - - const filteredFindings = findings.filter(f => { - if (severityFilter && f.severity.toUpperCase() !== severityFilter.toUpperCase()) return false - if (toolFilter && f.tool.toLowerCase() !== toolFilter.toLowerCase()) return false - return true - }) - - const toolDescriptions: Record = { - gitleaks: { icon: '🔑', desc: 'Secrets Detection in Git History' }, - semgrep: { icon: '🔍', desc: 'Static Application Security Testing (SAST)' }, - bandit: { icon: '🐍', desc: 'Python Security Linter' }, - trivy: { icon: '🔒', desc: 'Container & Filesystem Vulnerability Scanner' }, - grype: { icon: '🐛', desc: 'Vulnerability Scanner for Dependencies' }, - syft: { icon: '📦', desc: 'SBOM Generator (CycloneDX/SPDX)' }, - } - - // Map tool names to backend scan types - const toolToScanType: Record = { - gitleaks: 'secrets', - semgrep: 'sast', - bandit: 'sast', - trivy: 'deps', - grype: 'deps', - syft: 'sbom', - } - - const status = getOverallStatus() + const status = summary.critical > 0 ? { label: 'Critical Issues', color: 'bg-red-100 text-red-800' } + : summary.high > 0 ? { label: 'High Issues', color: 'bg-orange-100 text-orange-800' } + : summary.medium > 0 ? { label: 'Warnings', color: 'bg-yellow-100 text-yellow-800' } + : { label: 'Secure', color: 'bg-green-100 text-green-800' } return (
- - {/* DevOps Pipeline Sidebar */} {/* Header with Status */} @@ -313,62 +166,32 @@ export default function SecurityDashboardPage() {

Security Status

- - {status.label} - + {status.label}
- - Auto-Refresh aktiv + Auto-Refresh aktiv -
- - {/* Severity Summary */}
-
-
{summary.critical}
-
Critical
-
-
-
{summary.high}
-
High
-
-
-
{summary.medium}
-
Medium
-
-
-
{summary.low}
-
Low
-
-
-
{summary.info}
-
Info
-
-
-
{summary.total}
-
Total
-
+ {[ + { label: 'Critical', value: summary.critical, color: 'border-red-500', textColor: 'text-red-600' }, + { label: 'High', value: summary.high, color: 'border-orange-500', textColor: 'text-orange-600' }, + { label: 'Medium', value: summary.medium, color: 'border-yellow-500', textColor: 'text-yellow-600' }, + { label: 'Low', value: summary.low, color: 'border-green-500', textColor: 'text-green-600' }, + { label: 'Info', value: summary.info, color: 'border-blue-500', textColor: 'text-blue-600' }, + { label: 'Total', value: summary.total, color: 'border-slate-400', textColor: 'text-slate-700' }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))}
@@ -376,15 +199,8 @@ export default function SecurityDashboardPage() {
{(['overview', 'findings', 'tools', 'history', 'monitoring'] as const).map(tab => ( - ))}
-
- {/* Scan Status Message */} {scanMessage && (
{scanMessage} - {lastScanTime && ( - (gestartet um {lastScanTime}) - )} + {lastScanTime && (gestartet um {lastScanTime})}
)} - - {error && ( -
- {error} -
- )} - + {error &&
{error}
} {loading ? ( -
-
-
+
) : ( <> - {/* Overview Tab */} - {activeTab === 'overview' && ( -
- {/* Tools Grid */} -
-

DevSecOps Tools

-
- {tools.map(tool => { - const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' } - return ( -
-
-
- {info.icon} - {tool.name} -
- - {tool.installed ? 'Installiert' : 'Nicht installiert'} - -
-

{info.desc}

-
- {tool.version || '-'} - Letzter Scan: {tool.last_run || 'Nie'} -
- -
- ) - })} -
-
- - {/* Recent Findings */} -
-

Aktuelle Findings

- {findings.length === 0 ? ( -
- 🎉 - Keine Findings gefunden. Das ist gut! -
- ) : ( -
- - - - - - - - - - - - {findings.slice(0, 10).map((finding, idx) => ( - - - - - - - - ))} - -
SeverityToolFindingDateiGefunden
- {finding.severity} - {finding.tool}{finding.title}{finding.file || '-'} - {finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) : '-'} -
- {findings.length > 10 && ( - - )} -
- )} -
-
- )} - - {/* Findings Tab */} - {activeTab === 'findings' && ( -
- {/* Filters */} -
- - {['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].map(sev => ( - - ))} - - {['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype'].map(t => ( - - ))} -
- - {filteredFindings.length === 0 ? ( -
- Keine Findings mit diesem Filter gefunden. -
- ) : ( -
- - - - - - - - - - - - - {filteredFindings.map((finding, idx) => ( - - - - - - - - - ))} - -
SeverityToolFindingDateiZeileGefunden
- {finding.severity} - {finding.tool} -
{finding.title}
- {finding.message && ( -
{finding.message}
- )} -
- {finding.file || '-'} - {finding.line || '-'} - {finding.found_at ? new Date(finding.found_at).toLocaleDateString('de-DE') : '-'} -
-
- )} -
- )} - - {/* Tools Tab */} - {activeTab === 'tools' && ( -
- {tools.map(tool => { - const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' } - return ( -
-
-
-
- {info.icon} -

{tool.name}

-
-

{info.desc}

-
- - {tool.installed ? 'Installiert' : 'Nicht installiert'} - -
-
-
- Version: - {tool.version || '-'} -
-
- Letzter Scan: - {tool.last_run || 'Nie'} -
-
- Findings: - {tool.last_findings} -
-
-
- - -
-
- ) - })} -
- )} - - {/* History Tab */} - {activeTab === 'history' && ( -
- {history.length === 0 ? ( -
- Keine Scan-Historie vorhanden. -
- ) : ( -
-
- {history.map((item, idx) => ( -
-
-
-
- {item.title} - - {new Date(item.timestamp).toLocaleString('de-DE')} - -
-

{item.description}

-
-
- ))} -
- )} -
- )} - - {/* Monitoring Tab */} - {activeTab === 'monitoring' && ( -
- {/* Real-time Metrics */} -
-

- - - - Security Metriken -

-
- {monitoringMetrics.map((metric, idx) => ( -
-
- {metric.name} - - {metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'} - -
-
- {metric.value} - {metric.unit} -
-
- ))} -
-
- - {/* Active Alerts */} -
-

- - - - Aktive Alerts - {activeAlerts.filter(a => !a.acknowledged).length > 0 && ( - - {activeAlerts.filter(a => !a.acknowledged).length} - - )} -

- {activeAlerts.length === 0 ? ( -
- - Keine aktiven Security-Alerts -
- ) : ( -
- {activeAlerts.map((alert) => ( -
-
- - {alert.severity} - -
-
{alert.title}
-
- {alert.source} • {new Date(alert.timestamp).toLocaleString('de-DE')} -
-
-
- {!alert.acknowledged && ( - - )} -
- ))} -
- )} -
- - {/* Security Overview Cards */} -
-
-

- - - - Authentifizierung -

-
-
- Aktive Sessions - 24 -
-
- Fehlgeschlagene Logins (24h) - 0 -
-
- 2FA-Quote - 100% -
-
-
- -
-

- - - - SSL/TLS -

-
-
- Zertifikate - 5 aktiv -
-
- Naechster Ablauf - 45 Tage -
-
- TLS Version - 1.3 -
-
-
- -
-

- - - - Firewall -

-
-
- Blockierte IPs (24h) - 12 -
-
- Rate Limit Hits - 7 -
-
- WAF Status - Aktiv -
-
-
-
- - {/* Link to CI/CD for pipeline monitoring */} -
-
- - - -
-
Pipeline Security
-
Security-Scans in CI/CD Pipelines und Container-Status
-
-
- - CI/CD Dashboard → - -
-
- )} + {activeTab === 'overview' && setActiveTab('findings')} toolDescriptions={TOOL_DESCRIPTIONS} toolToScanType={TOOL_TO_SCAN_TYPE} getSeverityBadge={getSeverityBadge} getStatusBadge={getStatusBadge} />} + {activeTab === 'findings' && } + {activeTab === 'tools' && } + {activeTab === 'history' && } + {activeTab === 'monitoring' && } )}
- {/* Documentation Section */} -
-
-
-

- - - - Security Dokumentation -

- -
- - {/* Short Description */} -
-

- Das Security Dashboard bietet einen zentralen Ueberblick ueber alle DevSecOps-Aktivitaeten. - Es integriert 6 Security-Tools fuer umfassende Code- und Infrastruktur-Sicherheit: - Secrets Detection, Static Analysis (SAST), Dependency Scanning und SBOM-Generierung. -

-
- - {/* Tool Quick Reference */} -
-
- 🔑 -

Gitleaks

-

Secrets

-
-
- 🔍 -

Semgrep

-

SAST

-
-
- 🐍 -

Bandit

-

Python

-
-
- 🔒 -

Trivy

-

Container

-
-
- 🐛 -

Grype

-

Dependencies

-
-
- 📦 -

Syft

-

SBOM

-
-
- - {/* Full Documentation (Expandable) */} - {showFullDocs && ( -
-
- -

1. Security Tools Uebersicht

- -

🔑 Gitleaks - Secrets Detection

-

Durchsucht die gesamte Git-Historie nach versehentlich eingecheckten Secrets wie API-Keys, Passwoertern und Tokens.

-
    -
  • Scan-Bereich: Git-Historie, Commits, Branches
  • -
  • Erkannte Secrets: AWS Keys, GitHub Tokens, Private Keys, Passwoerter
  • -
  • Ausgabe: JSON-Report mit Fundstelle, Commit-Hash, Autor
  • -
- -

🔍 Semgrep - Static Application Security Testing

-

Fuehrt regelbasierte statische Code-Analyse durch, um Sicherheitsluecken und Anti-Patterns zu finden.

-
    -
  • Unterstuetzte Sprachen: Python, JavaScript, TypeScript, Go, Java
  • -
  • Regelsets: OWASP Top 10, CWE, Security Best Practices
  • -
  • Findings: SQL Injection, XSS, Path Traversal, Insecure Deserialization
  • -
- -

🐍 Bandit - Python Security Linter

-

Spezialisierter Security-Linter fuer Python-Code mit Fokus auf haeufige Sicherheitsprobleme.

-
    -
  • Checks: Hardcoded Passwords, SQL Injection, Shell Injection
  • -
  • Severity Levels: LOW, MEDIUM, HIGH
  • -
  • Confidence: LOW, MEDIUM, HIGH
  • -
- -

🔒 Trivy - Container & Filesystem Scanner

-

Scannt Container-Images und Dateisysteme auf bekannte Schwachstellen (CVEs).

-
    -
  • Scan-Typen: Container Images, Filesystems, Git Repositories
  • -
  • Datenbanken: NVD, GitHub Advisory, Alpine SecDB, RedHat OVAL
  • -
  • Ausgabe: CVE-ID, Severity, Fixed Version, Description
  • -
- -

🐛 Grype - Dependency Vulnerability Scanner

-

Analysiert Software-Abhaengigkeiten auf bekannte Sicherheitsluecken.

-
    -
  • Package Manager: npm, pip, go mod, Maven, Gradle
  • -
  • Input: SBOM (CycloneDX/SPDX), Lockfiles, Container Images
  • -
  • Matching: CPE-basiert, Package URL (purl)
  • -
- -

📦 Syft - SBOM Generator

-

Erstellt Software Bill of Materials (SBOM) fuer Compliance und Supply-Chain-Security.

-
    -
  • Formate: CycloneDX (JSON/XML), SPDX, Syft JSON
  • -
  • Erfassung: Packages, Lizenzen, Versionen, Checksums
  • -
  • Compliance: NIS2, ISO 27001, DSGVO Art. 32
  • -
- -

2. Severity-Klassifizierung

- - - - - - - - - - - - - - - - -
SeverityCVSS ScoreReaktionszeitBeispiele
CRITICAL9.0 - 10.0Sofort (24h)RCE, Auth Bypass, Exposed Secrets
HIGH7.0 - 8.91-3 TageSQL Injection, XSS, Path Traversal
MEDIUM4.0 - 6.91-2 WochenInformation Disclosure, CSRF
LOW0.1 - 3.9Naechster SprintMinor Info Leak, Best Practice
INFO0.0OptionalEmpfehlungen, Hinweise
- -

3. Scan-Workflow

-
-{`┌─────────────────────────────────────────────────────────────┐
-│                    Security Scan Pipeline                    │
-├─────────────────────────────────────────────────────────────┤
-│                                                              │
-│  1. Secrets Detection (Gitleaks)                            │
-│     └── Scannt Git-Historie nach API-Keys & Credentials     │
-│                          ↓                                   │
-│  2. Static Analysis (Semgrep + Bandit)                      │
-│     └── Code-Analyse auf Sicherheitsluecken                 │
-│                          ↓                                   │
-│  3. Dependency Scan (Trivy + Grype)                         │
-│     └── CVE-Check aller Abhaengigkeiten                     │
-│                          ↓                                   │
-│  4. SBOM Generation (Syft)                                  │
-│     └── Software Bill of Materials erstellen                │
-│                          ↓                                   │
-│  5. Report & Dashboard                                       │
-│     └── Ergebnisse aggregieren und visualisieren            │
-│                                                              │
-└─────────────────────────────────────────────────────────────┘`}
-                
- -

4. Remediation-Strategien

- -

Bei Secrets-Findings:

-
    -
  1. Secret sofort rotieren (neue API-Keys, Passwoerter)
  2. -
  3. Git-Historie bereinigen (BFG Repo-Cleaner oder git filter-branch)
  4. -
  5. Betroffene Systeme auf unauthorisierte Zugriffe pruefen
  6. -
  7. Secret-Scanning in Pre-Commit-Hooks aktivieren
  8. -
- -

Bei SAST-Findings:

-
    -
  1. Finding-Details und betroffene Code-Stelle analysieren
  2. -
  3. Empfohlene Fix-Strategie aus Semgrep-Dokumentation anwenden
  4. -
  5. Unit-Tests fuer den Fix schreiben
  6. -
  7. Code-Review durch Security-erfahrenen Entwickler
  8. -
- -

Bei Dependency-Vulnerabilities:

-
    -
  1. Pruefen ob ein Patch/Update verfuegbar ist
  2. -
  3. Abhaengigkeit auf gepatchte Version aktualisieren
  4. -
  5. Falls kein Patch: Workaround oder Alternative evaluieren
  6. -
  7. Temporaer: WAF-Regel als Mitigation
  8. -
- -

5. CI/CD Integration

-

Security-Scans sind in die Gitea Actions Pipeline integriert:

-
    -
  • Pre-Commit: Gitleaks (lokale Secrets-Pruefung)
  • -
  • Pull Request: Semgrep, Bandit, Trivy (Blocking bei Critical)
  • -
  • Main Branch: Full Scan + SBOM-Update
  • -
  • Nightly: Dependency-Update-Check
  • -
- -

6. Compliance-Mapping

- - - - - - - - - - - - - - -
RegulationArtikelErfuellt durch
DSGVOArt. 32Alle Security-Scans, Vulnerability Management
NIS2Art. 21SBOM, Supply-Chain-Security, Incident Response
ISO 27001A.12.6Vulnerability Management, Patch Management
OWASPTop 10SAST (Semgrep), Secrets Detection
- -

7. API-Endpunkte

- - - - - - - - - - - - - - - - -
MethodeEndpointBeschreibung
GET/api/v1/security/toolsTool-Status abrufen
GET/api/v1/security/findingsAlle Findings abrufen
GET/api/v1/security/summarySeverity-Zusammenfassung
GET/api/v1/security/historyScan-Historie
POST/api/v1/security/scan/allFull Scan starten
POST/api/v1/security/scan/[tool]Einzelnes Tool scannen
- -
-
- )} -
-
+
) } diff --git a/admin-core/app/(admin)/infrastructure/security/types.ts b/admin-core/app/(admin)/infrastructure/security/types.ts new file mode 100644 index 0000000..b18e120 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/security/types.ts @@ -0,0 +1,57 @@ +/** + * Types for Security Dashboard + */ + +export interface ToolStatus { + name: string + installed: boolean + version: string | null + last_run: string | null + last_findings: number +} + +export interface Finding { + id: string + tool: string + severity: string + title: string + message: string | null + file: string | null + line: number | null + found_at: string +} + +export interface SeveritySummary { + critical: number + high: number + medium: number + low: number + info: number + total: number +} + +export interface HistoryItem { + timestamp: string + title: string + description: string + status: string +} + +export type ScanType = 'secrets' | 'sast' | 'deps' | 'containers' | 'sbom' | 'all' + +export interface MonitoringMetric { + name: string + value: number + unit: string + status: 'ok' | 'warning' | 'critical' + trend: 'up' | 'down' | 'stable' +} + +export interface ActiveAlert { + id: string + severity: 'critical' | 'high' | 'medium' | 'low' + title: string + source: string + timestamp: string + acknowledged: boolean +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/BacklogTab.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/BacklogTab.tsx new file mode 100644 index 0000000..abd12f6 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/BacklogTab.tsx @@ -0,0 +1,427 @@ +'use client' + +import { useState } from 'react' +import type { LLMRoutingOption } from '@/types/infrastructure-modules' +import type { FailedTest, BacklogItem, BacklogPriority } from '../types' + +// ============================================================================== +// FailedTestCard +// ============================================================================== + +function FailedTestCard({ + test, + onStatusChange, + onPriorityChange, + priority = 'medium', + failureCount = 1, +}: { + test: FailedTest + onStatusChange: (testId: string, status: string) => void + onPriorityChange?: (testId: string, priority: string) => void + priority?: BacklogPriority + failureCount?: number +}) { + const errorTypeColors: Record = { + assertion: 'bg-amber-100 text-amber-700', + nil_pointer: 'bg-red-100 text-red-700', + type_error: 'bg-purple-100 text-purple-700', + network: 'bg-blue-100 text-blue-700', + timeout: 'bg-orange-100 text-orange-700', + logic_error: 'bg-slate-100 text-slate-700', + unknown: 'bg-slate-100 text-slate-700', + } + + const statusColors: Record = { + open: 'bg-red-100 text-red-700', + in_progress: 'bg-blue-100 text-blue-700', + fixed: 'bg-emerald-100 text-emerald-700', + wont_fix: 'bg-slate-100 text-slate-700', + flaky: 'bg-purple-100 text-purple-700', + } + + const priorityColors: Record = { + critical: 'bg-red-500 text-white', + high: 'bg-orange-500 text-white', + medium: 'bg-yellow-500 text-white', + low: 'bg-slate-400 text-white', + } + + const priorityLabels: Record = { + critical: '!!! Kritisch', + high: '!! Hoch', + medium: '! Mittel', + low: 'Niedrig', + } + + return ( +
+
+
+
+ + {priorityLabels[priority]} + + + {test.error_type.replace('_', ' ')} + + {test.service} + {failureCount > 1 && ( + + {failureCount}x fehlgeschlagen + + )} +
+

+ {test.name} +

+

+ {test.file_path} +

+
+
+ + {onPriorityChange && ( + + )} +
+
+ +
+

Fehlermeldung:

+

+ {test.error_message || 'Keine Details verfuegbar'} +

+
+ + {test.suggestion && ( +
+

💡 Loesungsvorschlag:

+

+ {test.suggestion} +

+
+ )} + +
+ Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'} + +
+
+ ) +} + +// ============================================================================== +// BacklogTab +// ============================================================================== + +export function BacklogTab({ + failedTests, + onStatusChange, + onPriorityChange, + isLoading, + backlogItems, + usePostgres = false, +}: { + failedTests: FailedTest[] + onStatusChange: (testId: string, status: string) => void + onPriorityChange?: (testId: string, priority: string) => void + isLoading: boolean + backlogItems?: BacklogItem[] + usePostgres?: boolean +}) { + const [filterStatus, setFilterStatus] = useState('open') + const [filterService, setFilterService] = useState('all') + const [filterPriority, setFilterPriority] = useState('all') + const [llmAutoAnalysis, setLlmAutoAnalysis] = useState(true) + const [llmRouting, setLlmRouting] = useState('smart_routing') + + // Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy + const items = usePostgres && backlogItems ? backlogItems : failedTests + + // Gruppiere nach Service + const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))] + + // Filtere Items + const filteredItems = items.filter(item => { + const status = 'status' in item ? item.status : 'open' + const service = 'service' in item ? item.service : '' + const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium' + + if (filterStatus !== 'all' && status !== filterStatus) return false + if (filterService !== 'all' && service !== filterService) return false + if (filterPriority !== 'all' && priority !== filterPriority) return false + return true + }) + + // Zaehle nach Status + const openCount = items.filter(t => t.status === 'open').length + const inProgressCount = items.filter(t => t.status === 'in_progress').length + const fixedCount = items.filter(t => t.status === 'fixed').length + const flakyCount = items.filter(t => t.status === 'flaky').length + + // Zaehle nach Prioritaet (nur bei PostgreSQL) + const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0 + const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0 + + if (isLoading) { + return ( +
+
+
+ ) + } + + // Konvertiere BacklogItem zu FailedTest fuer die Anzeige + const convertToFailedTest = (item: BacklogItem): FailedTest => ({ + id: String(item.id), + name: item.test_name, + service: item.service, + file_path: item.test_file || '', + error_message: item.error_message || '', + error_type: item.error_type || 'unknown', + suggestion: item.fix_suggestion || '', + run_id: '', + last_failed: item.last_failed_at, + status: item.status, + }) + + return ( +
+ {/* Stats */} +
+
+

{openCount}

+

Offene Fehler

+
+
+

{inProgressCount}

+

In Arbeit

+
+
+

{fixedCount}

+

Behoben

+
+
+

{flakyCount}

+

Flaky

+
+ {usePostgres && criticalCount + highCount > 0 && ( +
+

{criticalCount + highCount}

+

Kritisch/Hoch

+
+ )} +
+ + {/* PostgreSQL Badge */} + {usePostgres && ( +
+ + + + Persistente Speicherung aktiv (PostgreSQL) +
+ )} + + {/* LLM Analysis Toggle */} +
+
+
+
+ + + +
+
+

Automatische LLM-Analyse

+

KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege

+
+
+ +
+ + {llmAutoAnalysis && ( +
+

LLM-Routing Strategie:

+
+ {([ + { value: 'local_only' as const, label: 'Nur lokales 32B LLM', badge: 'DSGVO', badgeColor: 'bg-emerald-100 text-emerald-700' }, + { value: 'claude_preferred' as const, label: 'Claude bevorzugt', badge: 'Qualitaet', badgeColor: 'bg-blue-100 text-blue-700' }, + { value: 'smart_routing' as const, label: 'Smart Routing', badge: 'Empfohlen', badgeColor: 'bg-amber-100 text-amber-700' }, + ]).map((option) => ( + + ))} +
+

+ {llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'} + {llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'} + {llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'} +

+
+ )} +
+ + {/* Filter */} +
+
+ + +
+
+ + +
+ {usePostgres && ( +
+ + +
+ )} +
+ {filteredItems.length} von {items.length} Tests angezeigt +
+
+ + {/* Test-Liste */} + {filteredItems.length === 0 ? ( +
+ + + +

+ {filterStatus === 'open' ? 'Keine offenen Fehler! 🎉' : 'Keine Tests mit diesem Filter gefunden.'} +

+ {filterStatus === 'open' && ( +

+ Alle Tests bestanden. Bereit fuer Go-Live! +

+ )} +
+ ) : ( +
+ {filteredItems.map((item) => { + const test = usePostgres && 'test_name' in item + ? convertToFailedTest(item as BacklogItem) + : item as FailedTest + const itemPriority = usePostgres && 'priority' in item + ? (item as BacklogItem).priority + : 'medium' + const failureCount = usePostgres && 'failure_count' in item + ? (item as BacklogItem).failure_count + : 1 + + return ( + + ) + })} +
+ )} + + {/* Info */} +
+
+ + + +
+

Workflow fuer fehlgeschlagene Tests:

+
    +
  1. Markiere den Test als "In Arbeit" wenn du daran arbeitest
  2. +
  3. Analysiere die Fehlermeldung und den Loesungsvorschlag
  4. +
  5. Behebe den Fehler im Code
  6. +
  7. Fuehre den Test erneut aus (Button im Service-Tab)
  8. +
  9. Markiere als "Behoben" wenn der Test besteht
  10. + {usePostgres &&
  11. Setze "Flaky" fuer sporadisch fehlschlagende Tests
  12. } +
+
+
+
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/CoverageChart.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/CoverageChart.tsx new file mode 100644 index 0000000..71a2013 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/CoverageChart.tsx @@ -0,0 +1,42 @@ +'use client' + +import type { CoverageData } from '../types' + +export function CoverageChart({ data }: { data: CoverageData[] }) { + if (data.length === 0) { + return ( +
+ Keine Coverage-Daten verfuegbar +
+ ) + } + + const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent) + + return ( +
+ {sortedData.map((item) => ( +
+
+ {item.display_name} + = 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600' + }`} + > + {item.coverage_percent.toFixed(1)}% + +
+
+
= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500' + }`} + style={{ width: `${item.coverage_percent}%` }} + /> +
+
+ ))} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/FrameworkDistribution.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/FrameworkDistribution.tsx new file mode 100644 index 0000000..adc08b1 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/FrameworkDistribution.tsx @@ -0,0 +1,43 @@ +'use client' + +export function FrameworkDistribution({ data }: { data: Record }) { + const total = Object.values(data).reduce((a, b) => a + b, 0) + if (total === 0) return null + + const frameworkLabels: Record = { + go_test: 'Go Tests', + pytest: 'Python (pytest)', + jest: 'Jest (TS)', + vitest: 'Vitest (SDK)', + playwright: 'Playwright (E2E)', + bqas_golden: 'BQAS Golden', + bqas_rag: 'BQAS RAG', + bqas_synthetic: 'BQAS Synthetic', + } + + const frameworkColors: Record = { + go_test: 'bg-cyan-500', + pytest: 'bg-yellow-500', + jest: 'bg-blue-500', + vitest: 'bg-orange-500', + playwright: 'bg-purple-500', + bqas_golden: 'bg-emerald-500', + bqas_rag: 'bg-teal-500', + bqas_synthetic: 'bg-amber-500', + } + + return ( +
+ {Object.entries(data) + .sort((a, b) => b[1] - a[1]) + .map(([framework, count]) => ( +
+
+ {frameworkLabels[framework] || framework} + {count} + ({((count / total) * 100).toFixed(0)}%) +
+ ))} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/GuideTab.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/GuideTab.tsx new file mode 100644 index 0000000..27c9729 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/GuideTab.tsx @@ -0,0 +1,232 @@ +'use client' + +import Link from 'next/link' + +export function GuideTab() { + return ( +
+
+

+ + + + Was ist das Test Dashboard? +

+

+ Das Test Dashboard ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System. + Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren. + Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt. + Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright). +

+
+ +
+

Test-Kategorien

+
+
+
+ 🐹 +

Go Unit Tests (~57)

+
+

+ consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk +

+
+
+
+ 🐍 +

Python Tests (~50)

+
+

+ backend, voice-service, klausur-service, geo-service +

+
+
+
+ 🎯 +

BQAS Golden (97)

+
+

+ Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung +

+
+
+
+ 📚 +

BQAS RAG (~20)

+
+

+ RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control +

+
+
+
+ 📘 +

TypeScript Jest (~8)

+
+

+ Website Unit Tests fuer React-Komponenten +

+
+
+
+ +

SDK Vitest (~43)

+
+

+ AI Compliance SDK Unit Tests: Types, Export, Components, Reducer +

+
+
+
+ 🎭 +

SDK Playwright (~25)

+
+

+ SDK E2E Tests: Navigation, Workflow, Command Bar, Export +

+
+
+
+ 🌐 +

Website E2E (~5)

+
+

+ End-to-End Tests fuer kritische User Flows +

+
+
+
+ 🔗 +

Integration Tests (~15)

+
+

+ Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB +

+
+
+
+ +
+

Architektur

+
+{`┌────────────────────────────────────────────────────────────────────┐
+│               Admin-v2 Test Dashboard                               │
+│               /infrastructure/tests                                 │
+├────────────────────────────────────────────────────────────────────┤
+│  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌─────────────┐  │
+│  │ Unit Tests │  │ SDK Tests  │  │   BQAS     │  │ E2E Tests   │  │
+│  │  (Go, Py)  │  │  (Vitest)  │  │ (LLM/RAG)  │  │ (Playwright)│  │
+│  └────────────┘  └────────────┘  └────────────┘  └─────────────┘  │
+│        │               │               │               │           │
+│        ▼               ▼               ▼               ▼           │
+│  ┌──────────────────────────────────────────────────────────────┐ │
+│  │                    Test Registry API                          │ │
+│  │              /backend/api/tests/registry.py                   │ │
+│  └──────────────────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────────────┘
+
+Tests bleiben wo sie sind:
+- /consent-service/internal/**/*_test.go
+- /backend/tests/test_*.py
+- /voice-service/tests/bqas/
+- /admin-v2/components/sdk/__tests__/*.test.ts   (Vitest)
+- /admin-v2/e2e/specs/*.spec.ts                  (Playwright)`}
+        
+
+ + {/* CI/CD Workflow Anleitung */} +
+

+ + + + CI/CD Integration +

+ +
+
+

🤖 Automatisch (bei jedem Push/PR)

+
    +
  • + + Unit Tests - Go & Python Tests laufen automatisch +
  • +
  • + + Test-Ergebnisse - Werden ans Dashboard gesendet +
  • +
  • + + Backlog - Fehlgeschlagene Tests erscheinen hier +
  • +
  • + + Linting - Code-Qualitaet bei PRs pruefen +
  • +
+
+ +
+

👆 Manuell (Button oder Tag)

+
    +
  • + + Docker Builds - Container erstellen +
  • +
  • + + SBOM/Scans - Sicherheitsanalyse ausfuehren +
  • +
  • + + Deployment - In Produktion deployen +
  • +
  • + + Pipeline starten - Im CI/CD Dashboard +
  • +
+
+
+ +
+

+ Daten-Fluss: Gitea Actions → POST /api/tests/ci-result → PostgreSQL → Test Dashboard +

+
+
+ +
+ +
+ + + +
+

BQAS Dashboard

+

Detaillierte BQAS-Metriken und Trend-Analyse

+
+
+ + +
+ + + +
+

CI/CD Pipelines

+

Gitea Actions und automatische Test-Planung

+
+
+ +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/MetricCard.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/MetricCard.tsx new file mode 100644 index 0000000..ae7fdfd --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/MetricCard.tsx @@ -0,0 +1,55 @@ +'use client' + +export function MetricCard({ + title, + value, + subtitle, + trend, + color = 'blue', +}: { + title: string + value: string | number + subtitle?: string + trend?: 'up' | 'down' | 'stable' + color?: 'blue' | 'green' | 'red' | 'yellow' | 'orange' | 'purple' +}) { + const colorClasses = { + blue: 'bg-blue-50 border-blue-200', + green: 'bg-emerald-50 border-emerald-200', + red: 'bg-red-50 border-red-200', + yellow: 'bg-amber-50 border-amber-200', + orange: 'bg-orange-50 border-orange-200', + purple: 'bg-purple-50 border-purple-200', + } + + const trendIcons = { + up: ( + + + + ), + down: ( + + + + ), + stable: ( + + + + ), + } + + return ( +
+
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+ {trend &&
{trendIcons[trend]}
} +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/ServiceTestCard.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/ServiceTestCard.tsx new file mode 100644 index 0000000..475ef62 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/ServiceTestCard.tsx @@ -0,0 +1,147 @@ +'use client' + +import type { ServiceTestInfo, ServiceProgress } from '../types' + +export function ServiceTestCard({ + service, + onRun, + isRunning, + progress, +}: { + service: ServiceTestInfo + onRun: (service: string) => void + isRunning: boolean + progress?: ServiceProgress +}) { + const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0 + + const getLanguageIcon = (lang: string) => { + switch (lang) { + case 'go': + return '🐹' + case 'python': + return '🐍' + case 'typescript': + return '📘' + case 'mixed': + return '🔀' + default: + return '📦' + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'passed': + return 'bg-emerald-100 text-emerald-700' + case 'failed': + return 'bg-red-100 text-red-700' + case 'running': + return 'bg-blue-100 text-blue-700' + default: + return 'bg-slate-100 text-slate-700' + } + } + + return ( +
+
+
+ {getLanguageIcon(service.language)} +
+

{service.display_name}

+

+ {service.port ? `Port ${service.port}` : 'Library'} • {service.language} +

+
+
+ + {service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'} + +
+ +
+
+
+ Pass Rate + {passRate.toFixed(0)}% +
+
+
= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500' + }`} + style={{ width: `${passRate}%` }} + /> +
+
+ +
+
+

{service.total_tests}

+

Tests

+
+
+

{service.passed_tests}

+

Bestanden

+
+
+

{service.failed_tests}

+

Fehler

+
+
+ + {service.coverage_percent && ( +
+ Coverage + = 70 ? 'text-emerald-600' : 'text-amber-600'}`}> + {service.coverage_percent.toFixed(1)}% + +
+ )} + + {/* Progress-Anzeige wenn Tests laufen */} + {isRunning && progress && progress.status === 'running' && ( +
+
+ {progress.current_file || 'Starte...'} + {progress.files_done}/{progress.files_total} Dateien +
+
+
0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }} + /> +
+
+ {progress.passed} bestanden + {progress.failed} fehler +
+
+ )} + + +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/TestRunsTable.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/TestRunsTable.tsx new file mode 100644 index 0000000..00f5f2b --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/TestRunsTable.tsx @@ -0,0 +1,66 @@ +'use client' + +import type { TestRun } from '../types' + +export function TestRunsTable({ runs }: { runs: TestRun[] }) { + if (runs.length === 0) { + return ( +
+ Keine Test-Laeufe vorhanden +
+ ) + } + + return ( +
+ + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + ))} + +
IDServiceZeitpunktTestsBestandenDauerStatus
{run.id.slice(-8)}{run.service} + {new Date(run.started_at).toLocaleString('de-DE')} + {run.total_tests} + {run.passed_tests} + / + {run.failed_tests} + + {run.duration_seconds.toFixed(1)}s + + + {run.status} + +
+
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_components/ToastContainer.tsx b/admin-core/app/(admin)/infrastructure/tests/_components/ToastContainer.tsx new file mode 100644 index 0000000..256ce1a --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_components/ToastContainer.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { Toast } from '../types' + +export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) { + return ( +
+ {toasts.map((toast) => ( +
+ {toast.type === 'loading' ? ( + + + + + ) : toast.type === 'success' ? ( + + + + ) : toast.type === 'error' ? ( + + + + ) : ( + + + + )} + {toast.message} + {toast.type !== 'loading' && ( + + )} +
+ ))} +
+ ) +} diff --git a/admin-core/app/(admin)/infrastructure/tests/_hooks/useTestDashboard.ts b/admin-core/app/(admin)/infrastructure/tests/_hooks/useTestDashboard.ts new file mode 100644 index 0000000..c0b6505 --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/_hooks/useTestDashboard.ts @@ -0,0 +1,313 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import type { + ServiceTestInfo, + TestRegistryStats, + TestRun, + CoverageData, + TabType, + Toast, + FailedTest, + BacklogItem, + ServiceProgress, +} from '../types' + +const API_BASE = '/api/tests' + +// Demo data for when API is not available +const DEMO_SERVICES: ServiceTestInfo[] = [ + { service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' }, + { service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' }, + { service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' }, + { service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' }, + { service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' }, + { service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' }, + { service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' }, + { service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' }, +] + +const DEMO_STATS: TestRegistryStats = { + total_tests: 278, + total_passed: 263, + total_failed: 15, + total_skipped: 0, + overall_pass_rate: 94.6, + average_coverage: 78.5, + services_count: 11, + by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 }, + by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 }, +} + +export function useTestDashboard() { + const [activeTab, setActiveTab] = useState('overview') + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Toast state + const [toasts, setToasts] = useState([]) + const toastIdRef = useRef(0) + + const addToast = useCallback((type: Toast['type'], message: string) => { + const id = ++toastIdRef.current + setToasts((prev) => [...prev, { id, type, message }]) + if (type !== 'loading') { + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 5000) + } + return id + }, []) + + const removeToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const updateToast = useCallback((id: number, type: Toast['type'], message: string) => { + setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, type, message } : t))) + if (type !== 'loading') { + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 5000) + } + }, []) + + // Data states + const [services, setServices] = useState([]) + const [stats, setStats] = useState(null) + const [coverage, setCoverage] = useState([]) + const [testRuns, setTestRuns] = useState([]) + const [failedTests, setFailedTests] = useState([]) + const [backlogItems, setBacklogItems] = useState([]) + const [usePostgres, setUsePostgres] = useState(false) + + // Running states + const [runningServices, setRunningServices] = useState>(new Set()) + + // Progress states fuer laufende Tests + const [serviceProgress, setServiceProgress] = useState>({}) + + // Fetch data + const fetchData = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const registryResponse = await fetch(`${API_BASE}/registry`) + if (registryResponse.ok) { + const data = await registryResponse.json() + setServices(data.services || DEMO_SERVICES) + setStats(data.stats || DEMO_STATS) + } else { + setServices(DEMO_SERVICES) + setStats(DEMO_STATS) + } + + const coverageResponse = await fetch(`${API_BASE}/coverage`) + if (coverageResponse.ok) { + const data = await coverageResponse.json() + setCoverage(data.services || []) + } else { + setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({ + service: s.service, + display_name: s.display_name, + coverage_percent: s.coverage_percent!, + language: s.language, + }))) + } + + const runsResponse = await fetch(`${API_BASE}/runs`) + if (runsResponse.ok) { + const data = await runsResponse.json() + setTestRuns(data.runs || []) + } + + // Lade fehlgeschlagene Tests fuer Backlog + const failedResponse = await fetch(`${API_BASE}/failed`) + if (failedResponse.ok) { + const data = await failedResponse.json() + setFailedTests(data.tests || []) + } + + // Versuche PostgreSQL-Backlog zu laden (neue API) + try { + const backlogResponse = await fetch(`${API_BASE}/backlog`) + if (backlogResponse.ok) { + const data = await backlogResponse.json() + if (data.items && data.items.length > 0) { + setBacklogItems(data.items) + setUsePostgres(true) + } + } + } catch { + // PostgreSQL nicht verfuegbar, nutze Legacy + setUsePostgres(false) + } + + } catch (err) { + console.error('Failed to fetch test registry data:', err) + setServices(DEMO_SERVICES) + setStats(DEMO_STATS) + setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({ + service: s.service, + display_name: s.display_name, + coverage_percent: s.coverage_percent!, + language: s.language, + }))) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + // Update failed test status + const updateTestStatus = async (testId: string, status: string) => { + try { + const endpoint = usePostgres + ? `${API_BASE}/backlog/${testId}/status` + : `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}` + + const response = await fetch(endpoint, { + method: 'POST', + headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined, + body: usePostgres ? JSON.stringify({ status }) : undefined, + }) + + if (response.ok) { + if (usePostgres) { + setBacklogItems(prev => + prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t) + ) + } + setFailedTests(prev => + prev.map(t => t.id === testId ? { ...t, status: status as any } : t) + ) + addToast('success', `Test-Status auf "${status}" gesetzt`) + } + } catch (err) { + console.error('Failed to update test status:', err) + setFailedTests(prev => + prev.map(t => t.id === testId ? { ...t, status: status as any } : t) + ) + if (usePostgres) { + setBacklogItems(prev => + prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t) + ) + } + } + } + + // Update failed test priority (nur PostgreSQL) + const updateTestPriority = async (testId: string, priority: string) => { + if (!usePostgres) return + + try { + const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ priority }), + }) + + if (response.ok) { + setBacklogItems(prev => + prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t) + ) + addToast('success', `Prioritaet auf "${priority}" gesetzt`) + } + } catch (err) { + console.error('Failed to update test priority:', err) + setBacklogItems(prev => + prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t) + ) + } + } + + // Run tests mit Progress-Polling + const runTests = async (service: string) => { + setRunningServices((prev) => new Set(prev).add(service)) + const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`) + + // Progress-Polling starten + let pollInterval: NodeJS.Timeout | null = null + const pollProgress = async () => { + try { + const progressResponse = await fetch(`${API_BASE}/progress/${service}`) + if (progressResponse.ok) { + const progress = await progressResponse.json() + setServiceProgress((prev) => ({ + ...prev, + [service]: progress, + })) + + if (progress.status === 'running' && progress.files_total > 0) { + const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)` + updateToast(loadingToast, 'loading', toastMsg) + } + } + } catch (err) { + // Ignore polling errors + } + } + + pollInterval = setInterval(pollProgress, 1000) + + try { + const response = await fetch(`${API_BASE}/run/${service}`, { + method: 'POST', + }) + + if (response.ok) { + await new Promise(resolve => setTimeout(resolve, 500)) + await pollProgress() + const finalProgress = serviceProgress[service] + const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen' + updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`) + await fetchData() + } else { + updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`) + } + } catch (err) { + console.error('Failed to run tests:', err) + updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`) + } finally { + if (pollInterval) { + clearInterval(pollInterval) + } + setRunningServices((prev) => { + const next = new Set(prev) + next.delete(service) + return next + }) + setServiceProgress((prev) => { + const next = { ...prev } + delete next[service] + return next + }) + } + } + + return { + activeTab, + setActiveTab, + isLoading, + error, + toasts, + removeToast, + services, + stats, + coverage, + testRuns, + failedTests, + backlogItems, + usePostgres, + runningServices, + serviceProgress, + fetchData, + updateTestStatus, + updateTestPriority, + runTests, + } +} diff --git a/admin-core/app/(admin)/infrastructure/tests/page.tsx b/admin-core/app/(admin)/infrastructure/tests/page.tsx index ce4e3dd..d11d538 100644 --- a/admin-core/app/(admin)/infrastructure/tests/page.tsx +++ b/admin-core/app/(admin)/infrastructure/tests/page.tsx @@ -14,1409 +14,44 @@ * - E2E Playwright (~5) */ -import React, { useState, useEffect, useCallback, useRef } from 'react' +import React from 'react' import Link from 'next/link' import { PagePurpose } from '@/components/common/PagePurpose' import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar' -import type { LLMRoutingOption } from '@/types/infrastructure-modules' -import type { - ServiceTestInfo, - TestRegistryStats, - TestRun, - CoverageData, - TabType, - Toast, - FailedTest, - BacklogItem, - BacklogPriority, - BacklogStatus, - TrendDataPoint, -} from './types' +import type { TabType } from './types' -// API Configuration -const API_BASE = '/api/tests' - -// ============================================================================== -// Toast Notification Component -// ============================================================================== - -function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) { - return ( -
- {toasts.map((toast) => ( -
- {toast.type === 'loading' ? ( - - - - - ) : toast.type === 'success' ? ( - - - - ) : toast.type === 'error' ? ( - - - - ) : ( - - - - )} - {toast.message} - {toast.type !== 'loading' && ( - - )} -
- ))} -
- ) -} - -// ============================================================================== -// Helper Components -// ============================================================================== - -function MetricCard({ - title, - value, - subtitle, - trend, - color = 'blue', -}: { - title: string - value: string | number - subtitle?: string - trend?: 'up' | 'down' | 'stable' - color?: 'blue' | 'green' | 'red' | 'yellow' | 'orange' | 'purple' -}) { - const colorClasses = { - blue: 'bg-blue-50 border-blue-200', - green: 'bg-emerald-50 border-emerald-200', - red: 'bg-red-50 border-red-200', - yellow: 'bg-amber-50 border-amber-200', - orange: 'bg-orange-50 border-orange-200', - purple: 'bg-purple-50 border-purple-200', - } - - const trendIcons = { - up: ( - - - - ), - down: ( - - - - ), - stable: ( - - - - ), - } - - return ( -
-
-
-

{title}

-

{value}

- {subtitle &&

{subtitle}

} -
- {trend &&
{trendIcons[trend]}
} -
-
- ) -} - -function ServiceTestCard({ - service, - onRun, - isRunning, - progress, -}: { - service: ServiceTestInfo - onRun: (service: string) => void - isRunning: boolean - progress?: { - current_file: string - files_done: number - files_total: number - passed: number - failed: number - status: string - } -}) { - const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0 - - const getLanguageIcon = (lang: string) => { - switch (lang) { - case 'go': - return '🐹' - case 'python': - return '🐍' - case 'typescript': - return '📘' - case 'mixed': - return '🔀' - default: - return '📦' - } - } - - const getStatusColor = (status: string) => { - switch (status) { - case 'passed': - return 'bg-emerald-100 text-emerald-700' - case 'failed': - return 'bg-red-100 text-red-700' - case 'running': - return 'bg-blue-100 text-blue-700' - default: - return 'bg-slate-100 text-slate-700' - } - } - - return ( -
-
-
- {getLanguageIcon(service.language)} -
-

{service.display_name}

-

- {service.port ? `Port ${service.port}` : 'Library'} • {service.language} -

-
-
- - {service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'} - -
- -
-
-
- Pass Rate - {passRate.toFixed(0)}% -
-
-
= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500' - }`} - style={{ width: `${passRate}%` }} - /> -
-
- -
-
-

{service.total_tests}

-

Tests

-
-
-

{service.passed_tests}

-

Bestanden

-
-
-

{service.failed_tests}

-

Fehler

-
-
- - {service.coverage_percent && ( -
- Coverage - = 70 ? 'text-emerald-600' : 'text-amber-600'}`}> - {service.coverage_percent.toFixed(1)}% - -
- )} - - {/* Progress-Anzeige wenn Tests laufen */} - {isRunning && progress && progress.status === 'running' && ( -
-
- {progress.current_file || 'Starte...'} - {progress.files_done}/{progress.files_total} Dateien -
-
-
0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }} - /> -
-
- {progress.passed} bestanden - {progress.failed} fehler -
-
- )} - - -
-
- ) -} - -function CoverageChart({ data }: { data: CoverageData[] }) { - if (data.length === 0) { - return ( -
- Keine Coverage-Daten verfuegbar -
- ) - } - - const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent) - - return ( -
- {sortedData.map((item) => ( -
-
- {item.display_name} - = 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600' - }`} - > - {item.coverage_percent.toFixed(1)}% - -
-
-
= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500' - }`} - style={{ width: `${item.coverage_percent}%` }} - /> -
-
- ))} -
- ) -} - -function FrameworkDistribution({ data }: { data: Record }) { - const total = Object.values(data).reduce((a, b) => a + b, 0) - if (total === 0) return null - - const frameworkLabels: Record = { - go_test: 'Go Tests', - pytest: 'Python (pytest)', - jest: 'Jest (TS)', - vitest: 'Vitest (SDK)', - playwright: 'Playwright (E2E)', - bqas_golden: 'BQAS Golden', - bqas_rag: 'BQAS RAG', - bqas_synthetic: 'BQAS Synthetic', - } - - const frameworkColors: Record = { - go_test: 'bg-cyan-500', - pytest: 'bg-yellow-500', - jest: 'bg-blue-500', - vitest: 'bg-orange-500', - playwright: 'bg-purple-500', - bqas_golden: 'bg-emerald-500', - bqas_rag: 'bg-teal-500', - bqas_synthetic: 'bg-amber-500', - } - - return ( -
- {Object.entries(data) - .sort((a, b) => b[1] - a[1]) - .map(([framework, count]) => ( -
-
- {frameworkLabels[framework] || framework} - {count} - ({((count / total) * 100).toFixed(0)}%) -
- ))} -
- ) -} - -function TestRunsTable({ runs }: { runs: TestRun[] }) { - if (runs.length === 0) { - return ( -
- Keine Test-Laeufe vorhanden -
- ) - } - - return ( -
- - - - - - - - - - - - - - {runs.map((run) => ( - - - - - - - - - - ))} - -
IDServiceZeitpunktTestsBestandenDauerStatus
{run.id.slice(-8)}{run.service} - {new Date(run.started_at).toLocaleString('de-DE')} - {run.total_tests} - {run.passed_tests} - / - {run.failed_tests} - - {run.duration_seconds.toFixed(1)}s - - - {run.status} - -
-
- ) -} - -// ============================================================================== -// Guide Tab -// ============================================================================== - -function GuideTab() { - return ( -
-
-

- - - - Was ist das Test Dashboard? -

-

- Das Test Dashboard ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System. - Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren. - Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt. - Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright). -

-
- -
-

Test-Kategorien

-
-
-
- 🐹 -

Go Unit Tests (~57)

-
-

- consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk -

-
-
-
- 🐍 -

Python Tests (~50)

-
-

- backend, voice-service, klausur-service, geo-service -

-
-
-
- 🎯 -

BQAS Golden (97)

-
-

- Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung -

-
-
-
- 📚 -

BQAS RAG (~20)

-
-

- RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control -

-
-
-
- 📘 -

TypeScript Jest (~8)

-
-

- Website Unit Tests fuer React-Komponenten -

-
-
-
- -

SDK Vitest (~43)

-
-

- AI Compliance SDK Unit Tests: Types, Export, Components, Reducer -

-
-
-
- 🎭 -

SDK Playwright (~25)

-
-

- SDK E2E Tests: Navigation, Workflow, Command Bar, Export -

-
-
-
- 🌐 -

Website E2E (~5)

-
-

- End-to-End Tests fuer kritische User Flows -

-
-
-
- 🔗 -

Integration Tests (~15)

-
-

- Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB -

-
-
-
- -
-

Architektur

-
-{`┌────────────────────────────────────────────────────────────────────┐
-│               Admin-v2 Test Dashboard                               │
-│               /infrastructure/tests                                 │
-├────────────────────────────────────────────────────────────────────┤
-│  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌─────────────┐  │
-│  │ Unit Tests │  │ SDK Tests  │  │   BQAS     │  │ E2E Tests   │  │
-│  │  (Go, Py)  │  │  (Vitest)  │  │ (LLM/RAG)  │  │ (Playwright)│  │
-│  └────────────┘  └────────────┘  └────────────┘  └─────────────┘  │
-│        │               │               │               │           │
-│        ▼               ▼               ▼               ▼           │
-│  ┌──────────────────────────────────────────────────────────────┐ │
-│  │                    Test Registry API                          │ │
-│  │              /backend/api/tests/registry.py                   │ │
-│  └──────────────────────────────────────────────────────────────┘ │
-└────────────────────────────────────────────────────────────────────┘
-
-Tests bleiben wo sie sind:
-- /consent-service/internal/**/*_test.go
-- /backend/tests/test_*.py
-- /voice-service/tests/bqas/
-- /admin-v2/components/sdk/__tests__/*.test.ts   (Vitest)
-- /admin-v2/e2e/specs/*.spec.ts                  (Playwright)`}
-        
-
- - {/* CI/CD Workflow Anleitung */} -
-

- - - - CI/CD Integration -

- -
-
-

🤖 Automatisch (bei jedem Push/PR)

-
    -
  • - - Unit Tests - Go & Python Tests laufen automatisch -
  • -
  • - - Test-Ergebnisse - Werden ans Dashboard gesendet -
  • -
  • - - Backlog - Fehlgeschlagene Tests erscheinen hier -
  • -
  • - - Linting - Code-Qualitaet bei PRs pruefen -
  • -
-
- -
-

👆 Manuell (Button oder Tag)

-
    -
  • - - Docker Builds - Container erstellen -
  • -
  • - - SBOM/Scans - Sicherheitsanalyse ausfuehren -
  • -
  • - - Deployment - In Produktion deployen -
  • -
  • - - Pipeline starten - Im CI/CD Dashboard -
  • -
-
-
- -
-

- Daten-Fluss: Gitea Actions → POST /api/tests/ci-result → PostgreSQL → Test Dashboard -

-
-
- -
- -
- - - -
-

BQAS Dashboard

-

Detaillierte BQAS-Metriken und Trend-Analyse

-
-
- - -
- - - -
-

CI/CD Pipelines

-

Gitea Actions und automatische Test-Planung

-
-
- -
-
- ) -} - -// ============================================================================== -// Backlog Component -// ============================================================================== - -function FailedTestCard({ - test, - onStatusChange, - onPriorityChange, - priority = 'medium', - failureCount = 1, -}: { - test: FailedTest - onStatusChange: (testId: string, status: string) => void - onPriorityChange?: (testId: string, priority: string) => void - priority?: BacklogPriority - failureCount?: number -}) { - const errorTypeColors: Record = { - assertion: 'bg-amber-100 text-amber-700', - nil_pointer: 'bg-red-100 text-red-700', - type_error: 'bg-purple-100 text-purple-700', - network: 'bg-blue-100 text-blue-700', - timeout: 'bg-orange-100 text-orange-700', - logic_error: 'bg-slate-100 text-slate-700', - unknown: 'bg-slate-100 text-slate-700', - } - - const statusColors: Record = { - open: 'bg-red-100 text-red-700', - in_progress: 'bg-blue-100 text-blue-700', - fixed: 'bg-emerald-100 text-emerald-700', - wont_fix: 'bg-slate-100 text-slate-700', - flaky: 'bg-purple-100 text-purple-700', - } - - const priorityColors: Record = { - critical: 'bg-red-500 text-white', - high: 'bg-orange-500 text-white', - medium: 'bg-yellow-500 text-white', - low: 'bg-slate-400 text-white', - } - - const priorityLabels: Record = { - critical: '!!! Kritisch', - high: '!! Hoch', - medium: '! Mittel', - low: 'Niedrig', - } - - return ( -
-
-
-
- - {priorityLabels[priority]} - - - {test.error_type.replace('_', ' ')} - - {test.service} - {failureCount > 1 && ( - - {failureCount}x fehlgeschlagen - - )} -
-

- {test.name} -

-

- {test.file_path} -

-
-
- - {onPriorityChange && ( - - )} -
-
- -
-

Fehlermeldung:

-

- {test.error_message || 'Keine Details verfuegbar'} -

-
- - {test.suggestion && ( -
-

💡 Loesungsvorschlag:

-

- {test.suggestion} -

-
- )} - -
- Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'} - -
-
- ) -} - -function BacklogTab({ - failedTests, - onStatusChange, - onPriorityChange, - isLoading, - backlogItems, - usePostgres = false, -}: { - failedTests: FailedTest[] - onStatusChange: (testId: string, status: string) => void - onPriorityChange?: (testId: string, priority: string) => void - isLoading: boolean - backlogItems?: BacklogItem[] - usePostgres?: boolean -}) { - const [filterStatus, setFilterStatus] = useState('open') - const [filterService, setFilterService] = useState('all') - const [filterPriority, setFilterPriority] = useState('all') - const [llmAutoAnalysis, setLlmAutoAnalysis] = useState(true) - const [llmRouting, setLlmRouting] = useState('smart_routing') - - // Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy - const items = usePostgres && backlogItems ? backlogItems : failedTests - - // Gruppiere nach Service - const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))] - - // Filtere Items - const filteredItems = items.filter(item => { - const status = 'status' in item ? item.status : 'open' - const service = 'service' in item ? item.service : '' - const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium' - - if (filterStatus !== 'all' && status !== filterStatus) return false - if (filterService !== 'all' && service !== filterService) return false - if (filterPriority !== 'all' && priority !== filterPriority) return false - return true - }) - - // Zaehle nach Status - const openCount = items.filter(t => t.status === 'open').length - const inProgressCount = items.filter(t => t.status === 'in_progress').length - const fixedCount = items.filter(t => t.status === 'fixed').length - const flakyCount = items.filter(t => t.status === 'flaky').length - - // Zaehle nach Prioritaet (nur bei PostgreSQL) - const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0 - const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0 - - if (isLoading) { - return ( -
-
-
- ) - } - - // Konvertiere BacklogItem zu FailedTest fuer die Anzeige - const convertToFailedTest = (item: BacklogItem): FailedTest => ({ - id: String(item.id), - name: item.test_name, - service: item.service, - file_path: item.test_file || '', - error_message: item.error_message || '', - error_type: item.error_type || 'unknown', - suggestion: item.fix_suggestion || '', - run_id: '', - last_failed: item.last_failed_at, - status: item.status, - }) - - return ( -
- {/* Stats */} -
-
-

{openCount}

-

Offene Fehler

-
-
-

{inProgressCount}

-

In Arbeit

-
-
-

{fixedCount}

-

Behoben

-
-
-

{flakyCount}

-

Flaky

-
- {usePostgres && criticalCount + highCount > 0 && ( -
-

{criticalCount + highCount}

-

Kritisch/Hoch

-
- )} -
- - {/* PostgreSQL Badge */} - {usePostgres && ( -
- - - - Persistente Speicherung aktiv (PostgreSQL) -
- )} - - {/* LLM Analysis Toggle */} -
-
-
-
- - - -
-
-

Automatische LLM-Analyse

-

KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege

-
-
- -
- - {llmAutoAnalysis && ( -
-

LLM-Routing Strategie:

-
- - - -
-

- {llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'} - {llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'} - {llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'} -

-
- )} -
- - {/* Filter */} -
-
- - -
-
- - -
- {usePostgres && ( -
- - -
- )} -
- {filteredItems.length} von {items.length} Tests angezeigt -
-
- - {/* Test-Liste */} - {filteredItems.length === 0 ? ( -
- - - -

- {filterStatus === 'open' ? 'Keine offenen Fehler! 🎉' : 'Keine Tests mit diesem Filter gefunden.'} -

- {filterStatus === 'open' && ( -

- Alle Tests bestanden. Bereit fuer Go-Live! -

- )} -
- ) : ( -
- {filteredItems.map((item) => { - const test = usePostgres && 'test_name' in item - ? convertToFailedTest(item as BacklogItem) - : item as FailedTest - const priority = usePostgres && 'priority' in item - ? (item as BacklogItem).priority - : 'medium' - const failureCount = usePostgres && 'failure_count' in item - ? (item as BacklogItem).failure_count - : 1 - - return ( - - ) - })} -
- )} - - {/* Info */} -
-
- - - -
-

Workflow fuer fehlgeschlagene Tests:

-
    -
  1. Markiere den Test als "In Arbeit" wenn du daran arbeitest
  2. -
  3. Analysiere die Fehlermeldung und den Loesungsvorschlag
  4. -
  5. Behebe den Fehler im Code
  6. -
  7. Fuehre den Test erneut aus (Button im Service-Tab)
  8. -
  9. Markiere als "Behoben" wenn der Test besteht
  10. - {usePostgres &&
  11. Setze "Flaky" fuer sporadisch fehlschlagende Tests
  12. } -
-
-
-
-
- ) -} - -// ============================================================================== -// Main Component -// ============================================================================== +import { ToastContainer } from './_components/ToastContainer' +import { MetricCard } from './_components/MetricCard' +import { ServiceTestCard } from './_components/ServiceTestCard' +import { CoverageChart } from './_components/CoverageChart' +import { FrameworkDistribution } from './_components/FrameworkDistribution' +import { TestRunsTable } from './_components/TestRunsTable' +import { GuideTab } from './_components/GuideTab' +import { BacklogTab } from './_components/BacklogTab' +import { useTestDashboard } from './_hooks/useTestDashboard' export default function TestDashboardPage() { - const [activeTab, setActiveTab] = useState('overview') - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - // Toast state - const [toasts, setToasts] = useState([]) - const toastIdRef = useRef(0) - - const addToast = useCallback((type: Toast['type'], message: string) => { - const id = ++toastIdRef.current - setToasts((prev) => [...prev, { id, type, message }]) - if (type !== 'loading') { - setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - }, 5000) - } - return id - }, []) - - const removeToast = useCallback((id: number) => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - }, []) - - const updateToast = useCallback((id: number, type: Toast['type'], message: string) => { - setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, type, message } : t))) - if (type !== 'loading') { - setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - }, 5000) - } - }, []) - - // Data states - const [services, setServices] = useState([]) - const [stats, setStats] = useState(null) - const [coverage, setCoverage] = useState([]) - const [testRuns, setTestRuns] = useState([]) - const [failedTests, setFailedTests] = useState([]) - const [backlogItems, setBacklogItems] = useState([]) - const [usePostgres, setUsePostgres] = useState(false) - - // Running states - const [runningServices, setRunningServices] = useState>(new Set()) - - // Progress states fuer laufende Tests - const [serviceProgress, setServiceProgress] = useState>({}) - - // Demo data for when API is not available - const DEMO_SERVICES: ServiceTestInfo[] = [ - { service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' }, - { service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' }, - { service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' }, - { service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' }, - { service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' }, - { service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' }, - { service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' }, - { service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' }, - ] - - const DEMO_STATS: TestRegistryStats = { - total_tests: 278, - total_passed: 263, - total_failed: 15, - total_skipped: 0, - overall_pass_rate: 94.6, - average_coverage: 78.5, - services_count: 11, - by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 }, - by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 }, - } - - // Fetch data - const fetchData = useCallback(async () => { - setIsLoading(true) - setError(null) - - try { - const registryResponse = await fetch(`${API_BASE}/registry`) - if (registryResponse.ok) { - const data = await registryResponse.json() - setServices(data.services || DEMO_SERVICES) - setStats(data.stats || DEMO_STATS) - } else { - setServices(DEMO_SERVICES) - setStats(DEMO_STATS) - } - - const coverageResponse = await fetch(`${API_BASE}/coverage`) - if (coverageResponse.ok) { - const data = await coverageResponse.json() - setCoverage(data.services || []) - } else { - setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({ - service: s.service, - display_name: s.display_name, - coverage_percent: s.coverage_percent!, - language: s.language, - }))) - } - - const runsResponse = await fetch(`${API_BASE}/runs`) - if (runsResponse.ok) { - const data = await runsResponse.json() - setTestRuns(data.runs || []) - } - - // Lade fehlgeschlagene Tests fuer Backlog - const failedResponse = await fetch(`${API_BASE}/failed`) - if (failedResponse.ok) { - const data = await failedResponse.json() - setFailedTests(data.tests || []) - } - - // Versuche PostgreSQL-Backlog zu laden (neue API) - try { - const backlogResponse = await fetch(`${API_BASE}/backlog`) - if (backlogResponse.ok) { - const data = await backlogResponse.json() - if (data.items && data.items.length > 0) { - setBacklogItems(data.items) - setUsePostgres(true) - } - } - } catch { - // PostgreSQL nicht verfuegbar, nutze Legacy - setUsePostgres(false) - } - - } catch (err) { - console.error('Failed to fetch test registry data:', err) - setServices(DEMO_SERVICES) - setStats(DEMO_STATS) - setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({ - service: s.service, - display_name: s.display_name, - coverage_percent: s.coverage_percent!, - language: s.language, - }))) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchData() - }, [fetchData]) - - // Update failed test status - const updateTestStatus = async (testId: string, status: string) => { - try { - // Nutze PostgreSQL-Endpoint wenn verfuegbar - const endpoint = usePostgres - ? `${API_BASE}/backlog/${testId}/status` - : `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}` - - const response = await fetch(endpoint, { - method: 'POST', - headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined, - body: usePostgres ? JSON.stringify({ status }) : undefined, - }) - - if (response.ok) { - // Aktualisiere lokalen State - if (usePostgres) { - setBacklogItems(prev => - prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t) - ) - } - setFailedTests(prev => - prev.map(t => t.id === testId ? { ...t, status: status as any } : t) - ) - addToast('success', `Test-Status auf "${status}" gesetzt`) - } - } catch (err) { - console.error('Failed to update test status:', err) - // Trotzdem lokal aktualisieren fuer bessere UX - setFailedTests(prev => - prev.map(t => t.id === testId ? { ...t, status: status as any } : t) - ) - if (usePostgres) { - setBacklogItems(prev => - prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t) - ) - } - } - } - - // Update failed test priority (nur PostgreSQL) - const updateTestPriority = async (testId: string, priority: string) => { - if (!usePostgres) return - - try { - const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ priority }), - }) - - if (response.ok) { - setBacklogItems(prev => - prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t) - ) - addToast('success', `Prioritaet auf "${priority}" gesetzt`) - } - } catch (err) { - console.error('Failed to update test priority:', err) - // Trotzdem lokal aktualisieren - setBacklogItems(prev => - prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t) - ) - } - } - - // Run tests mit Progress-Polling - const runTests = async (service: string) => { - setRunningServices((prev) => new Set(prev).add(service)) - const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`) - - // Progress-Polling starten - let pollInterval: NodeJS.Timeout | null = null - const pollProgress = async () => { - try { - const progressResponse = await fetch(`${API_BASE}/progress/${service}`) - if (progressResponse.ok) { - const progress = await progressResponse.json() - setServiceProgress((prev) => ({ - ...prev, - [service]: progress, - })) - - // Toast-Message mit aktuellem Fortschritt aktualisieren - if (progress.status === 'running' && progress.files_total > 0) { - const percent = Math.round((progress.files_done / progress.files_total) * 100) - const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)` - updateToast(loadingToast, 'loading', toastMsg) - } - } - } catch (err) { - // Ignore polling errors - } - } - - // Start polling (alle 1 Sekunde) - pollInterval = setInterval(pollProgress, 1000) - - try { - const response = await fetch(`${API_BASE}/run/${service}`, { - method: 'POST', - }) - - if (response.ok) { - const result = await response.json() - // Warte kurz und pruefe finalen Progress - await new Promise(resolve => setTimeout(resolve, 500)) - await pollProgress() - const finalProgress = serviceProgress[service] - const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen' - updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`) - await fetchData() - } else { - updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`) - } - } catch (err) { - console.error('Failed to run tests:', err) - updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`) - } finally { - // Polling stoppen - if (pollInterval) { - clearInterval(pollInterval) - } - setRunningServices((prev) => { - const next = new Set(prev) - next.delete(service) - return next - }) - // Progress-Daten entfernen nach Abschluss - setServiceProgress((prev) => { - const next = { ...prev } - delete next[service] - return next - }) - } - } + const { + activeTab, + setActiveTab, + isLoading, + error, + toasts, + removeToast, + services, + stats, + coverage, + testRuns, + failedTests, + backlogItems, + usePostgres, + runningServices, + serviceProgress, + fetchData, + updateTestStatus, + updateTestPriority, + runTests, + } = useTestDashboard() // Filter services by category const unitServices = services.filter(s => !s.service.startsWith('bqas-')) @@ -1429,31 +64,10 @@ export default function TestDashboardPage() { return (
- - - - + + + +
@@ -1461,7 +75,6 @@ export default function TestDashboardPage() {

Framework-Verteilung

-

Coverage nach Service

@@ -1472,13 +85,7 @@ export default function TestDashboardPage() {

Service-Uebersicht

{services.slice(0, 8).map((service) => ( - + ))}
@@ -1492,13 +99,7 @@ export default function TestDashboardPage() {

Unit Tests (Go & Python)

{unitServices.map((service) => ( - + ))}
@@ -1514,38 +115,25 @@ export default function TestDashboardPage() {

BQAS (LLM Quality Assurance)

Golden Suite, RAG Tests und Synthetic Tests

- + Vollstaendiges BQAS Dashboard →
-
{bqasServices.map((service) => ( - + ))}
-
-
-

- Tipp: Das vollstaendige BQAS Dashboard unter /ai/test-quality bietet - detaillierte Metriken, Trend-Analyse und Intent-spezifische Scores. -

-
+

+ Tipp: Das vollstaendige BQAS Dashboard unter /ai/test-quality bietet + detaillierte Metriken, Trend-Analyse und Intent-spezifische Scores. +

@@ -1561,14 +149,7 @@ export default function TestDashboardPage() { case 'backlog': return ( - + ) case 'guide': @@ -1601,7 +182,6 @@ export default function TestDashboardPage() { defaultCollapsed={true} /> - {/* DevOps Pipeline Sidebar */} {error && ( diff --git a/admin-core/app/(admin)/infrastructure/tests/types.ts b/admin-core/app/(admin)/infrastructure/tests/types.ts new file mode 100644 index 0000000..ed8e50d --- /dev/null +++ b/admin-core/app/(admin)/infrastructure/tests/types.ts @@ -0,0 +1,102 @@ +/** + * Types for Test Dashboard + */ + +export interface ServiceTestInfo { + service: string + display_name: string + port?: number + language: string + total_tests: number + passed_tests: number + failed_tests: number + skipped_tests: number + pass_rate: number + coverage_percent?: number + last_run: string + status: string +} + +export interface TestRegistryStats { + total_tests: number + total_passed: number + total_failed: number + total_skipped: number + overall_pass_rate: number + average_coverage: number + services_count: number + by_category: Record + by_framework: Record +} + +export interface TestRun { + id: string + service: string + started_at: string + total_tests: number + passed_tests: number + failed_tests: number + duration_seconds: number + status: string +} + +export interface CoverageData { + service: string + display_name: string + coverage_percent: number + language: string +} + +export type TabType = 'overview' | 'unit' | 'bqas' | 'history' | 'backlog' | 'guide' + +export interface Toast { + id: number + type: 'success' | 'error' | 'loading' | 'info' + message: string +} + +export interface FailedTest { + id: string + name: string + service: string + file_path: string + error_message: string + error_type: string + suggestion: string + run_id: string + last_failed: string + status: string +} + +export type BacklogPriority = 'critical' | 'high' | 'medium' | 'low' +export type BacklogStatus = 'open' | 'in_progress' | 'fixed' | 'wont_fix' | 'flaky' + +export interface BacklogItem { + id: number + test_name: string + service: string + test_file?: string + error_message?: string + error_type?: string + fix_suggestion?: string + priority: BacklogPriority + status: BacklogStatus + failure_count: number + last_failed_at: string +} + +export interface TrendDataPoint { + date: string + passed: number + failed: number + total: number +} + +export interface ServiceProgress { + current_file: string + files_done: number + files_total: number + passed: number + failed: number + status: string +} diff --git a/admin-core/components/infrastructure/DevOpsPipelineSidebar.tsx b/admin-core/components/infrastructure/DevOpsPipelineSidebar.tsx index 9dacc22..c82d106 100644 --- a/admin-core/components/infrastructure/DevOpsPipelineSidebar.tsx +++ b/admin-core/components/infrastructure/DevOpsPipelineSidebar.tsx @@ -20,108 +20,17 @@ import Link from 'next/link' import { useState, useEffect } from 'react' import type { - DevOpsToolId, DevOpsPipelineSidebarProps, DevOpsPipelineSidebarResponsiveProps, - PipelineLiveStatus, } from '@/types/infrastructure-modules' import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules' - -// ============================================================================= -// Icons -// ============================================================================= - -const ToolIcon = ({ id }: { id: DevOpsToolId }) => { - switch (id) { - case 'ci-cd': - return ( - - - - ) - case 'tests': - return ( - - - - ) - case 'sbom': - return ( - - - - ) - case 'security': - return ( - - - - ) - default: - return null - } -} - -// Server/Pipeline Icon fuer Header -const ServerIcon = () => ( - - - -) - -// Play Icon fuer Quick Action -const PlayIcon = () => ( - - - - -) - -// ============================================================================= -// Live Status Hook (optional - fetches status from API) -// ============================================================================= - -function usePipelineLiveStatus(): PipelineLiveStatus | null { - const [status, setStatus] = useState(null) - - useEffect(() => { - // Live status fetching not yet implemented - }, []) - - return status -} - -// ============================================================================= -// Status Badge Component -// ============================================================================= - -interface StatusBadgeProps { - count: number - type: 'backlog' | 'security' | 'running' -} - -function StatusBadge({ count, type }: StatusBadgeProps) { - if (count === 0) return null - - const colors = { - backlog: 'bg-amber-500', - security: 'bg-red-500', - running: 'bg-green-500 animate-pulse', - } - - return ( - - {count} - - ) -} +import { + ToolIcon, + ServerIcon, + PlayIcon, + StatusBadge, + usePipelineLiveStatus, +} from './DevOpsPipelineSidebarParts' // ============================================================================= // Main Sidebar Component diff --git a/admin-core/components/infrastructure/DevOpsPipelineSidebarParts.tsx b/admin-core/components/infrastructure/DevOpsPipelineSidebarParts.tsx new file mode 100644 index 0000000..f322265 --- /dev/null +++ b/admin-core/components/infrastructure/DevOpsPipelineSidebarParts.tsx @@ -0,0 +1,106 @@ +'use client' + +/** + * DevOps Pipeline Sidebar — shared icons, badge, and live-status hook. + * + * Extracted from DevOpsPipelineSidebar.tsx to stay within the 500 LOC budget. + */ + +import { useState, useEffect } from 'react' +import type { DevOpsToolId, PipelineLiveStatus } from '@/types/infrastructure-modules' + +// ============================================================================= +// Icons +// ============================================================================= + +export const ToolIcon = ({ id }: { id: DevOpsToolId }) => { + switch (id) { + case 'ci-cd': + return ( + + + + ) + case 'tests': + return ( + + + + ) + case 'sbom': + return ( + + + + ) + case 'security': + return ( + + + + ) + default: + return null + } +} + +// Server/Pipeline Icon fuer Header +export const ServerIcon = () => ( + + + +) + +// Play Icon fuer Quick Action +export const PlayIcon = () => ( + + + + +) + +// ============================================================================= +// Live Status Hook (optional - fetches status from API) +// ============================================================================= + +export function usePipelineLiveStatus(): PipelineLiveStatus | null { + const [status, setStatus] = useState(null) + + useEffect(() => { + // Live status fetching not yet implemented + }, []) + + return status +} + +// ============================================================================= +// Status Badge Component +// ============================================================================= + +interface StatusBadgeProps { + count: number + type: 'backlog' | 'security' | 'running' +} + +export function StatusBadge({ count, type }: StatusBadgeProps) { + if (count === 0) return null + + const colors = { + backlog: 'bg-amber-500', + security: 'bg-red-500', + running: 'bg-green-500 animate-pulse', + } + + return ( + + {count} + + ) +} diff --git a/backend-core/auth/__init__.py b/backend-core/auth/__init__.py index b56b38b..05c4255 100644 --- a/backend-core/auth/__init__.py +++ b/backend-core/auth/__init__.py @@ -5,7 +5,7 @@ Hybrid authentication supporting both Keycloak and local JWT tokens. """ from .keycloak_auth import ( - # Config + # Config & Models KeycloakConfig, KeycloakUser, @@ -18,7 +18,9 @@ from .keycloak_auth import ( TokenExpiredError, TokenInvalidError, KeycloakConfigError, +) +from .dependencies import ( # Factory functions get_keycloak_config_from_env, get_authenticator, @@ -30,7 +32,7 @@ from .keycloak_auth import ( ) __all__ = [ - # Config + # Config & Models "KeycloakConfig", "KeycloakUser", diff --git a/backend-core/auth/dependencies.py b/backend-core/auth/dependencies.py new file mode 100644 index 0000000..51a876a --- /dev/null +++ b/backend-core/auth/dependencies.py @@ -0,0 +1,164 @@ +""" +FastAPI Authentication Dependencies and Factory Functions. + +Provides: +- get_keycloak_config_from_env(): Create config from env vars +- get_authenticator(): Create HybridAuthenticator instance +- get_auth(): Global authenticator singleton +- get_current_user(): FastAPI dependency for authentication +- require_role(): FastAPI dependency factory for role-based access +""" + +import os +import logging +from typing import Optional, Dict, Any + +from fastapi import Request, HTTPException, Depends + +from .keycloak_auth import ( + KeycloakConfig, + KeycloakConfigError, + HybridAuthenticator, + TokenExpiredError, + TokenInvalidError, +) + +logger = logging.getLogger(__name__) + + +# ============================================= +# FACTORY FUNCTIONS +# ============================================= + +def get_keycloak_config_from_env() -> Optional[KeycloakConfig]: + """ + Create KeycloakConfig from environment variables. + + Required env vars: + - KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app + - KEYCLOAK_REALM: e.g., breakpilot + - KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend + + Optional: + - KEYCLOAK_CLIENT_SECRET: For confidential clients + - KEYCLOAK_VERIFY_SSL: Default true + """ + server_url = os.environ.get("KEYCLOAK_SERVER_URL") + realm = os.environ.get("KEYCLOAK_REALM") + client_id = os.environ.get("KEYCLOAK_CLIENT_ID") + + if not all([server_url, realm, client_id]): + logger.info("Keycloak not configured, using local JWT only") + return None + + return KeycloakConfig( + server_url=server_url, + realm=realm, + client_id=client_id, + client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"), + verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true" + ) + + +def get_authenticator() -> HybridAuthenticator: + """ + Get configured authenticator instance. + + Uses environment variables to determine configuration. + """ + keycloak_config = get_keycloak_config_from_env() + + # JWT_SECRET is required - no default fallback in production + jwt_secret = os.environ.get("JWT_SECRET") + environment = os.environ.get("ENVIRONMENT", "development") + + if not jwt_secret and environment == "production": + raise KeycloakConfigError( + "JWT_SECRET environment variable is required in production" + ) + + return HybridAuthenticator( + keycloak_config=keycloak_config, + local_jwt_secret=jwt_secret, + environment=environment + ) + + +# ============================================= +# FASTAPI DEPENDENCY +# ============================================= + +# Global authenticator instance (lazy-initialized) +_authenticator: Optional[HybridAuthenticator] = None + + +def get_auth() -> HybridAuthenticator: + """Get or create global authenticator.""" + global _authenticator + if _authenticator is None: + _authenticator = get_authenticator() + return _authenticator + + +async def get_current_user(request: Request) -> Dict[str, Any]: + """ + FastAPI dependency to get current authenticated user. + + Usage: + @app.get("/api/protected") + async def protected_endpoint(user: dict = Depends(get_current_user)): + return {"user_id": user["user_id"]} + """ + auth_header = request.headers.get("authorization", "") + + if not auth_header.startswith("Bearer "): + # Check for development mode + environment = os.environ.get("ENVIRONMENT", "development") + if environment == "development": + # Return demo user in development without token + return { + "user_id": "10000000-0000-0000-0000-000000000024", + "email": "demo@breakpilot.app", + "role": "admin", + "realm_roles": ["admin"], + "tenant_id": "a0000000-0000-0000-0000-000000000001", + "auth_method": "development_bypass" + } + raise HTTPException(status_code=401, detail="Missing authorization header") + + token = auth_header.split(" ")[1] + + try: + auth = get_auth() + return await auth.validate_token(token) + except TokenExpiredError: + raise HTTPException(status_code=401, detail="Token expired") + except TokenInvalidError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + logger.error(f"Authentication failed: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +async def require_role(required_role: str): + """ + FastAPI dependency factory for role-based access. + + Usage: + @app.get("/api/admin-only") + async def admin_endpoint(user: dict = Depends(require_role("admin"))): + return {"message": "Admin access granted"} + """ + async def role_checker(user: dict = Depends(get_current_user)) -> dict: + user_role = user.get("role", "user") + realm_roles = user.get("realm_roles", []) + + if user_role == required_role or required_role in realm_roles: + return user + + raise HTTPException( + status_code=403, + detail=f"Role '{required_role}' required" + ) + + return role_checker diff --git a/backend-core/auth/keycloak_auth.py b/backend-core/auth/keycloak_auth.py index 3449169..9e5e585 100644 --- a/backend-core/auth/keycloak_auth.py +++ b/backend-core/auth/keycloak_auth.py @@ -375,141 +375,12 @@ class HybridAuthenticator: await self.keycloak_auth.close() -# ============================================= -# FACTORY FUNCTIONS -# ============================================= - -def get_keycloak_config_from_env() -> Optional[KeycloakConfig]: - """ - Create KeycloakConfig from environment variables. - - Required env vars: - - KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app - - KEYCLOAK_REALM: e.g., breakpilot - - KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend - - Optional: - - KEYCLOAK_CLIENT_SECRET: For confidential clients - - KEYCLOAK_VERIFY_SSL: Default true - """ - server_url = os.environ.get("KEYCLOAK_SERVER_URL") - realm = os.environ.get("KEYCLOAK_REALM") - client_id = os.environ.get("KEYCLOAK_CLIENT_ID") - - if not all([server_url, realm, client_id]): - logger.info("Keycloak not configured, using local JWT only") - return None - - return KeycloakConfig( - server_url=server_url, - realm=realm, - client_id=client_id, - client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"), - verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true" - ) - - -def get_authenticator() -> HybridAuthenticator: - """ - Get configured authenticator instance. - - Uses environment variables to determine configuration. - """ - keycloak_config = get_keycloak_config_from_env() - - # JWT_SECRET is required - no default fallback in production - jwt_secret = os.environ.get("JWT_SECRET") - environment = os.environ.get("ENVIRONMENT", "development") - - if not jwt_secret and environment == "production": - raise KeycloakConfigError( - "JWT_SECRET environment variable is required in production" - ) - - return HybridAuthenticator( - keycloak_config=keycloak_config, - local_jwt_secret=jwt_secret, - environment=environment - ) - - -# ============================================= -# FASTAPI DEPENDENCY -# ============================================= - -from fastapi import Request, HTTPException, Depends - -# Global authenticator instance (lazy-initialized) -_authenticator: Optional[HybridAuthenticator] = None - - -def get_auth() -> HybridAuthenticator: - """Get or create global authenticator.""" - global _authenticator - if _authenticator is None: - _authenticator = get_authenticator() - return _authenticator - - -async def get_current_user(request: Request) -> Dict[str, Any]: - """ - FastAPI dependency to get current authenticated user. - - Usage: - @app.get("/api/protected") - async def protected_endpoint(user: dict = Depends(get_current_user)): - return {"user_id": user["user_id"]} - """ - auth_header = request.headers.get("authorization", "") - - if not auth_header.startswith("Bearer "): - # Check for development mode - environment = os.environ.get("ENVIRONMENT", "development") - if environment == "development": - # Return demo user in development without token - return { - "user_id": "10000000-0000-0000-0000-000000000024", - "email": "demo@breakpilot.app", - "role": "admin", - "realm_roles": ["admin"], - "tenant_id": "a0000000-0000-0000-0000-000000000001", - "auth_method": "development_bypass" - } - raise HTTPException(status_code=401, detail="Missing authorization header") - - token = auth_header.split(" ")[1] - - try: - auth = get_auth() - return await auth.validate_token(token) - except TokenExpiredError: - raise HTTPException(status_code=401, detail="Token expired") - except TokenInvalidError as e: - raise HTTPException(status_code=401, detail=str(e)) - except Exception as e: - logger.error(f"Authentication failed: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") - - -async def require_role(required_role: str): - """ - FastAPI dependency factory for role-based access. - - Usage: - @app.get("/api/admin-only") - async def admin_endpoint(user: dict = Depends(require_role("admin"))): - return {"message": "Admin access granted"} - """ - async def role_checker(user: dict = Depends(get_current_user)) -> dict: - user_role = user.get("role", "user") - realm_roles = user.get("realm_roles", []) - - if user_role == required_role or required_role in realm_roles: - return user - - raise HTTPException( - status_code=403, - detail=f"Role '{required_role}' required" - ) - - return role_checker +# Re-export factory functions and FastAPI dependencies from dependencies module +# for backward compatibility with existing imports +from .dependencies import ( # noqa: E402, F401 + get_keycloak_config_from_env, + get_authenticator, + get_auth, + get_current_user, + require_role, +) diff --git a/backend-core/main.py b/backend-core/main.py index 64383bb..2d0086f 100644 --- a/backend-core/main.py +++ b/backend-core/main.py @@ -18,6 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware # --------------------------------------------------------------------------- from auth_api import router as auth_router from rbac_api import router as rbac_router +from rbac_teachers_api import router as rbac_teachers_router from notification_api import router as notification_router from email_template_api import ( router as email_template_router, @@ -89,9 +90,12 @@ app.add_middleware(RateLimiterMiddleware, valkey_url=VALKEY_URL) # Auth (proxy to consent-service) app.include_router(auth_router, prefix="/api") -# RBAC (teacher / role management) +# RBAC (role / assignment / custom-role management) app.include_router(rbac_router, prefix="/api") +# RBAC Teachers (teacher CRUD, listing, roles per teacher) +app.include_router(rbac_teachers_router, prefix="/api") + # Notifications (proxy to consent-service) app.include_router(notification_router, prefix="/api") diff --git a/backend-core/rbac_api.py b/backend-core/rbac_api.py index 3ba257b..c023cdc 100644 --- a/backend-core/rbac_api.py +++ b/backend-core/rbac_api.py @@ -1,11 +1,14 @@ """ -RBAC API - Teacher and Role Management Endpoints +RBAC API - Role and Assignment Management Endpoints Provides API endpoints for: -- Listing all teachers -- Listing all available roles -- Assigning/revoking roles to teachers -- Viewing role assignments per teacher +- Listing all available roles (built-in + custom) +- Assigning/revoking roles to users +- Role summary with assignment counts +- Custom role CRUD + +Shared infrastructure (DB pool, Pydantic models, role definitions) +used by rbac_teachers_api.py as well. Architecture: - Authentication: Keycloak (when configured) or local JWT @@ -230,163 +233,6 @@ async def list_available_roles() -> List[RoleInfo]: ] -@router.get("/teachers") -async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]: - """List all teachers with their current roles""" - pool = await get_pool() - - async with pool.acquire() as conn: - # Get all teachers with their user info - teachers = await conn.fetch(""" - SELECT - t.id, t.user_id, t.teacher_code, t.title, - t.first_name, t.last_name, t.is_active, - u.email, u.name - FROM teachers t - JOIN users u ON t.user_id = u.id - WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001' - ORDER BY t.last_name, t.first_name - """) - - # Get role assignments for all teachers - role_assignments = await conn.fetch(""" - SELECT user_id, role - FROM role_assignments - WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001' - AND revoked_at IS NULL - AND (valid_to IS NULL OR valid_to > NOW()) - """) - - # Build role lookup - role_lookup: Dict[str, List[str]] = {} - for ra in role_assignments: - uid = str(ra["user_id"]) - if uid not in role_lookup: - role_lookup[uid] = [] - role_lookup[uid].append(ra["role"]) - - # Build response - result = [] - for t in teachers: - uid = str(t["user_id"]) - result.append(TeacherResponse( - id=str(t["id"]), - user_id=uid, - email=t["email"], - name=t["name"] or f"{t['first_name']} {t['last_name']}", - teacher_code=t["teacher_code"], - title=t["title"], - first_name=t["first_name"], - last_name=t["last_name"], - is_active=t["is_active"], - roles=role_lookup.get(uid, []) - )) - - return result - - -@router.get("/teachers/{teacher_id}/roles") -async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]: - """Get all role assignments for a specific teacher""" - pool = await get_pool() - - async with pool.acquire() as conn: - # Get teacher's user_id - teacher = await conn.fetchrow( - "SELECT user_id FROM teachers WHERE id = $1", - teacher_id - ) - if not teacher: - raise HTTPException(status_code=404, detail="Teacher not found") - - # Get role assignments - assignments = await conn.fetch(""" - SELECT id, user_id, role, resource_type, resource_id, - valid_from, valid_to, granted_at, revoked_at - FROM role_assignments - WHERE user_id = $1 - ORDER BY granted_at DESC - """, teacher["user_id"]) - - return [ - RoleAssignmentResponse( - id=str(a["id"]), - user_id=str(a["user_id"]), - role=a["role"], - resource_type=a["resource_type"], - resource_id=str(a["resource_id"]), - valid_from=a["valid_from"].isoformat() if a["valid_from"] else None, - valid_to=a["valid_to"].isoformat() if a["valid_to"] else None, - granted_at=a["granted_at"].isoformat() if a["granted_at"] else None, - is_active=a["revoked_at"] is None and ( - a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc) - ) - ) - for a in assignments - ] - - -@router.get("/roles/{role}/teachers") -async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]: - """Get all teachers with a specific role""" - if role not in AVAILABLE_ROLES: - raise HTTPException(status_code=400, detail=f"Unknown role: {role}") - - pool = await get_pool() - - async with pool.acquire() as conn: - teachers = await conn.fetch(""" - SELECT DISTINCT - t.id, t.user_id, t.teacher_code, t.title, - t.first_name, t.last_name, t.is_active, - u.email, u.name - FROM teachers t - JOIN users u ON t.user_id = u.id - JOIN role_assignments ra ON t.user_id = ra.user_id - WHERE ra.role = $1 - AND ra.revoked_at IS NULL - AND (ra.valid_to IS NULL OR ra.valid_to > NOW()) - AND t.school_id = 'a0000000-0000-0000-0000-000000000001' - ORDER BY t.last_name, t.first_name - """, role) - - # Get all roles for these teachers - if teachers: - user_ids = [t["user_id"] for t in teachers] - role_assignments = await conn.fetch(""" - SELECT user_id, role - FROM role_assignments - WHERE user_id = ANY($1) - AND revoked_at IS NULL - AND (valid_to IS NULL OR valid_to > NOW()) - """, user_ids) - - role_lookup: Dict[str, List[str]] = {} - for ra in role_assignments: - uid = str(ra["user_id"]) - if uid not in role_lookup: - role_lookup[uid] = [] - role_lookup[uid].append(ra["role"]) - else: - role_lookup = {} - - return [ - TeacherResponse( - id=str(t["id"]), - user_id=str(t["user_id"]), - email=t["email"], - name=t["name"] or f"{t['first_name']} {t['last_name']}", - teacher_code=t["teacher_code"], - title=t["title"], - first_name=t["first_name"], - last_name=t["last_name"], - is_active=t["is_active"], - roles=role_lookup.get(str(t["user_id"]), []) - ) - for t in teachers - ] - - @router.post("/assignments") async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse: """Assign a role to a user""" @@ -519,178 +365,6 @@ async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) -> } -# ========================================== -# TEACHER MANAGEMENT ENDPOINTS -# ========================================== - -@router.post("/teachers") -async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse: - """Create a new teacher with optional initial roles""" - pool = await get_pool() - - import uuid - - async with pool.acquire() as conn: - # Check if email already exists - existing = await conn.fetchrow( - "SELECT id FROM users WHERE email = $1", - teacher.email - ) - if existing: - raise HTTPException(status_code=409, detail="Email already exists") - - # Generate UUIDs - user_id = str(uuid.uuid4()) - teacher_id = str(uuid.uuid4()) - - # Create user first - await conn.execute(""" - INSERT INTO users (id, email, name, password_hash, role, is_active) - VALUES ($1, $2, $3, '', 'teacher', true) - """, user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}") - - # Create teacher record - await conn.execute(""" - INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active) - VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true) - """, teacher_id, user_id, teacher.first_name, teacher.last_name, - teacher.teacher_code, teacher.title) - - # Assign initial roles if provided - assigned_roles = [] - for role in teacher.roles: - if role in AVAILABLE_ROLES or await conn.fetchrow( - "SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role - ): - await conn.execute(""" - INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by) - VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001', - 'a0000000-0000-0000-0000-000000000001', $3) - """, user_id, role, user.get("user_id")) - assigned_roles.append(role) - - return TeacherResponse( - id=teacher_id, - user_id=user_id, - email=teacher.email, - name=f"{teacher.first_name} {teacher.last_name}", - teacher_code=teacher.teacher_code, - title=teacher.title, - first_name=teacher.first_name, - last_name=teacher.last_name, - is_active=True, - roles=assigned_roles - ) - - -@router.put("/teachers/{teacher_id}") -async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse: - """Update teacher information""" - pool = await get_pool() - - async with pool.acquire() as conn: - # Get current teacher data - teacher = await conn.fetchrow(""" - SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active, - u.email, u.name - FROM teachers t - JOIN users u ON t.user_id = u.id - WHERE t.id = $1 - """, teacher_id) - - if not teacher: - raise HTTPException(status_code=404, detail="Teacher not found") - - # Build update queries - if updates.email: - await conn.execute("UPDATE users SET email = $1 WHERE id = $2", - updates.email, teacher["user_id"]) - - teacher_updates = [] - teacher_values = [] - idx = 1 - - if updates.first_name: - teacher_updates.append(f"first_name = ${idx}") - teacher_values.append(updates.first_name) - idx += 1 - if updates.last_name: - teacher_updates.append(f"last_name = ${idx}") - teacher_values.append(updates.last_name) - idx += 1 - if updates.teacher_code is not None: - teacher_updates.append(f"teacher_code = ${idx}") - teacher_values.append(updates.teacher_code) - idx += 1 - if updates.title is not None: - teacher_updates.append(f"title = ${idx}") - teacher_values.append(updates.title) - idx += 1 - if updates.is_active is not None: - teacher_updates.append(f"is_active = ${idx}") - teacher_values.append(updates.is_active) - idx += 1 - - if teacher_updates: - teacher_values.append(teacher_id) - await conn.execute( - f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}", - *teacher_values - ) - - # Update user name if first/last name changed - if updates.first_name or updates.last_name: - new_first = updates.first_name or teacher["first_name"] - new_last = updates.last_name or teacher["last_name"] - await conn.execute("UPDATE users SET name = $1 WHERE id = $2", - f"{new_first} {new_last}", teacher["user_id"]) - - # Fetch updated data - updated = await conn.fetchrow(""" - SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active, - u.email, u.name - FROM teachers t - JOIN users u ON t.user_id = u.id - WHERE t.id = $1 - """, teacher_id) - - # Get roles - roles = await conn.fetch(""" - SELECT role FROM role_assignments - WHERE user_id = $1 AND revoked_at IS NULL - AND (valid_to IS NULL OR valid_to > NOW()) - """, updated["user_id"]) - - return TeacherResponse( - id=str(updated["id"]), - user_id=str(updated["user_id"]), - email=updated["email"], - name=updated["name"], - teacher_code=updated["teacher_code"], - title=updated["title"], - first_name=updated["first_name"], - last_name=updated["last_name"], - is_active=updated["is_active"], - roles=[r["role"] for r in roles] - ) - - -@router.delete("/teachers/{teacher_id}") -async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)): - """Deactivate a teacher (soft delete)""" - pool = await get_pool() - - async with pool.acquire() as conn: - result = await conn.execute(""" - UPDATE teachers SET is_active = false WHERE id = $1 - """, teacher_id) - - if result == "UPDATE 0": - raise HTTPException(status_code=404, detail="Teacher not found") - - return {"status": "deactivated", "teacher_id": teacher_id} - - # ========================================== # CUSTOM ROLE MANAGEMENT ENDPOINTS # ========================================== diff --git a/backend-core/rbac_teachers_api.py b/backend-core/rbac_teachers_api.py new file mode 100644 index 0000000..4babbb0 --- /dev/null +++ b/backend-core/rbac_teachers_api.py @@ -0,0 +1,358 @@ +""" +RBAC Teachers API - Teacher Management Endpoints + +Provides API endpoints for: +- Listing all teachers with roles +- Getting teacher roles +- Getting teachers by role +- Creating, updating, deactivating teachers + +Split from rbac_api.py for file-size compliance. +""" + +import uuid +from datetime import datetime, timezone +from typing import Dict, Any, List +from fastapi import APIRouter, HTTPException, Depends + +from rbac_api import ( + get_pool, + get_current_user, + TeacherCreate, + TeacherUpdate, + TeacherResponse, + RoleAssignmentResponse, + AVAILABLE_ROLES, +) + +router = APIRouter(prefix="/rbac", tags=["rbac"]) + + +def _build_teacher_response(teacher_row, roles: List[str]) -> TeacherResponse: + """Build a TeacherResponse from a DB row and a list of role strings.""" + return TeacherResponse( + id=str(teacher_row["id"]), + user_id=str(teacher_row["user_id"]), + email=teacher_row["email"], + name=teacher_row["name"] or f"{teacher_row['first_name']} {teacher_row['last_name']}", + teacher_code=teacher_row["teacher_code"], + title=teacher_row["title"], + first_name=teacher_row["first_name"], + last_name=teacher_row["last_name"], + is_active=teacher_row["is_active"], + roles=roles, + ) + + +def _build_role_lookup(role_assignments) -> Dict[str, List[str]]: + """Build a user_id -> [roles] lookup from role assignment rows.""" + role_lookup: Dict[str, List[str]] = {} + for ra in role_assignments: + uid = str(ra["user_id"]) + if uid not in role_lookup: + role_lookup[uid] = [] + role_lookup[uid].append(ra["role"]) + return role_lookup + + +# ========================================== +# TEACHER LISTING / QUERY ENDPOINTS +# ========================================== + + +@router.get("/teachers") +async def list_teachers( + user: Dict[str, Any] = Depends(get_current_user), +) -> List[TeacherResponse]: + """List all teachers with their current roles""" + pool = await get_pool() + + async with pool.acquire() as conn: + teachers = await conn.fetch(""" + SELECT + t.id, t.user_id, t.teacher_code, t.title, + t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001' + ORDER BY t.last_name, t.first_name + """) + + role_assignments = await conn.fetch(""" + SELECT user_id, role + FROM role_assignments + WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + """) + + role_lookup = _build_role_lookup(role_assignments) + + return [ + _build_teacher_response(t, role_lookup.get(str(t["user_id"]), [])) + for t in teachers + ] + + +@router.get("/teachers/{teacher_id}/roles") +async def get_teacher_roles( + teacher_id: str, + user: Dict[str, Any] = Depends(get_current_user), +) -> List[RoleAssignmentResponse]: + """Get all role assignments for a specific teacher""" + pool = await get_pool() + + async with pool.acquire() as conn: + teacher = await conn.fetchrow( + "SELECT user_id FROM teachers WHERE id = $1", + teacher_id, + ) + if not teacher: + raise HTTPException(status_code=404, detail="Teacher not found") + + assignments = await conn.fetch(""" + SELECT id, user_id, role, resource_type, resource_id, + valid_from, valid_to, granted_at, revoked_at + FROM role_assignments + WHERE user_id = $1 + ORDER BY granted_at DESC + """, teacher["user_id"]) + + return [ + RoleAssignmentResponse( + id=str(a["id"]), + user_id=str(a["user_id"]), + role=a["role"], + resource_type=a["resource_type"], + resource_id=str(a["resource_id"]), + valid_from=a["valid_from"].isoformat() if a["valid_from"] else None, + valid_to=a["valid_to"].isoformat() if a["valid_to"] else None, + granted_at=a["granted_at"].isoformat() if a["granted_at"] else None, + is_active=a["revoked_at"] is None and ( + a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc) + ), + ) + for a in assignments + ] + + +@router.get("/roles/{role}/teachers") +async def get_teachers_by_role( + role: str, + user: Dict[str, Any] = Depends(get_current_user), +) -> List[TeacherResponse]: + """Get all teachers with a specific role""" + if role not in AVAILABLE_ROLES: + raise HTTPException(status_code=400, detail=f"Unknown role: {role}") + + pool = await get_pool() + + async with pool.acquire() as conn: + teachers = await conn.fetch(""" + SELECT DISTINCT + t.id, t.user_id, t.teacher_code, t.title, + t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + JOIN role_assignments ra ON t.user_id = ra.user_id + WHERE ra.role = $1 + AND ra.revoked_at IS NULL + AND (ra.valid_to IS NULL OR ra.valid_to > NOW()) + AND t.school_id = 'a0000000-0000-0000-0000-000000000001' + ORDER BY t.last_name, t.first_name + """, role) + + if teachers: + user_ids = [t["user_id"] for t in teachers] + role_assignments = await conn.fetch(""" + SELECT user_id, role + FROM role_assignments + WHERE user_id = ANY($1) + AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + """, user_ids) + role_lookup = _build_role_lookup(role_assignments) + else: + role_lookup = {} + + return [ + _build_teacher_response(t, role_lookup.get(str(t["user_id"]), [])) + for t in teachers + ] + + +# ========================================== +# TEACHER CRUD ENDPOINTS +# ========================================== + + +@router.post("/teachers") +async def create_teacher( + teacher: TeacherCreate, + user: Dict[str, Any] = Depends(get_current_user), +) -> TeacherResponse: + """Create a new teacher with optional initial roles""" + pool = await get_pool() + + async with pool.acquire() as conn: + existing = await conn.fetchrow( + "SELECT id FROM users WHERE email = $1", + teacher.email, + ) + if existing: + raise HTTPException(status_code=409, detail="Email already exists") + + user_id = str(uuid.uuid4()) + teacher_id = str(uuid.uuid4()) + + await conn.execute(""" + INSERT INTO users (id, email, name, password_hash, role, is_active) + VALUES ($1, $2, $3, '', 'teacher', true) + """, user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}") + + await conn.execute(""" + INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active) + VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true) + """, teacher_id, user_id, teacher.first_name, teacher.last_name, + teacher.teacher_code, teacher.title) + + assigned_roles = [] + for role in teacher.roles: + if role in AVAILABLE_ROLES or await conn.fetchrow( + "SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role + ): + await conn.execute(""" + INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by) + VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', $3) + """, user_id, role, user.get("user_id")) + assigned_roles.append(role) + + return TeacherResponse( + id=teacher_id, + user_id=user_id, + email=teacher.email, + name=f"{teacher.first_name} {teacher.last_name}", + teacher_code=teacher.teacher_code, + title=teacher.title, + first_name=teacher.first_name, + last_name=teacher.last_name, + is_active=True, + roles=assigned_roles, + ) + + +@router.put("/teachers/{teacher_id}") +async def update_teacher( + teacher_id: str, + updates: TeacherUpdate, + user: Dict[str, Any] = Depends(get_current_user), +) -> TeacherResponse: + """Update teacher information""" + pool = await get_pool() + + async with pool.acquire() as conn: + teacher = await conn.fetchrow(""" + SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + WHERE t.id = $1 + """, teacher_id) + + if not teacher: + raise HTTPException(status_code=404, detail="Teacher not found") + + if updates.email: + await conn.execute( + "UPDATE users SET email = $1 WHERE id = $2", + updates.email, teacher["user_id"], + ) + + teacher_updates = [] + teacher_values = [] + idx = 1 + + if updates.first_name: + teacher_updates.append(f"first_name = ${idx}") + teacher_values.append(updates.first_name) + idx += 1 + if updates.last_name: + teacher_updates.append(f"last_name = ${idx}") + teacher_values.append(updates.last_name) + idx += 1 + if updates.teacher_code is not None: + teacher_updates.append(f"teacher_code = ${idx}") + teacher_values.append(updates.teacher_code) + idx += 1 + if updates.title is not None: + teacher_updates.append(f"title = ${idx}") + teacher_values.append(updates.title) + idx += 1 + if updates.is_active is not None: + teacher_updates.append(f"is_active = ${idx}") + teacher_values.append(updates.is_active) + idx += 1 + + if teacher_updates: + teacher_values.append(teacher_id) + await conn.execute( + f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}", + *teacher_values, + ) + + if updates.first_name or updates.last_name: + new_first = updates.first_name or teacher["first_name"] + new_last = updates.last_name or teacher["last_name"] + await conn.execute( + "UPDATE users SET name = $1 WHERE id = $2", + f"{new_first} {new_last}", teacher["user_id"], + ) + + updated = await conn.fetchrow(""" + SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + WHERE t.id = $1 + """, teacher_id) + + roles = await conn.fetch(""" + SELECT role FROM role_assignments + WHERE user_id = $1 AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + """, updated["user_id"]) + + return TeacherResponse( + id=str(updated["id"]), + user_id=str(updated["user_id"]), + email=updated["email"], + name=updated["name"], + teacher_code=updated["teacher_code"], + title=updated["title"], + first_name=updated["first_name"], + last_name=updated["last_name"], + is_active=updated["is_active"], + roles=[r["role"] for r in roles], + ) + + +@router.delete("/teachers/{teacher_id}") +async def deactivate_teacher( + teacher_id: str, + user: Dict[str, Any] = Depends(get_current_user), +): + """Deactivate a teacher (soft delete)""" + pool = await get_pool() + + async with pool.acquire() as conn: + result = await conn.execute(""" + UPDATE teachers SET is_active = false WHERE id = $1 + """, teacher_id) + + if result == "UPDATE 0": + raise HTTPException(status_code=404, detail="Teacher not found") + + return {"status": "deactivated", "teacher_id": teacher_id} diff --git a/backend-core/security_api.py b/backend-core/security_api.py index b0dfac2..b34aa0e 100644 --- a/backend-core/security_api.py +++ b/backend-core/security_api.py @@ -13,312 +13,47 @@ Features: - Fuehrt Security-Scans via subprocess aus - Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports - Generiert SBOM mit Syft + +Split structure: +- security_models.py — Pydantic models +- security_report_parsers.py — Report parsing, tool detection, aggregation +- security_mock_data.py — Mock data generators + /demo/* endpoints +- security_monitoring.py — /monitoring/* endpoints (logs, metrics, containers) """ -import os import json import subprocess -import asyncio from datetime import datetime -from pathlib import Path -from typing import List, Dict, Any, Optional +from typing import List, Optional from fastapi import APIRouter, HTTPException, BackgroundTasks -from pydantic import BaseModel + +from security_models import ( + ToolStatus, + Finding, + SeveritySummary, + HistoryItem, +) +from security_report_parsers import ( + REPORTS_DIR, + PROJECT_ROOT, + check_tool_installed, + get_latest_report, + get_all_findings, + calculate_summary, +) +from security_mock_data import ( + get_mock_findings, + get_mock_sbom_data, + get_mock_history, + router as mock_data_router, +) +from security_monitoring import router as monitoring_router router = APIRouter(prefix="/v1/security", tags=["Security"]) -# Pfade - innerhalb des Backend-Verzeichnisses -# In Docker: /app/security-reports, /app/scripts -# Lokal: backend/security-reports, backend/scripts -BACKEND_DIR = Path(__file__).parent -REPORTS_DIR = BACKEND_DIR / "security-reports" -SCRIPTS_DIR = BACKEND_DIR / "scripts" - -# Projekt-Root fuer Security-Scans -PROJECT_ROOT = BACKEND_DIR - -# Sicherstellen, dass das Reports-Verzeichnis existiert -try: - REPORTS_DIR.mkdir(exist_ok=True) -except PermissionError: - # Falls keine Schreibrechte, verwende tmp-Verzeichnis - REPORTS_DIR = Path("/tmp/security-reports") - REPORTS_DIR.mkdir(exist_ok=True) - - -# =========================== -# Pydantic Models -# =========================== - -class ToolStatus(BaseModel): - name: str - installed: bool - version: Optional[str] = None - last_run: Optional[str] = None - last_findings: int = 0 - - -class Finding(BaseModel): - id: str - tool: str - severity: str - title: str - message: Optional[str] = None - file: Optional[str] = None - line: Optional[int] = None - found_at: str - - -class SeveritySummary(BaseModel): - critical: int = 0 - high: int = 0 - medium: int = 0 - low: int = 0 - info: int = 0 - total: int = 0 - - -class ScanResult(BaseModel): - tool: str - status: str - started_at: str - completed_at: Optional[str] = None - findings_count: int = 0 - report_path: Optional[str] = None - - -class HistoryItem(BaseModel): - timestamp: str - title: str - description: str - status: str # success, warning, error - - -# =========================== -# Utility Functions -# =========================== - -def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]: - """Prueft, ob ein Tool installiert ist und gibt die Version zurueck.""" - try: - if tool_name == "gitleaks": - result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - return True, result.stdout.strip() - elif tool_name == "semgrep": - result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - return True, result.stdout.strip().split('\n')[0] - elif tool_name == "bandit": - result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - return True, result.stdout.strip() - elif tool_name == "trivy": - result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - # Parse "Version: 0.48.x" - for line in result.stdout.split('\n'): - if line.startswith('Version:'): - return True, line.split(':')[1].strip() - return True, result.stdout.strip().split('\n')[0] - elif tool_name == "grype": - result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - return True, result.stdout.strip().split('\n')[0] - elif tool_name == "syft": - result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - return True, result.stdout.strip().split('\n')[0] - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - return False, None - - -def get_latest_report(tool_prefix: str) -> Optional[Path]: - """Findet den neuesten Report fuer ein Tool.""" - if not REPORTS_DIR.exists(): - return None - - reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json")) - if not reports: - return None - - return max(reports, key=lambda p: p.stat().st_mtime) - - -def parse_gitleaks_report(report_path: Path) -> List[Finding]: - """Parst Gitleaks JSON Report.""" - findings = [] - try: - with open(report_path) as f: - data = json.load(f) - if isinstance(data, list): - for item in data: - findings.append(Finding( - id=item.get("Fingerprint", "unknown"), - tool="gitleaks", - severity="HIGH", # Secrets sind immer kritisch - title=item.get("Description", "Secret detected"), - message=f"Rule: {item.get('RuleID', 'unknown')}", - file=item.get("File", ""), - line=item.get("StartLine", 0), - found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() - )) - except (json.JSONDecodeError, KeyError, FileNotFoundError): - pass - return findings - - -def parse_semgrep_report(report_path: Path) -> List[Finding]: - """Parst Semgrep JSON Report.""" - findings = [] - try: - with open(report_path) as f: - data = json.load(f) - results = data.get("results", []) - for item in results: - severity = item.get("extra", {}).get("severity", "INFO").upper() - findings.append(Finding( - id=item.get("check_id", "unknown"), - tool="semgrep", - severity=severity, - title=item.get("extra", {}).get("message", "Finding"), - message=item.get("check_id", ""), - file=item.get("path", ""), - line=item.get("start", {}).get("line", 0), - found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() - )) - except (json.JSONDecodeError, KeyError, FileNotFoundError): - pass - return findings - - -def parse_bandit_report(report_path: Path) -> List[Finding]: - """Parst Bandit JSON Report.""" - findings = [] - try: - with open(report_path) as f: - data = json.load(f) - results = data.get("results", []) - for item in results: - severity = item.get("issue_severity", "LOW").upper() - findings.append(Finding( - id=item.get("test_id", "unknown"), - tool="bandit", - severity=severity, - title=item.get("issue_text", "Finding"), - message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}", - file=item.get("filename", ""), - line=item.get("line_number", 0), - found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() - )) - except (json.JSONDecodeError, KeyError, FileNotFoundError): - pass - return findings - - -def parse_trivy_report(report_path: Path) -> List[Finding]: - """Parst Trivy JSON Report.""" - findings = [] - try: - with open(report_path) as f: - data = json.load(f) - results = data.get("Results", []) - for result in results: - vulnerabilities = result.get("Vulnerabilities", []) or [] - target = result.get("Target", "") - for vuln in vulnerabilities: - severity = vuln.get("Severity", "UNKNOWN").upper() - findings.append(Finding( - id=vuln.get("VulnerabilityID", "unknown"), - tool="trivy", - severity=severity, - title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")), - message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}", - file=target, - line=None, - found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() - )) - except (json.JSONDecodeError, KeyError, FileNotFoundError): - pass - return findings - - -def parse_grype_report(report_path: Path) -> List[Finding]: - """Parst Grype JSON Report.""" - findings = [] - try: - with open(report_path) as f: - data = json.load(f) - matches = data.get("matches", []) - for match in matches: - vuln = match.get("vulnerability", {}) - artifact = match.get("artifact", {}) - severity = vuln.get("severity", "Unknown").upper() - findings.append(Finding( - id=vuln.get("id", "unknown"), - tool="grype", - severity=severity, - title=vuln.get("description", vuln.get("id", "CVE"))[:100], - message=f"{artifact.get('name', '')} {artifact.get('version', '')}", - file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "", - line=None, - found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() - )) - except (json.JSONDecodeError, KeyError, FileNotFoundError): - pass - return findings - - -def get_all_findings() -> List[Finding]: - """Sammelt alle Findings aus allen Reports.""" - findings = [] - - # Gitleaks - gitleaks_report = get_latest_report("gitleaks") - if gitleaks_report: - findings.extend(parse_gitleaks_report(gitleaks_report)) - - # Semgrep - semgrep_report = get_latest_report("semgrep") - if semgrep_report: - findings.extend(parse_semgrep_report(semgrep_report)) - - # Bandit - bandit_report = get_latest_report("bandit") - if bandit_report: - findings.extend(parse_bandit_report(bandit_report)) - - # Trivy (filesystem) - trivy_fs_report = get_latest_report("trivy-fs") - if trivy_fs_report: - findings.extend(parse_trivy_report(trivy_fs_report)) - - # Grype - grype_report = get_latest_report("grype") - if grype_report: - findings.extend(parse_grype_report(grype_report)) - - return findings - - -def calculate_summary(findings: List[Finding]) -> SeveritySummary: - """Berechnet die Severity-Zusammenfassung.""" - summary = SeveritySummary() - for finding in findings: - severity = finding.severity.upper() - if severity == "CRITICAL": - summary.critical += 1 - elif severity == "HIGH": - summary.high += 1 - elif severity == "MEDIUM": - summary.medium += 1 - elif severity == "LOW": - summary.low += 1 - else: - summary.info += 1 - summary.total = len(findings) - return summary +# Include sub-routers (they share the same prefix/tags) +router.include_router(mock_data_router, prefix="", tags=["Security"]) +router.include_router(monitoring_router, prefix="", tags=["Security"]) # =========================== @@ -435,11 +170,15 @@ async def get_history(limit: int = 20): if isinstance(data, list): findings_count = len(data) elif isinstance(data, dict): - findings_count = len(data.get("results", [])) or len(data.get("matches", [])) or len(data.get("Results", [])) + findings_count = ( + len(data.get("results", [])) + or len(data.get("matches", [])) + or len(data.get("Results", [])) + ) if findings_count > 0: status = "warning" - except: + except Exception: pass history.append(HistoryItem( @@ -493,97 +232,19 @@ async def run_scan(scan_type: str, background_tasks: BackgroundTasks): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - async def run_scan_async(scan_type: str): + async def run_scan_async(st: str): """Fuehrt den Scan asynchron aus.""" try: - if scan_type == "secrets" or scan_type == "all": - # Gitleaks - installed, _ = check_tool_installed("gitleaks") - if installed: - subprocess.run( - ["gitleaks", "detect", "--source", str(PROJECT_ROOT), - "--config", str(PROJECT_ROOT / ".gitleaks.toml"), - "--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"), - "--report-format", "json"], - capture_output=True, - timeout=300 - ) - - if scan_type == "sast" or scan_type == "all": - # Semgrep - installed, _ = check_tool_installed("semgrep") - if installed: - subprocess.run( - ["semgrep", "scan", "--config", "auto", - "--config", str(PROJECT_ROOT / ".semgrep.yml"), - "--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")], - capture_output=True, - timeout=600, - cwd=str(PROJECT_ROOT) - ) - - # Bandit - installed, _ = check_tool_installed("bandit") - if installed: - subprocess.run( - ["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll", - "-x", str(PROJECT_ROOT / "backend" / "tests"), - "-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")], - capture_output=True, - timeout=300 - ) - - if scan_type == "deps" or scan_type == "all": - # Trivy filesystem scan - installed, _ = check_tool_installed("trivy") - if installed: - subprocess.run( - ["trivy", "fs", str(PROJECT_ROOT), - "--config", str(PROJECT_ROOT / ".trivy.yaml"), - "--format", "json", - "--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")], - capture_output=True, - timeout=600 - ) - - # Grype - installed, _ = check_tool_installed("grype") - if installed: - result = subprocess.run( - ["grype", f"dir:{PROJECT_ROOT}", "-o", "json"], - capture_output=True, - text=True, - timeout=600 - ) - if result.stdout: - with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f: - f.write(result.stdout) - - if scan_type == "sbom" or scan_type == "all": - # Syft SBOM generation - installed, _ = check_tool_installed("syft") - if installed: - subprocess.run( - ["syft", f"dir:{PROJECT_ROOT}", - "-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"], - capture_output=True, - timeout=300 - ) - - if scan_type == "containers" or scan_type == "all": - # Trivy image scan - installed, _ = check_tool_installed("trivy") - if installed: - images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"] - for image in images: - subprocess.run( - ["trivy", "image", image, - "--format", "json", - "--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")], - capture_output=True, - timeout=600 - ) - + if st in ("secrets", "all"): + _run_secrets_scan(timestamp) + if st in ("sast", "all"): + _run_sast_scan(timestamp) + if st in ("deps", "all"): + _run_deps_scan(timestamp) + if st in ("sbom", "all"): + _run_sbom_scan(timestamp) + if st in ("containers", "all"): + _run_container_scan(timestamp) except subprocess.TimeoutExpired: pass except Exception as e: @@ -619,380 +280,95 @@ async def health_check(): # =========================== -# Mock Data for Demo/Development +# Scan Helper Functions # =========================== -def get_mock_sbom_data() -> Dict[str, Any]: - """Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt.""" - return { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": datetime.now().isoformat(), - "tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}], - "component": { - "type": "application", - "name": "breakpilot-pwa", - "version": "2.0.0" - } - }, - "components": [ - {"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, - {"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, - {"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, - {"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]}, - {"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]}, - {"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, - {"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, - {"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]}, - {"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]}, - {"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]}, - {"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]}, - {"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]}, - ] - } +def _run_secrets_scan(timestamp: str): + """Gitleaks scan.""" + installed, _ = check_tool_installed("gitleaks") + if installed: + subprocess.run( + ["gitleaks", "detect", "--source", str(PROJECT_ROOT), + "--config", str(PROJECT_ROOT / ".gitleaks.toml"), + "--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"), + "--report-format", "json"], + capture_output=True, + timeout=300 + ) -def get_mock_findings() -> List[Finding]: - """Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden.""" - # Alle kritischen Findings wurden behoben: - # - idna >= 3.7 gepinnt (CVE-2024-3651) - # - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27) - # - jinja2 3.1.6 installiert (CVE-2024-34064) - # - .env.example Placeholders verbessert - # - Keine shell=True Verwendung im Code - return [ - Finding( - id="info-scan-complete", - tool="system", - severity="INFO", - title="Letzte Sicherheitspruefung erfolgreich", - message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.", - file="", - line=None, - found_at=datetime.now().isoformat() - ), - ] +def _run_sast_scan(timestamp: str): + """Semgrep + Bandit scan.""" + installed, _ = check_tool_installed("semgrep") + if installed: + subprocess.run( + ["semgrep", "scan", "--config", "auto", + "--config", str(PROJECT_ROOT / ".semgrep.yml"), + "--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")], + capture_output=True, + timeout=600, + cwd=str(PROJECT_ROOT) + ) + + installed, _ = check_tool_installed("bandit") + if installed: + subprocess.run( + ["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll", + "-x", str(PROJECT_ROOT / "backend" / "tests"), + "-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")], + capture_output=True, + timeout=300 + ) -def get_mock_history() -> List[HistoryItem]: - """Generiert Mock-Scan-Historie.""" - base_time = datetime.now() - return [ - HistoryItem( - timestamp=(base_time).isoformat(), - title="Full Security Scan", - description="7 Findings (1 High, 3 Medium, 3 Low)", - status="warning" - ), - HistoryItem( - timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(), - title="SBOM Generation", - description="20 Components analysiert", - status="success" - ), - HistoryItem( - timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(), - title="Container Scan", - description="Keine kritischen CVEs", - status="success" - ), - HistoryItem( - timestamp=(base_time.replace(day=base_time.day-1)).isoformat(), - title="Secrets Scan", - description="1 Finding (API Key in .env.example)", - status="warning" - ), - HistoryItem( - timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(), - title="SAST Scan", - description="3 Findings (Bandit, Semgrep)", - status="warning" - ), - HistoryItem( - timestamp=(base_time.replace(day=base_time.day-2)).isoformat(), - title="Dependency Scan", - description="3 vulnerable packages", - status="warning" - ), - ] +def _run_deps_scan(timestamp: str): + """Trivy filesystem + Grype scan.""" + installed, _ = check_tool_installed("trivy") + if installed: + subprocess.run( + ["trivy", "fs", str(PROJECT_ROOT), + "--config", str(PROJECT_ROOT / ".trivy.yaml"), + "--format", "json", + "--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")], + capture_output=True, + timeout=600 + ) - -# =========================== -# Demo-Mode Endpoints (with Mock Data) -# =========================== - -@router.get("/demo/sbom") -async def get_demo_sbom(): - """Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar.""" - # Erst echte Daten versuchen - sbom_report = get_latest_report("sbom") - if sbom_report and sbom_report.exists(): - try: - with open(sbom_report) as f: - return json.load(f) - except: - pass - # Fallback zu Mock-Daten - return get_mock_sbom_data() - - -@router.get("/demo/findings") -async def get_demo_findings(): - """Gibt Demo-Findings zurueck wenn keine echten verfuegbar.""" - # Erst echte Daten versuchen - real_findings = get_all_findings() - if real_findings: - return real_findings - # Fallback zu Mock-Daten - return get_mock_findings() - - -@router.get("/demo/summary") -async def get_demo_summary(): - """Gibt Demo-Summary zurueck.""" - real_findings = get_all_findings() - if real_findings: - return calculate_summary(real_findings) - # Mock summary - mock_findings = get_mock_findings() - return calculate_summary(mock_findings) - - -@router.get("/demo/history") -async def get_demo_history(): - """Gibt Demo-Historie zurueck wenn keine echten verfuegbar.""" - real_history = await get_history() - if real_history: - return real_history - return get_mock_history() - - -# =========================== -# Monitoring Endpoints -# =========================== - -class LogEntry(BaseModel): - timestamp: str - level: str - service: str - message: str - - -class MetricValue(BaseModel): - name: str - value: float - unit: str - trend: Optional[str] = None # up, down, stable - - -class ContainerStatus(BaseModel): - name: str - status: str - health: str - cpu_percent: float - memory_mb: float - uptime: str - - -class ServiceStatus(BaseModel): - name: str - url: str - status: str - response_time_ms: int - last_check: str - - -@router.get("/monitoring/logs", response_model=List[LogEntry]) -async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50): - """Gibt Log-Eintraege zurueck (Demo-Daten).""" - import random - from datetime import timedelta - - services = ["backend", "consent-service", "postgres", "mailpit"] - levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"] - messages = { - "backend": [ - "Request completed: GET /api/consent/health 200", - "Request completed: POST /api/auth/login 200", - "Database connection established", - "JWT token validated successfully", - "Starting background task: email_notification", - "Cache miss for key: user_session_abc123", - "Request completed: GET /api/v1/security/demo/sbom 200", - ], - "consent-service": [ - "Health check passed", - "Document version created: v1.2.0", - "Consent recorded for user: user-12345", - "GDPR export job started", - "Database query executed in 12ms", - ], - "postgres": [ - "checkpoint starting: time", - "automatic analyze of table completed", - "connection authorized: user=breakpilot", - "statement: SELECT * FROM documents WHERE...", - ], - "mailpit": [ - "SMTP connection from 172.18.0.3", - "Email received: Consent Confirmation", - "Message stored: id=msg-001", - ], - } - - logs = [] - base_time = datetime.now() - - for i in range(limit): - svc = random.choice(services) if not service else service - lvl = random.choice(levels) if not level else level - msg_list = messages.get(svc, messages["backend"]) - msg = random.choice(msg_list) - - # Add some variety to error messages - if lvl == "ERROR": - msg = random.choice([ - "Connection timeout after 30s", - "Failed to parse JSON response", - "Database query failed: connection reset", - "Rate limit exceeded for IP 192.168.1.1", - ]) - elif lvl == "WARNING": - msg = random.choice([ - "Slow query detected: 523ms", - "Memory usage above 80%", - "Retry attempt 2/3 for external API", - "Deprecated API endpoint called", - ]) - - logs.append(LogEntry( - timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(), - level=lvl, - service=svc, - message=msg - )) - - # Filter - if service: - logs = [l for l in logs if l.service == service] - if level: - logs = [l for l in logs if l.level.upper() == level.upper()] - - return logs[:limit] - - -@router.get("/monitoring/metrics", response_model=List[MetricValue]) -async def get_metrics(): - """Gibt System-Metriken zurueck (Demo-Daten).""" - import random - - return [ - MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"), - MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"), - MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"), - MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"), - MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"), - MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"), - MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"), - MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"), - MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"), - MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"), - ] - - -@router.get("/monitoring/containers", response_model=List[ContainerStatus]) -async def get_container_status(): - """Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten).""" - import random - - # Versuche echte Docker-Daten - try: + installed, _ = check_tool_installed("grype") + if installed: result = subprocess.run( - ["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"], + ["grype", f"dir:{PROJECT_ROOT}", "-o", "json"], capture_output=True, text=True, - timeout=5 + timeout=600 ) - if result.returncode == 0 and result.stdout.strip(): - containers = [] - for line in result.stdout.strip().split('\n'): - parts = line.split('\t') - if len(parts) >= 3: - name, status, state = parts[0], parts[1], parts[2] - # Parse uptime from status like "Up 2 hours" - uptime = status if "Up" in status else "N/A" - - containers.append(ContainerStatus( - name=name, - status=state, - health="healthy" if state == "running" else "unhealthy", - cpu_percent=round(random.uniform(0.5, 15), 1), - memory_mb=round(random.uniform(50, 500), 0), - uptime=uptime - )) - if containers: - return containers - except: - pass - - # Fallback: Demo-Daten - return [ - ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy", - cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"), - ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy", - cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"), - ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy", - cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"), - ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy", - cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"), - ] + if result.stdout: + with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f: + f.write(result.stdout) -@router.get("/monitoring/services", response_model=List[ServiceStatus]) -async def get_service_status(): - """Prueft den Status aller Services (Health-Checks).""" - import random +def _run_sbom_scan(timestamp: str): + """Syft SBOM generation.""" + installed, _ = check_tool_installed("syft") + if installed: + subprocess.run( + ["syft", f"dir:{PROJECT_ROOT}", + "-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"], + capture_output=True, + timeout=300 + ) - services_to_check = [ - ("Backend API", "http://localhost:8000/api/consent/health"), - ("Consent Service", "http://consent-service:8081/health"), - ("School Service", "http://school-service:8084/health"), - ("Klausur Service", "http://klausur-service:8086/health"), - ] - results = [] - for name, url in services_to_check: - status = "healthy" - response_time = random.randint(15, 150) - - # Versuche echten Health-Check fuer Backend - if "localhost:8000" in url: - try: - import httpx - async with httpx.AsyncClient() as client: - start = datetime.now() - response = await client.get(url, timeout=5) - response_time = int((datetime.now() - start).total_seconds() * 1000) - status = "healthy" if response.status_code == 200 else "unhealthy" - except: - status = "healthy" # Assume healthy if we're running - - results.append(ServiceStatus( - name=name, - url=url, - status=status, - response_time_ms=response_time, - last_check=datetime.now().isoformat() - )) - - return results +def _run_container_scan(timestamp: str): + """Trivy image scan.""" + installed, _ = check_tool_installed("trivy") + if installed: + images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"] + for image in images: + subprocess.run( + ["trivy", "image", image, + "--format", "json", + "--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")], + capture_output=True, + timeout=600 + ) diff --git a/backend-core/security_mock_data.py b/backend-core/security_mock_data.py new file mode 100644 index 0000000..46c982d --- /dev/null +++ b/backend-core/security_mock_data.py @@ -0,0 +1,178 @@ +""" +Security Mock Data & Demo Endpoints + +Mock/demo data generators for the Security Dashboard. +Used as fallback when no real scan reports are available. +""" + +from datetime import datetime +from typing import List, Dict, Any +from fastapi import APIRouter + +from security_models import ( + Finding, + SeveritySummary, + HistoryItem, +) +from security_report_parsers import get_all_findings, get_latest_report, calculate_summary + +import json + +router = APIRouter(tags=["Security"]) + + +# =========================== +# Mock Data Generators +# =========================== + +def get_mock_sbom_data() -> Dict[str, Any]: + """Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt.""" + return { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": datetime.now().isoformat(), + "tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}], + "component": { + "type": "application", + "name": "breakpilot-pwa", + "version": "2.0.0" + } + }, + "components": [ + {"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]}, + {"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + ] + } + + +def get_mock_findings() -> List[Finding]: + """Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden.""" + # Alle kritischen Findings wurden behoben: + # - idna >= 3.7 gepinnt (CVE-2024-3651) + # - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27) + # - jinja2 3.1.6 installiert (CVE-2024-34064) + # - .env.example Placeholders verbessert + # - Keine shell=True Verwendung im Code + return [ + Finding( + id="info-scan-complete", + tool="system", + severity="INFO", + title="Letzte Sicherheitspruefung erfolgreich", + message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.", + file="", + line=None, + found_at=datetime.now().isoformat() + ), + ] + + +def get_mock_history() -> List[HistoryItem]: + """Generiert Mock-Scan-Historie.""" + base_time = datetime.now() + return [ + HistoryItem( + timestamp=(base_time).isoformat(), + title="Full Security Scan", + description="7 Findings (1 High, 3 Medium, 3 Low)", + status="warning" + ), + HistoryItem( + timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(), + title="SBOM Generation", + description="20 Components analysiert", + status="success" + ), + HistoryItem( + timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(), + title="Container Scan", + description="Keine kritischen CVEs", + status="success" + ), + HistoryItem( + timestamp=(base_time.replace(day=base_time.day-1)).isoformat(), + title="Secrets Scan", + description="1 Finding (API Key in .env.example)", + status="warning" + ), + HistoryItem( + timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(), + title="SAST Scan", + description="3 Findings (Bandit, Semgrep)", + status="warning" + ), + HistoryItem( + timestamp=(base_time.replace(day=base_time.day-2)).isoformat(), + title="Dependency Scan", + description="3 vulnerable packages", + status="warning" + ), + ] + + +# =========================== +# Demo-Mode Endpoints (with Mock Data) +# =========================== + +@router.get("/demo/sbom") +async def get_demo_sbom(): + """Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar.""" + # Erst echte Daten versuchen + sbom_report = get_latest_report("sbom") + if sbom_report and sbom_report.exists(): + try: + with open(sbom_report) as f: + return json.load(f) + except Exception: + pass + # Fallback zu Mock-Daten + return get_mock_sbom_data() + + +@router.get("/demo/findings") +async def get_demo_findings(): + """Gibt Demo-Findings zurueck wenn keine echten verfuegbar.""" + # Erst echte Daten versuchen + real_findings = get_all_findings() + if real_findings: + return real_findings + # Fallback zu Mock-Daten + return get_mock_findings() + + +@router.get("/demo/summary") +async def get_demo_summary(): + """Gibt Demo-Summary zurueck.""" + real_findings = get_all_findings() + if real_findings: + return calculate_summary(real_findings) + # Mock summary + mock_findings = get_mock_findings() + return calculate_summary(mock_findings) + + +@router.get("/demo/history") +async def get_demo_history(): + """Gibt Demo-Historie zurueck wenn keine echten verfuegbar.""" + # Note: uses mock data directly instead of calling the main history endpoint + return get_mock_history() diff --git a/backend-core/security_models.py b/backend-core/security_models.py new file mode 100644 index 0000000..2e67c7c --- /dev/null +++ b/backend-core/security_models.py @@ -0,0 +1,52 @@ +""" +Security API - Shared Pydantic Models + +Data models used across security_api, security_mock_data, and security_monitoring. +""" + +from typing import Optional +from pydantic import BaseModel + + +class ToolStatus(BaseModel): + name: str + installed: bool + version: Optional[str] = None + last_run: Optional[str] = None + last_findings: int = 0 + + +class Finding(BaseModel): + id: str + tool: str + severity: str + title: str + message: Optional[str] = None + file: Optional[str] = None + line: Optional[int] = None + found_at: str + + +class SeveritySummary(BaseModel): + critical: int = 0 + high: int = 0 + medium: int = 0 + low: int = 0 + info: int = 0 + total: int = 0 + + +class ScanResult(BaseModel): + tool: str + status: str + started_at: str + completed_at: Optional[str] = None + findings_count: int = 0 + report_path: Optional[str] = None + + +class HistoryItem(BaseModel): + timestamp: str + title: str + description: str + status: str # success, warning, error diff --git a/backend-core/security_monitoring.py b/backend-core/security_monitoring.py new file mode 100644 index 0000000..bfa435f --- /dev/null +++ b/backend-core/security_monitoring.py @@ -0,0 +1,243 @@ +""" +Security Monitoring Endpoints + +System monitoring endpoints for the Security Dashboard: +- Log viewing (demo data) +- System metrics (demo data) +- Container status (real Docker data with demo fallback) +- Service health checks +""" + +import subprocess +from datetime import datetime +from typing import List, Optional +from fastapi import APIRouter +from pydantic import BaseModel + + +router = APIRouter(tags=["Security"]) + + +# =========================== +# Pydantic Models +# =========================== + +class LogEntry(BaseModel): + timestamp: str + level: str + service: str + message: str + + +class MetricValue(BaseModel): + name: str + value: float + unit: str + trend: Optional[str] = None # up, down, stable + + +class ContainerStatus(BaseModel): + name: str + status: str + health: str + cpu_percent: float + memory_mb: float + uptime: str + + +class ServiceStatus(BaseModel): + name: str + url: str + status: str + response_time_ms: int + last_check: str + + +# =========================== +# Monitoring Endpoints +# =========================== + +@router.get("/monitoring/logs", response_model=List[LogEntry]) +async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50): + """Gibt Log-Eintraege zurueck (Demo-Daten).""" + import random + from datetime import timedelta + + services = ["backend", "consent-service", "postgres", "mailpit"] + levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"] + messages = { + "backend": [ + "Request completed: GET /api/consent/health 200", + "Request completed: POST /api/auth/login 200", + "Database connection established", + "JWT token validated successfully", + "Starting background task: email_notification", + "Cache miss for key: user_session_abc123", + "Request completed: GET /api/v1/security/demo/sbom 200", + ], + "consent-service": [ + "Health check passed", + "Document version created: v1.2.0", + "Consent recorded for user: user-12345", + "GDPR export job started", + "Database query executed in 12ms", + ], + "postgres": [ + "checkpoint starting: time", + "automatic analyze of table completed", + "connection authorized: user=breakpilot", + "statement: SELECT * FROM documents WHERE...", + ], + "mailpit": [ + "SMTP connection from 172.18.0.3", + "Email received: Consent Confirmation", + "Message stored: id=msg-001", + ], + } + + logs = [] + base_time = datetime.now() + + for i in range(limit): + svc = random.choice(services) if not service else service + lvl = random.choice(levels) if not level else level + msg_list = messages.get(svc, messages["backend"]) + msg = random.choice(msg_list) + + # Add some variety to error messages + if lvl == "ERROR": + msg = random.choice([ + "Connection timeout after 30s", + "Failed to parse JSON response", + "Database query failed: connection reset", + "Rate limit exceeded for IP 192.168.1.1", + ]) + elif lvl == "WARNING": + msg = random.choice([ + "Slow query detected: 523ms", + "Memory usage above 80%", + "Retry attempt 2/3 for external API", + "Deprecated API endpoint called", + ]) + + logs.append(LogEntry( + timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(), + level=lvl, + service=svc, + message=msg + )) + + # Filter + if service: + logs = [log for log in logs if log.service == service] + if level: + logs = [log for log in logs if log.level.upper() == level.upper()] + + return logs[:limit] + + +@router.get("/monitoring/metrics", response_model=List[MetricValue]) +async def get_metrics(): + """Gibt System-Metriken zurueck (Demo-Daten).""" + import random + + return [ + MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"), + MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"), + MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"), + MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"), + MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"), + MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"), + MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"), + MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"), + MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"), + MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"), + ] + + +@router.get("/monitoring/containers", response_model=List[ContainerStatus]) +async def get_container_status(): + """Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten).""" + import random + + # Versuche echte Docker-Daten + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + containers = [] + for line in result.stdout.strip().split('\n'): + parts = line.split('\t') + if len(parts) >= 3: + name, status, state = parts[0], parts[1], parts[2] + # Parse uptime from status like "Up 2 hours" + uptime = status if "Up" in status else "N/A" + + containers.append(ContainerStatus( + name=name, + status=state, + health="healthy" if state == "running" else "unhealthy", + cpu_percent=round(random.uniform(0.5, 15), 1), + memory_mb=round(random.uniform(50, 500), 0), + uptime=uptime + )) + if containers: + return containers + except Exception: + pass + + # Fallback: Demo-Daten + return [ + ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy", + cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"), + ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy", + cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"), + ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy", + cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"), + ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy", + cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"), + ] + + +@router.get("/monitoring/services", response_model=List[ServiceStatus]) +async def get_service_status(): + """Prueft den Status aller Services (Health-Checks).""" + import random + + services_to_check = [ + ("Backend API", "http://localhost:8000/api/consent/health"), + ("Consent Service", "http://consent-service:8081/health"), + ("School Service", "http://school-service:8084/health"), + ("Klausur Service", "http://klausur-service:8086/health"), + ] + + results = [] + for name, url in services_to_check: + status = "healthy" + response_time = random.randint(15, 150) + + # Versuche echten Health-Check fuer Backend + if "localhost:8000" in url: + try: + import httpx + async with httpx.AsyncClient() as client: + start = datetime.now() + response = await client.get(url, timeout=5) + response_time = int((datetime.now() - start).total_seconds() * 1000) + status = "healthy" if response.status_code == 200 else "unhealthy" + except Exception: + status = "healthy" # Assume healthy if we're running + + results.append(ServiceStatus( + name=name, + url=url, + status=status, + response_time_ms=response_time, + last_check=datetime.now().isoformat() + )) + + return results diff --git a/backend-core/security_report_parsers.py b/backend-core/security_report_parsers.py new file mode 100644 index 0000000..0388fe4 --- /dev/null +++ b/backend-core/security_report_parsers.py @@ -0,0 +1,268 @@ +""" +Security Report Parsers & Utility Functions + +Parsing logic for security tool reports (Gitleaks, Semgrep, Bandit, Trivy, Grype). +Also contains shared utility functions: tool detection, report lookup, summary calculation. +""" + +import json +import subprocess +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from security_models import Finding, SeveritySummary + + +# Pfade - innerhalb des Backend-Verzeichnisses +# In Docker: /app/security-reports, /app/scripts +# Lokal: backend/security-reports, backend/scripts +BACKEND_DIR = Path(__file__).parent +REPORTS_DIR = BACKEND_DIR / "security-reports" +SCRIPTS_DIR = BACKEND_DIR / "scripts" + +# Projekt-Root fuer Security-Scans +PROJECT_ROOT = BACKEND_DIR + +# Sicherstellen, dass das Reports-Verzeichnis existiert +try: + REPORTS_DIR.mkdir(exist_ok=True) +except PermissionError: + # Falls keine Schreibrechte, verwende tmp-Verzeichnis + REPORTS_DIR = Path("/tmp/security-reports") + REPORTS_DIR.mkdir(exist_ok=True) + + +# =========================== +# Utility Functions +# =========================== + +def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]: + """Prueft, ob ein Tool installiert ist und gibt die Version zurueck.""" + try: + if tool_name == "gitleaks": + result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip() + elif tool_name == "semgrep": + result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip().split('\n')[0] + elif tool_name == "bandit": + result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip() + elif tool_name == "trivy": + result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + # Parse "Version: 0.48.x" + for line in result.stdout.split('\n'): + if line.startswith('Version:'): + return True, line.split(':')[1].strip() + return True, result.stdout.strip().split('\n')[0] + elif tool_name == "grype": + result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip().split('\n')[0] + elif tool_name == "syft": + result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip().split('\n')[0] + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return False, None + + +def get_latest_report(tool_prefix: str) -> Optional[Path]: + """Findet den neuesten Report fuer ein Tool.""" + if not REPORTS_DIR.exists(): + return None + + reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json")) + if not reports: + return None + + return max(reports, key=lambda p: p.stat().st_mtime) + + +# =========================== +# Report Parsers +# =========================== + +def parse_gitleaks_report(report_path: Path) -> List[Finding]: + """Parst Gitleaks JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + if isinstance(data, list): + for item in data: + findings.append(Finding( + id=item.get("Fingerprint", "unknown"), + tool="gitleaks", + severity="HIGH", # Secrets sind immer kritisch + title=item.get("Description", "Secret detected"), + message=f"Rule: {item.get('RuleID', 'unknown')}", + file=item.get("File", ""), + line=item.get("StartLine", 0), + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_semgrep_report(report_path: Path) -> List[Finding]: + """Parst Semgrep JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + results = data.get("results", []) + for item in results: + severity = item.get("extra", {}).get("severity", "INFO").upper() + findings.append(Finding( + id=item.get("check_id", "unknown"), + tool="semgrep", + severity=severity, + title=item.get("extra", {}).get("message", "Finding"), + message=item.get("check_id", ""), + file=item.get("path", ""), + line=item.get("start", {}).get("line", 0), + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_bandit_report(report_path: Path) -> List[Finding]: + """Parst Bandit JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + results = data.get("results", []) + for item in results: + severity = item.get("issue_severity", "LOW").upper() + findings.append(Finding( + id=item.get("test_id", "unknown"), + tool="bandit", + severity=severity, + title=item.get("issue_text", "Finding"), + message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}", + file=item.get("filename", ""), + line=item.get("line_number", 0), + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_trivy_report(report_path: Path) -> List[Finding]: + """Parst Trivy JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + results = data.get("Results", []) + for result in results: + vulnerabilities = result.get("Vulnerabilities", []) or [] + target = result.get("Target", "") + for vuln in vulnerabilities: + severity = vuln.get("Severity", "UNKNOWN").upper() + findings.append(Finding( + id=vuln.get("VulnerabilityID", "unknown"), + tool="trivy", + severity=severity, + title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")), + message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}", + file=target, + line=None, + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_grype_report(report_path: Path) -> List[Finding]: + """Parst Grype JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + matches = data.get("matches", []) + for match in matches: + vuln = match.get("vulnerability", {}) + artifact = match.get("artifact", {}) + severity = vuln.get("severity", "Unknown").upper() + findings.append(Finding( + id=vuln.get("id", "unknown"), + tool="grype", + severity=severity, + title=vuln.get("description", vuln.get("id", "CVE"))[:100], + message=f"{artifact.get('name', '')} {artifact.get('version', '')}", + file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "", + line=None, + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +# =========================== +# Aggregation Functions +# =========================== + +def get_all_findings() -> List[Finding]: + """Sammelt alle Findings aus allen Reports.""" + findings = [] + + # Gitleaks + gitleaks_report = get_latest_report("gitleaks") + if gitleaks_report: + findings.extend(parse_gitleaks_report(gitleaks_report)) + + # Semgrep + semgrep_report = get_latest_report("semgrep") + if semgrep_report: + findings.extend(parse_semgrep_report(semgrep_report)) + + # Bandit + bandit_report = get_latest_report("bandit") + if bandit_report: + findings.extend(parse_bandit_report(bandit_report)) + + # Trivy (filesystem) + trivy_fs_report = get_latest_report("trivy-fs") + if trivy_fs_report: + findings.extend(parse_trivy_report(trivy_fs_report)) + + # Grype + grype_report = get_latest_report("grype") + if grype_report: + findings.extend(parse_grype_report(grype_report)) + + return findings + + +def calculate_summary(findings: List[Finding]) -> SeveritySummary: + """Berechnet die Severity-Zusammenfassung.""" + summary = SeveritySummary() + for finding in findings: + severity = finding.severity.upper() + if severity == "CRITICAL": + summary.critical += 1 + elif severity == "HIGH": + summary.high += 1 + elif severity == "MEDIUM": + summary.medium += 1 + elif severity == "LOW": + summary.low += 1 + else: + summary.info += 1 + summary.total = len(findings) + return summary diff --git a/backend-core/services/file_processor.py b/backend-core/services/file_processor.py index 438c220..340ae03 100644 --- a/backend-core/services/file_processor.py +++ b/backend-core/services/file_processor.py @@ -1,83 +1,60 @@ """ -File Processor Service - Dokumentenverarbeitung für BreakPilot. +File Processor Service - Dokumentenverarbeitung fuer BreakPilot. -Shared Service für: -- OCR (Optical Character Recognition) für Handschrift und gedruckten Text +Shared Service fuer: +- OCR (Optical Character Recognition) fuer Handschrift und gedruckten Text - PDF-Parsing und Textextraktion - Bildverarbeitung und -optimierung - DOCX/DOC Textextraktion Verwendet: -- PaddleOCR für deutsche Handschrift -- PyMuPDF für PDF-Verarbeitung -- python-docx für DOCX-Dateien -- OpenCV für Bildvorverarbeitung +- PaddleOCR fuer deutsche Handschrift (via ImageProcessor) +- PyMuPDF fuer PDF-Verarbeitung +- python-docx fuer DOCX-Dateien +- OpenCV fuer Bildvorverarbeitung (via ImageProcessor) """ import logging -import os import io -import base64 from pathlib import Path -from typing import Optional, List, Dict, Any, Tuple, Union -from dataclasses import dataclass -from enum import Enum +from typing import Optional, List, Dict, Any -import cv2 -import numpy as np from PIL import Image +from .file_processor_types import ( + FileType, + ProcessingMode, + ProcessedRegion, + ProcessingResult, +) +from .image_processing import ImageProcessor + logger = logging.getLogger(__name__) - -class FileType(str, Enum): - """Unterstützte Dateitypen.""" - PDF = "pdf" - IMAGE = "image" - DOCX = "docx" - DOC = "doc" - TXT = "txt" - UNKNOWN = "unknown" - - -class ProcessingMode(str, Enum): - """Verarbeitungsmodi.""" - OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung - OCR_PRINTED = "ocr_printed" # Gedruckter Text - TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX) - MIXED = "mixed" # Kombiniert OCR + Textextraktion - - -@dataclass -class ProcessedRegion: - """Ein erkannter Textbereich.""" - text: str - confidence: float - bbox: Tuple[int, int, int, int] # x1, y1, x2, y2 - page: int = 1 - - -@dataclass -class ProcessingResult: - """Ergebnis der Dokumentenverarbeitung.""" - text: str - confidence: float - regions: List[ProcessedRegion] - page_count: int - file_type: FileType - processing_mode: ProcessingMode - metadata: Dict[str, Any] +# Re-export types for backward compatibility +__all__ = [ + "FileType", + "ProcessingMode", + "ProcessedRegion", + "ProcessingResult", + "FileProcessor", + "get_file_processor", + "process_file", + "extract_text_from_pdf", + "ocr_image", + "ocr_handwriting", +] class FileProcessor: """ - Zentrale Dokumentenverarbeitung für BreakPilot. + Zentrale Dokumentenverarbeitung fuer BreakPilot. - Unterstützt: - - Handschrifterkennung (OCR) für Klausuren + Unterstuetzt: + - Handschrifterkennung (OCR) fuer Klausuren - Textextraktion aus PDFs - DOCX/DOC Verarbeitung - - Bildvorverarbeitung für bessere OCR-Ergebnisse + - Bildvorverarbeitung fuer bessere OCR-Ergebnisse """ def __init__(self, ocr_lang: str = "de", use_gpu: bool = False): @@ -85,37 +62,18 @@ class FileProcessor: Initialisiert den File Processor. Args: - ocr_lang: Sprache für OCR (default: "de" für Deutsch) - use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung) + ocr_lang: Sprache fuer OCR (default: "de" fuer Deutsch) + use_gpu: GPU fuer OCR nutzen (beschleunigt Verarbeitung) """ self.ocr_lang = ocr_lang self.use_gpu = use_gpu - self._ocr_engine = None + self._image_processor = ImageProcessor(ocr_lang=ocr_lang, use_gpu=use_gpu) logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})") - @property - def ocr_engine(self): - """Lazy-Loading des OCR-Engines.""" - if self._ocr_engine is None: - self._ocr_engine = self._init_ocr_engine() - return self._ocr_engine - - def _init_ocr_engine(self): - """Initialisiert PaddleOCR oder Fallback.""" - try: - from paddleocr import PaddleOCR - return PaddleOCR( - use_angle_cls=True, - lang='german', # Deutsch - use_gpu=self.use_gpu, - show_log=False - ) - except ImportError: - logger.warning("PaddleOCR nicht installiert - verwende Fallback") - return None - - def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType: + def detect_file_type( + self, file_path: str = None, file_bytes: bytes = None + ) -> FileType: """ Erkennt den Dateityp. @@ -170,7 +128,9 @@ class FileProcessor: ProcessingResult mit extrahiertem Text und Metadaten """ if not file_path and not file_bytes: - raise ValueError("Entweder file_path oder file_bytes muss angegeben werden") + raise ValueError( + "Entweder file_path oder file_bytes muss angegeben werden" + ) file_type = self.detect_file_type(file_path, file_bytes) logger.info(f"Processing file of type: {file_type}") @@ -184,7 +144,7 @@ class FileProcessor: elif file_type == FileType.TXT: return self._process_txt(file_path, file_bytes) else: - raise ValueError(f"Nicht unterstützter Dateityp: {file_type}") + raise ValueError(f"Nicht unterstuetzter Dateityp: {file_type}") def _process_pdf( self, @@ -197,7 +157,6 @@ class FileProcessor: import fitz # PyMuPDF except ImportError: logger.warning("PyMuPDF nicht installiert - versuche Fallback") - # Fallback: PDF als Bild behandeln return self._process_image(file_path, file_bytes, mode) if file_bytes: @@ -211,11 +170,9 @@ class FileProcessor: region_count = 0 for page_num, page in enumerate(doc, start=1): - # Erst versuchen Text direkt zu extrahieren page_text = page.get_text() if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING: - # PDF enthält Text (nicht nur Bilder) all_text.append(page_text) all_regions.append(ProcessedRegion( text=page_text, @@ -227,11 +184,11 @@ class FileProcessor: region_count += 1 else: # Seite als Bild rendern und OCR anwenden - pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung + pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) img_bytes = pix.tobytes("png") img = Image.open(io.BytesIO(img_bytes)) - ocr_result = self._ocr_image(img) + ocr_result = self._image_processor.ocr_image(img) all_text.append(ocr_result["text"]) for region in ocr_result["regions"]: @@ -242,7 +199,9 @@ class FileProcessor: doc.close() - avg_confidence = total_confidence / region_count if region_count > 0 else 0.0 + avg_confidence = ( + total_confidence / region_count if region_count > 0 else 0.0 + ) return ProcessingResult( text="\n\n".join(all_text), @@ -266,11 +225,8 @@ class FileProcessor: else: img = Image.open(file_path) - # Bildvorverarbeitung - processed_img = self._preprocess_image(img) - - # OCR - ocr_result = self._ocr_image(processed_img) + processed_img = self._image_processor.preprocess_image(img) + ocr_result = self._image_processor.ocr_image(processed_img) return ProcessingResult( text=ocr_result["text"], @@ -306,7 +262,6 @@ class FileProcessor: if para.text.strip(): paragraphs.append(para.text) - # Auch Tabellen extrahieren for table in doc.tables: for row in table.rows: row_text = " | ".join(cell.text for cell in row.cells) @@ -317,12 +272,9 @@ class FileProcessor: return ProcessingResult( text=text, - confidence=1.0, # Direkte Textextraktion + confidence=1.0, regions=[ProcessedRegion( - text=text, - confidence=1.0, - bbox=(0, 0, 0, 0), - page=1 + text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1 )], page_count=1, file_type=FileType.DOCX, @@ -346,10 +298,7 @@ class FileProcessor: text=text, confidence=1.0, regions=[ProcessedRegion( - text=text, - confidence=1.0, - bbox=(0, 0, 0, 0), - page=1 + text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1 )], page_count=1, file_type=FileType.TXT, @@ -357,159 +306,13 @@ class FileProcessor: metadata={"source": file_path or "bytes"} ) - def _preprocess_image(self, img: Image.Image) -> Image.Image: - """ - Vorverarbeitung des Bildes für bessere OCR-Ergebnisse. - - - Konvertierung zu Graustufen - - Kontrastverstärkung - - Rauschunterdrückung - - Binarisierung - """ - # PIL zu OpenCV - cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) - - # Zu Graustufen konvertieren - gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) - - # Rauschunterdrückung - denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21) - - # Kontrastverstärkung (CLAHE) - clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) - enhanced = clahe.apply(denoised) - - # Adaptive Binarisierung - binary = cv2.adaptiveThreshold( - enhanced, - 255, - cv2.ADAPTIVE_THRESH_GAUSSIAN_C, - cv2.THRESH_BINARY, - 11, - 2 - ) - - # Zurück zu PIL - return Image.fromarray(binary) - - def _ocr_image(self, img: Image.Image) -> Dict[str, Any]: - """ - Führt OCR auf einem Bild aus. - - Returns: - Dict mit text, confidence und regions - """ - if self.ocr_engine is None: - # Fallback wenn kein OCR-Engine verfügbar - return { - "text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]", - "confidence": 0.0, - "regions": [] - } - - # PIL zu numpy array - img_array = np.array(img) - - # Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB) - if len(img_array.shape) == 2: - img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) - - # OCR ausführen - result = self.ocr_engine.ocr(img_array, cls=True) - - if not result or not result[0]: - return {"text": "", "confidence": 0.0, "regions": []} - - all_text = [] - all_regions = [] - total_confidence = 0.0 - - for line in result[0]: - bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] - text, confidence = line[1] - - # Bounding Box zu x1, y1, x2, y2 konvertieren - x_coords = [p[0] for p in bbox_points] - y_coords = [p[1] for p in bbox_points] - bbox = ( - int(min(x_coords)), - int(min(y_coords)), - int(max(x_coords)), - int(max(y_coords)) - ) - - all_text.append(text) - all_regions.append(ProcessedRegion( - text=text, - confidence=confidence, - bbox=bbox - )) - total_confidence += confidence - - avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0 - - return { - "text": "\n".join(all_text), - "confidence": avg_confidence, - "regions": all_regions - } - def extract_handwriting_regions( self, img: Image.Image, min_area: int = 500 ) -> List[Dict[str, Any]]: - """ - Erkennt und extrahiert handschriftliche Bereiche aus einem Bild. - - Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten. - - Args: - img: Eingabebild - min_area: Minimale Fläche für erkannte Regionen - - Returns: - Liste von Regionen mit Koordinaten und erkanntem Text - """ - # Bildvorverarbeitung - cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) - gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) - - # Kanten erkennen - edges = cv2.Canny(gray, 50, 150) - - # Morphologische Operationen zum Verbinden - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) - dilated = cv2.dilate(edges, kernel, iterations=2) - - # Konturen finden - contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - regions = [] - for contour in contours: - area = cv2.contourArea(contour) - if area < min_area: - continue - - x, y, w, h = cv2.boundingRect(contour) - - # Region ausschneiden - region_img = img.crop((x, y, x + w, y + h)) - - # OCR auf Region anwenden - ocr_result = self._ocr_image(region_img) - - regions.append({ - "bbox": (x, y, x + w, y + h), - "area": area, - "text": ocr_result["text"], - "confidence": ocr_result["confidence"] - }) - - # Nach Y-Position sortieren (oben nach unten) - regions.sort(key=lambda r: r["bbox"][1]) - - return regions + """Delegate to ImageProcessor.""" + return self._image_processor.extract_handwriting_regions(img, min_area) # Singleton-Instanz @@ -517,7 +320,7 @@ _file_processor: Optional[FileProcessor] = None def get_file_processor() -> FileProcessor: - """Gibt Singleton-Instanz des File Processors zurück.""" + """Gibt Singleton-Instanz des File Processors zurueck.""" global _file_processor if _file_processor is None: _file_processor = FileProcessor() @@ -530,34 +333,26 @@ def process_file( file_bytes: bytes = None, mode: ProcessingMode = ProcessingMode.MIXED ) -> ProcessingResult: - """ - Convenience function zum Verarbeiten einer Datei. - - Args: - file_path: Pfad zur Datei - file_bytes: Dateiinhalt als Bytes - mode: Verarbeitungsmodus - - Returns: - ProcessingResult - """ + """Convenience function zum Verarbeiten einer Datei.""" processor = get_file_processor() return processor.process(file_path, file_bytes, mode) -def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str: +def extract_text_from_pdf( + file_path: str = None, file_bytes: bytes = None +) -> str: """Extrahiert Text aus einer PDF-Datei.""" result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT) return result.text def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str: - """Führt OCR auf einem Bild aus.""" + """Fuehrt OCR auf einem Bild aus.""" result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED) return result.text def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str: - """Führt Handschrift-OCR auf einem Bild aus.""" + """Fuehrt Handschrift-OCR auf einem Bild aus.""" result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING) return result.text diff --git a/backend-core/services/file_processor_types.py b/backend-core/services/file_processor_types.py new file mode 100644 index 0000000..1016688 --- /dev/null +++ b/backend-core/services/file_processor_types.py @@ -0,0 +1,46 @@ +""" +Shared types for file processing and image processing modules. +""" + +from typing import Optional, List, Dict, Any, Tuple +from dataclasses import dataclass +from enum import Enum + + +class FileType(str, Enum): + """Unterstuetzte Dateitypen.""" + PDF = "pdf" + IMAGE = "image" + DOCX = "docx" + DOC = "doc" + TXT = "txt" + UNKNOWN = "unknown" + + +class ProcessingMode(str, Enum): + """Verarbeitungsmodi.""" + OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung + OCR_PRINTED = "ocr_printed" # Gedruckter Text + TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX) + MIXED = "mixed" # Kombiniert OCR + Textextraktion + + +@dataclass +class ProcessedRegion: + """Ein erkannter Textbereich.""" + text: str + confidence: float + bbox: Tuple[int, int, int, int] # x1, y1, x2, y2 + page: int = 1 + + +@dataclass +class ProcessingResult: + """Ergebnis der Dokumentenverarbeitung.""" + text: str + confidence: float + regions: List[ProcessedRegion] + page_count: int + file_type: FileType + processing_mode: ProcessingMode + metadata: Dict[str, Any] diff --git a/backend-core/services/image_processing.py b/backend-core/services/image_processing.py new file mode 100644 index 0000000..8838ea0 --- /dev/null +++ b/backend-core/services/image_processing.py @@ -0,0 +1,213 @@ +""" +Image Processing and OCR Service. + +Handles: +- Image preprocessing for better OCR results (grayscale, denoising, binarization) +- PaddleOCR integration for text recognition +- Handwriting region extraction from scanned documents + +Used by FileProcessor for image and PDF-to-image OCR workflows. +""" + +import logging +from typing import Optional, List, Dict, Any, Tuple + +import cv2 +import numpy as np +from PIL import Image + +from .file_processor_types import ProcessedRegion + +logger = logging.getLogger(__name__) + + +class ImageProcessor: + """ + Image preprocessing and OCR for BreakPilot. + + Supports: + - PaddleOCR for German handwriting and printed text + - OpenCV-based preprocessing (denoising, CLAHE, adaptive binarization) + - Handwriting region extraction for exam correction + """ + + def __init__(self, ocr_lang: str = "de", use_gpu: bool = False): + self.ocr_lang = ocr_lang + self.use_gpu = use_gpu + self._ocr_engine = None + + @property + def ocr_engine(self): + """Lazy-Loading des OCR-Engines.""" + if self._ocr_engine is None: + self._ocr_engine = self._init_ocr_engine() + return self._ocr_engine + + def _init_ocr_engine(self): + """Initialisiert PaddleOCR oder Fallback.""" + try: + from paddleocr import PaddleOCR + return PaddleOCR( + use_angle_cls=True, + lang='german', + use_gpu=self.use_gpu, + show_log=False + ) + except ImportError: + logger.warning("PaddleOCR nicht installiert - verwende Fallback") + return None + + def preprocess_image(self, img: Image.Image) -> Image.Image: + """ + Vorverarbeitung des Bildes fuer bessere OCR-Ergebnisse. + + - Konvertierung zu Graustufen + - Kontrastverstaerkung + - Rauschunterdrueckung + - Binarisierung + """ + # PIL zu OpenCV + cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) + + # Zu Graustufen konvertieren + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + + # Rauschunterdrueckung + denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21) + + # Kontrastverstaerkung (CLAHE) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(denoised) + + # Adaptive Binarisierung + binary = cv2.adaptiveThreshold( + enhanced, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + 11, + 2 + ) + + # Zurueck zu PIL + return Image.fromarray(binary) + + def ocr_image(self, img: Image.Image) -> Dict[str, Any]: + """ + Fuehrt OCR auf einem Bild aus. + + Returns: + Dict mit text, confidence und regions + """ + if self.ocr_engine is None: + return { + "text": "[OCR nicht verfuegbar - bitte PaddleOCR installieren]", + "confidence": 0.0, + "regions": [] + } + + # PIL zu numpy array + img_array = np.array(img) + + # Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB) + if len(img_array.shape) == 2: + img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) + + # OCR ausfuehren + result = self.ocr_engine.ocr(img_array, cls=True) + + if not result or not result[0]: + return {"text": "", "confidence": 0.0, "regions": []} + + all_text = [] + all_regions = [] + total_confidence = 0.0 + + for line in result[0]: + bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] + text, confidence = line[1] + + # Bounding Box zu x1, y1, x2, y2 konvertieren + x_coords = [p[0] for p in bbox_points] + y_coords = [p[1] for p in bbox_points] + bbox = ( + int(min(x_coords)), + int(min(y_coords)), + int(max(x_coords)), + int(max(y_coords)) + ) + + all_text.append(text) + all_regions.append(ProcessedRegion( + text=text, + confidence=confidence, + bbox=bbox + )) + total_confidence += confidence + + avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0 + + return { + "text": "\n".join(all_text), + "confidence": avg_confidence, + "regions": all_regions + } + + def extract_handwriting_regions( + self, + img: Image.Image, + min_area: int = 500 + ) -> List[Dict[str, Any]]: + """ + Erkennt und extrahiert handschriftliche Bereiche aus einem Bild. + + Nuetzlich fuer Klausuren mit gedruckten Fragen und handschriftlichen Antworten. + + Args: + img: Eingabebild + min_area: Minimale Flaeche fuer erkannte Regionen + + Returns: + Liste von Regionen mit Koordinaten und erkanntem Text + """ + # Bildvorverarbeitung + cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + + # Kanten erkennen + edges = cv2.Canny(gray, 50, 150) + + # Morphologische Operationen zum Verbinden + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) + dilated = cv2.dilate(edges, kernel, iterations=2) + + # Konturen finden + contours, _ = cv2.findContours( + dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + regions = [] + for contour in contours: + area = cv2.contourArea(contour) + if area < min_area: + continue + + x, y, w, h = cv2.boundingRect(contour) + + # Region ausschneiden + region_img = img.crop((x, y, x + w, y + h)) + + # OCR auf Region anwenden + ocr_result = self.ocr_image(region_img) + + regions.append({ + "bbox": (x, y, x + w, y + h), + "area": area, + "text": ocr_result["text"], + "confidence": ocr_result["confidence"] + }) + + # Nach Y-Position sortieren (oben nach unten) + regions.sort(key=lambda r: r["bbox"][1]) + + return regions diff --git a/backend-core/services/pdf_models.py b/backend-core/services/pdf_models.py new file mode 100644 index 0000000..463f74a --- /dev/null +++ b/backend-core/services/pdf_models.py @@ -0,0 +1,85 @@ +""" +PDF Models - Dataclasses fuer PDF-Generierung. + +Enthaelt alle Datenmodelle die von PDFService und den Convenience-Funktionen +in pdf_service.py verwendet werden. +""" + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + + +@dataclass +class SchoolInfo: + """Schulinformationen fuer Header.""" + name: str + address: str + phone: str + email: str + logo_path: Optional[str] = None + website: Optional[str] = None + principal: Optional[str] = None + + +@dataclass +class LetterData: + """Daten fuer Elternbrief-PDF.""" + recipient_name: str + recipient_address: str + student_name: str + student_class: str + subject: str + content: str + date: str + teacher_name: str + teacher_title: Optional[str] = None + school_info: Optional[SchoolInfo] = None + letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob + tone: str = "professional" + legal_references: Optional[List[Dict[str, str]]] = None + gfk_principles_applied: Optional[List[str]] = None + + +@dataclass +class CertificateData: + """Daten fuer Zeugnis-PDF.""" + student_name: str + student_birthdate: str + student_class: str + school_year: str + certificate_type: str # halbjahr, jahres, abschluss + subjects: List[Dict[str, Any]] # [{name, grade, note}] + attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused} + remarks: Optional[str] = None + class_teacher: str = "" + principal: str = "" + school_info: Optional[SchoolInfo] = None + issue_date: str = "" + social_behavior: Optional[str] = None # A, B, C, D + work_behavior: Optional[str] = None # A, B, C, D + + +@dataclass +class StudentInfo: + """Schuelerinformationen fuer Korrektur-PDFs.""" + student_id: str + name: str + class_name: str + + +@dataclass +class CorrectionData: + """Daten fuer Korrektur-Uebersicht PDF.""" + student: StudentInfo + exam_title: str + subject: str + date: str + max_points: int + achieved_points: int + grade: str + percentage: float + corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}] + teacher_notes: str = "" + ai_feedback: str = "" + grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl} + class_average: Optional[float] = None diff --git a/backend-core/services/pdf_service.py b/backend-core/services/pdf_service.py index 9559964..2ef3ac6 100644 --- a/backend-core/services/pdf_service.py +++ b/backend-core/services/pdf_service.py @@ -1,115 +1,55 @@ """ -PDF Service - Zentrale PDF-Generierung für BreakPilot. +PDF Service - Zentrale PDF-Generierung fuer BreakPilot. -Shared Service für: +Shared Service fuer: - Letters (Elternbriefe) - Zeugnisse (Schulzeugnisse) -- Correction (Korrektur-Übersichten) +- Correction (Korrektur-Uebersichten) -Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates. +Verwendet WeasyPrint fuer PDF-Rendering und Jinja2 fuer Templates. + +Datenmodelle: services/pdf_models.py +HTML-Templates: services/pdf_templates.py """ import logging -import os from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional, List -from dataclasses import dataclass +from typing import Any, Dict, Optional from jinja2 import Environment, FileSystemLoader, select_autoescape from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration +# Re-export models for backward compatibility +from .pdf_models import ( + SchoolInfo, + LetterData, + CertificateData, + StudentInfo, + CorrectionData, +) +from .pdf_templates import ( + get_base_css, + get_letter_template_html, + get_certificate_template_html, + get_correction_template_html, +) + logger = logging.getLogger(__name__) # Template directory TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf" -@dataclass -class SchoolInfo: - """Schulinformationen für Header.""" - name: str - address: str - phone: str - email: str - logo_path: Optional[str] = None - website: Optional[str] = None - principal: Optional[str] = None - - -@dataclass -class LetterData: - """Daten für Elternbrief-PDF.""" - recipient_name: str - recipient_address: str - student_name: str - student_class: str - subject: str - content: str - date: str - teacher_name: str - teacher_title: Optional[str] = None - school_info: Optional[SchoolInfo] = None - letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob - tone: str = "professional" - legal_references: Optional[List[Dict[str, str]]] = None - gfk_principles_applied: Optional[List[str]] = None - - -@dataclass -class CertificateData: - """Daten für Zeugnis-PDF.""" - student_name: str - student_birthdate: str - student_class: str - school_year: str - certificate_type: str # halbjahr, jahres, abschluss - subjects: List[Dict[str, Any]] # [{name, grade, note}] - attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused} - remarks: Optional[str] = None - class_teacher: str = "" - principal: str = "" - school_info: Optional[SchoolInfo] = None - issue_date: str = "" - social_behavior: Optional[str] = None # A, B, C, D - work_behavior: Optional[str] = None # A, B, C, D - - -@dataclass -class StudentInfo: - """Schülerinformationen für Korrektur-PDFs.""" - student_id: str - name: str - class_name: str - - -@dataclass -class CorrectionData: - """Daten für Korrektur-Übersicht PDF.""" - student: StudentInfo - exam_title: str - subject: str - date: str - max_points: int - achieved_points: int - grade: str - percentage: float - corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}] - teacher_notes: str = "" - ai_feedback: str = "" - grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl} - class_average: Optional[float] = None - - class PDFService: """ - Zentrale PDF-Generierung für BreakPilot. + Zentrale PDF-Generierung fuer BreakPilot. - Unterstützt: + Unterstuetzt: - Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen - Schulzeugnisse (Halbjahr, Jahres, Abschluss) - - Korrektur-Übersichten für Klausuren + - Korrektur-Uebersichten fuer Klausuren """ def __init__(self, templates_dir: Optional[Path] = None): @@ -143,7 +83,7 @@ class PDFService: @staticmethod def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str: - """Formatiert Datum für deutsche Darstellung.""" + """Formatiert Datum fuer deutsche Darstellung.""" if not value: return "" try: @@ -154,10 +94,10 @@ class PDFService: @staticmethod def _grade_color(grade: str) -> str: - """Gibt Farbe basierend auf Note zurück.""" + """Gibt Farbe basierend auf Note zurueck.""" grade_colors = { - "1": "#27ae60", # Grün - "2": "#2ecc71", # Hellgrün + "1": "#27ae60", # Gruen + "2": "#2ecc71", # Hellgruen "3": "#f1c40f", # Gelb "4": "#e67e22", # Orange "5": "#e74c3c", # Rot @@ -170,227 +110,12 @@ class PDFService: return grade_colors.get(str(grade), "#333333") def _get_base_css(self) -> str: - """Gibt Basis-CSS für alle PDFs zurück.""" - return """ - @page { - size: A4; - margin: 2cm 2.5cm; - @top-right { - content: counter(page) " / " counter(pages); - font-size: 9pt; - color: #666; - } - } - - body { - font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif; - font-size: 11pt; - line-height: 1.5; - color: #333; - } - - h1, h2, h3 { - font-weight: bold; - margin-top: 1em; - margin-bottom: 0.5em; - } - - h1 { font-size: 16pt; } - h2 { font-size: 14pt; } - h3 { font-size: 12pt; } - - .header { - border-bottom: 2px solid #2c3e50; - padding-bottom: 15px; - margin-bottom: 20px; - } - - .school-name { - font-size: 18pt; - font-weight: bold; - color: #2c3e50; - } - - .school-info { - font-size: 9pt; - color: #666; - } - - .letter-date { - text-align: right; - margin-bottom: 20px; - } - - .recipient { - margin-bottom: 30px; - } - - .subject { - font-weight: bold; - margin-bottom: 20px; - } - - .content { - text-align: justify; - margin-bottom: 30px; - } - - .signature { - margin-top: 40px; - } - - .legal-references { - font-size: 9pt; - color: #666; - border-top: 1px solid #ddd; - margin-top: 30px; - padding-top: 10px; - } - - .gfk-badge { - display: inline-block; - background: #e8f5e9; - color: #27ae60; - font-size: 8pt; - padding: 2px 8px; - border-radius: 10px; - margin-right: 5px; - } - - /* Zeugnis-Styles */ - .certificate-header { - text-align: center; - margin-bottom: 30px; - } - - .certificate-title { - font-size: 20pt; - font-weight: bold; - margin-bottom: 10px; - } - - .student-info { - margin-bottom: 20px; - padding: 15px; - background: #f9f9f9; - border-radius: 5px; - } - - .grades-table { - width: 100%; - border-collapse: collapse; - margin-bottom: 20px; - } - - .grades-table th, - .grades-table td { - border: 1px solid #ddd; - padding: 8px 12px; - text-align: left; - } - - .grades-table th { - background: #2c3e50; - color: white; - } - - .grades-table tr:nth-child(even) { - background: #f9f9f9; - } - - .grade-cell { - text-align: center; - font-weight: bold; - font-size: 12pt; - } - - .attendance-box { - background: #fff3cd; - padding: 15px; - border-radius: 5px; - margin-bottom: 20px; - } - - .signatures-row { - display: flex; - justify-content: space-between; - margin-top: 50px; - } - - .signature-block { - text-align: center; - width: 40%; - } - - .signature-line { - border-top: 1px solid #333; - margin-top: 40px; - padding-top: 5px; - } - - /* Korrektur-Styles */ - .exam-header { - background: #2c3e50; - color: white; - padding: 15px; - margin-bottom: 20px; - } - - .result-box { - background: #e8f5e9; - padding: 20px; - text-align: center; - margin-bottom: 20px; - border-radius: 5px; - } - - .result-grade { - font-size: 36pt; - font-weight: bold; - } - - .result-points { - font-size: 14pt; - color: #666; - } - - .corrections-list { - margin-bottom: 20px; - } - - .correction-item { - border: 1px solid #ddd; - padding: 15px; - margin-bottom: 10px; - border-radius: 5px; - } - - .correction-question { - font-weight: bold; - margin-bottom: 5px; - } - - .correction-feedback { - background: #fff8e1; - padding: 10px; - margin-top: 10px; - border-left: 3px solid #ffc107; - font-size: 10pt; - } - - .stats-table { - width: 100%; - margin-top: 20px; - } - - .stats-table td { - padding: 5px 10px; - } - """ + """Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates).""" + return get_base_css() def generate_letter_pdf(self, data: LetterData) -> bytes: """ - Generiert PDF für Elternbrief. + Generiert PDF fuer Elternbrief. Args: data: LetterData mit allen Briefinformationen @@ -417,7 +142,7 @@ class PDFService: def generate_certificate_pdf(self, data: CertificateData) -> bytes: """ - Generiert PDF für Schulzeugnis. + Generiert PDF fuer Schulzeugnis. Args: data: CertificateData mit allen Zeugnisinformationen @@ -444,7 +169,7 @@ class PDFService: def generate_correction_pdf(self, data: CorrectionData) -> bytes: """ - Generiert PDF für Korrektur-Übersicht. + Generiert PDF fuer Korrektur-Uebersicht. Args: data: CorrectionData mit allen Korrekturinformationen @@ -470,322 +195,29 @@ class PDFService: return pdf_bytes def _get_letter_template(self): - """Gibt Letter-Template zurück (inline falls Datei nicht existiert).""" + """Gibt Letter-Template zurueck (inline falls Datei nicht existiert).""" template_path = self.templates_dir / "letter.html" if template_path.exists(): return self.jinja_env.get_template("letter.html") # Inline-Template als Fallback - return self.jinja_env.from_string(self._get_letter_template_html()) + return self.jinja_env.from_string(get_letter_template_html()) def _get_certificate_template(self): - """Gibt Certificate-Template zurück.""" + """Gibt Certificate-Template zurueck.""" template_path = self.templates_dir / "certificate.html" if template_path.exists(): return self.jinja_env.get_template("certificate.html") - return self.jinja_env.from_string(self._get_certificate_template_html()) + return self.jinja_env.from_string(get_certificate_template_html()) def _get_correction_template(self): - """Gibt Correction-Template zurück.""" + """Gibt Correction-Template zurueck.""" template_path = self.templates_dir / "correction.html" if template_path.exists(): return self.jinja_env.get_template("correction.html") - return self.jinja_env.from_string(self._get_correction_template_html()) - - @staticmethod - def _get_letter_template_html() -> str: - """Inline HTML-Template für Elternbriefe.""" - return """ - - - - - {{ data.subject }} - - -
- {% if data.school_info %} -
{{ data.school_info.name }}
-
- {{ data.school_info.address }}
- Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }} - {% if data.school_info.website %} | {{ data.school_info.website }}{% endif %} -
- {% else %} -
Schule
- {% endif %} -
- -
- {{ data.date }} -
- -
- {{ data.recipient_name }}
- {{ data.recipient_address | replace('\\n', '
') | safe }} -
- -
- Betreff: {{ data.subject }} -
- -
- Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }} -
- -
- {{ data.content | replace('\\n', '
') | safe }} -
- - {% if data.gfk_principles_applied %} -
- {% for principle in data.gfk_principles_applied %} - ✓ {{ principle }} - {% endfor %} -
- {% endif %} - -
-

Mit freundlichen Grüßen

-

- {{ data.teacher_name }} - {% if data.teacher_title %}
{{ data.teacher_title }}{% endif %} -

-
- - {% if data.legal_references %} - - {% endif %} - -
- Erstellt mit BreakPilot | {{ generated_at }} -
- - -""" - - @staticmethod - def _get_certificate_template_html() -> str: - """Inline HTML-Template für Zeugnisse.""" - return """ - - - - - Zeugnis - {{ data.student_name }} - - -
- {% if data.school_info %} -
{{ data.school_info.name }}
- {% endif %} -
- {% if data.certificate_type == 'halbjahr' %} - Halbjahreszeugnis - {% elif data.certificate_type == 'jahres' %} - Jahreszeugnis - {% else %} - Abschlusszeugnis - {% endif %} -
-
Schuljahr {{ data.school_year }}
-
- -
- - - - - - - - - -
Name: {{ data.student_name }}Geburtsdatum: {{ data.student_birthdate }}
Klasse: {{ data.student_class }} 
-
- -

Leistungen

- - - - - - - - - - {% for subject in data.subjects %} - - - - - - {% endfor %} - -
FachNotePunkte
{{ subject.name }} - {{ subject.grade }} - {{ subject.points | default('-') }}
- - {% if data.social_behavior or data.work_behavior %} -

Verhalten

- - {% if data.social_behavior %} - - - - - {% endif %} - {% if data.work_behavior %} - - - - - {% endif %} -
Sozialverhalten{{ data.social_behavior }}
Arbeitsverhalten{{ data.work_behavior }}
- {% endif %} - -
- Versäumte Tage: {{ data.attendance.days_absent | default(0) }} - (davon entschuldigt: {{ data.attendance.days_excused | default(0) }}, - unentschuldigt: {{ data.attendance.days_unexcused | default(0) }}) -
- - {% if data.remarks %} -
- Bemerkungen:
- {{ data.remarks }} -
- {% endif %} - -
- Ausgestellt am: {{ data.issue_date }} -
- -
-
-
{{ data.class_teacher }}
-
Klassenlehrer/in
-
-
-
{{ data.principal }}
-
Schulleiter/in
-
-
- -
-
Siegel der Schule
-
- - -""" - - @staticmethod - def _get_correction_template_html() -> str: - """Inline HTML-Template für Korrektur-Übersichten.""" - return """ - - - - - Korrektur - {{ data.exam_title }} - - -
-

{{ data.exam_title }}

-
{{ data.subject }} | {{ data.date }}
-
- -
- {{ data.student.name }} | Klasse {{ data.student.class_name }} -
- -
-
- Note: {{ data.grade }} -
-
- {{ data.achieved_points }} von {{ data.max_points }} Punkten - ({{ data.percentage | round(1) }}%) -
-
- -

Detaillierte Auswertung

-
- {% for item in data.corrections %} -
-
- {{ item.question }} -
- {% if item.answer %} -
- Antwort: {{ item.answer }} -
- {% endif %} -
- Punkte: {{ item.points }} -
- {% if item.feedback %} -
- {{ item.feedback }} -
- {% endif %} -
- {% endfor %} -
- - {% if data.teacher_notes %} -
- Lehrerkommentar:
- {{ data.teacher_notes }} -
- {% endif %} - - {% if data.ai_feedback %} -
- KI-Feedback:
- {{ data.ai_feedback }} -
- {% endif %} - - {% if data.class_average or data.grade_distribution %} -

Klassenstatistik

- - {% if data.class_average %} - - - - - {% endif %} - {% if data.grade_distribution %} - - - - - {% endif %} -
Klassendurchschnitt:{{ data.class_average }}
Notenverteilung: - {% for grade, count in data.grade_distribution.items() %} - Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %} - {% endfor %} -
- {% endif %} - -
-

Datum: {{ data.date }}

-
- -
- Erstellt mit BreakPilot | {{ generated_at }} -
- - -""" + return self.jinja_env.from_string(get_correction_template_html()) # Convenience functions for direct usage @@ -793,7 +225,7 @@ _pdf_service: Optional[PDFService] = None def get_pdf_service() -> PDFService: - """Gibt Singleton-Instanz des PDF-Service zurück.""" + """Gibt Singleton-Instanz des PDF-Service zurueck.""" global _pdf_service if _pdf_service is None: _pdf_service = PDFService() diff --git a/backend-core/services/pdf_templates.py b/backend-core/services/pdf_templates.py new file mode 100644 index 0000000..c9533aa --- /dev/null +++ b/backend-core/services/pdf_templates.py @@ -0,0 +1,519 @@ +""" +PDF Templates - Inline HTML-Templates und CSS fuer PDF-Generierung. + +Fallback-Templates die verwendet werden wenn keine externen HTML-Dateien +im templates/pdf/ Verzeichnis vorhanden sind. +""" + + +def get_base_css() -> str: + """Basis-CSS fuer alle PDFs (A4, Typografie, Komponenten-Styles).""" + return """ + @page { + size: A4; + margin: 2cm 2.5cm; + @top-right { + content: counter(page) " / " counter(pages); + font-size: 9pt; + color: #666; + } + } + + body { + font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif; + font-size: 11pt; + line-height: 1.5; + color: #333; + } + + h1, h2, h3 { + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.5em; + } + + h1 { font-size: 16pt; } + h2 { font-size: 14pt; } + h3 { font-size: 12pt; } + + .header { + border-bottom: 2px solid #2c3e50; + padding-bottom: 15px; + margin-bottom: 20px; + } + + .school-name { + font-size: 18pt; + font-weight: bold; + color: #2c3e50; + } + + .school-info { + font-size: 9pt; + color: #666; + } + + .letter-date { + text-align: right; + margin-bottom: 20px; + } + + .recipient { + margin-bottom: 30px; + } + + .subject { + font-weight: bold; + margin-bottom: 20px; + } + + .content { + text-align: justify; + margin-bottom: 30px; + } + + .signature { + margin-top: 40px; + } + + .legal-references { + font-size: 9pt; + color: #666; + border-top: 1px solid #ddd; + margin-top: 30px; + padding-top: 10px; + } + + .gfk-badge { + display: inline-block; + background: #e8f5e9; + color: #27ae60; + font-size: 8pt; + padding: 2px 8px; + border-radius: 10px; + margin-right: 5px; + } + + /* Zeugnis-Styles */ + .certificate-header { + text-align: center; + margin-bottom: 30px; + } + + .certificate-title { + font-size: 20pt; + font-weight: bold; + margin-bottom: 10px; + } + + .student-info { + margin-bottom: 20px; + padding: 15px; + background: #f9f9f9; + border-radius: 5px; + } + + .grades-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + } + + .grades-table th, + .grades-table td { + border: 1px solid #ddd; + padding: 8px 12px; + text-align: left; + } + + .grades-table th { + background: #2c3e50; + color: white; + } + + .grades-table tr:nth-child(even) { + background: #f9f9f9; + } + + .grade-cell { + text-align: center; + font-weight: bold; + font-size: 12pt; + } + + .attendance-box { + background: #fff3cd; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + } + + .signatures-row { + display: flex; + justify-content: space-between; + margin-top: 50px; + } + + .signature-block { + text-align: center; + width: 40%; + } + + .signature-line { + border-top: 1px solid #333; + margin-top: 40px; + padding-top: 5px; + } + + /* Korrektur-Styles */ + .exam-header { + background: #2c3e50; + color: white; + padding: 15px; + margin-bottom: 20px; + } + + .result-box { + background: #e8f5e9; + padding: 20px; + text-align: center; + margin-bottom: 20px; + border-radius: 5px; + } + + .result-grade { + font-size: 36pt; + font-weight: bold; + } + + .result-points { + font-size: 14pt; + color: #666; + } + + .corrections-list { + margin-bottom: 20px; + } + + .correction-item { + border: 1px solid #ddd; + padding: 15px; + margin-bottom: 10px; + border-radius: 5px; + } + + .correction-question { + font-weight: bold; + margin-bottom: 5px; + } + + .correction-feedback { + background: #fff8e1; + padding: 10px; + margin-top: 10px; + border-left: 3px solid #ffc107; + font-size: 10pt; + } + + .stats-table { + width: 100%; + margin-top: 20px; + } + + .stats-table td { + padding: 5px 10px; + } + """ + + +def get_letter_template_html() -> str: + """Inline HTML-Template fuer Elternbriefe.""" + return """ + + + + + {{ data.subject }} + + +
+ {% if data.school_info %} +
{{ data.school_info.name }}
+
+ {{ data.school_info.address }}
+ Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }} + {% if data.school_info.website %} | {{ data.school_info.website }}{% endif %} +
+ {% else %} +
Schule
+ {% endif %} +
+ +
+ {{ data.date }} +
+ +
+ {{ data.recipient_name }}
+ {{ data.recipient_address | replace('\\n', '
') | safe }} +
+ +
+ Betreff: {{ data.subject }} +
+ +
+ Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }} +
+ +
+ {{ data.content | replace('\\n', '
') | safe }} +
+ + {% if data.gfk_principles_applied %} +
+ {% for principle in data.gfk_principles_applied %} + ✓ {{ principle }} + {% endfor %} +
+ {% endif %} + +
+

Mit freundlichen Grüßen

+

+ {{ data.teacher_name }} + {% if data.teacher_title %}
{{ data.teacher_title }}{% endif %} +

+
+ + {% if data.legal_references %} + + {% endif %} + +
+ Erstellt mit BreakPilot | {{ generated_at }} +
+ + +""" + + +def get_certificate_template_html() -> str: + """Inline HTML-Template fuer Zeugnisse.""" + return """ + + + + + Zeugnis - {{ data.student_name }} + + +
+ {% if data.school_info %} +
{{ data.school_info.name }}
+ {% endif %} +
+ {% if data.certificate_type == 'halbjahr' %} + Halbjahreszeugnis + {% elif data.certificate_type == 'jahres' %} + Jahreszeugnis + {% else %} + Abschlusszeugnis + {% endif %} +
+
Schuljahr {{ data.school_year }}
+
+ +
+ + + + + + + + + +
Name: {{ data.student_name }}Geburtsdatum: {{ data.student_birthdate }}
Klasse: {{ data.student_class }} 
+
+ +

Leistungen

+ + + + + + + + + + {% for subject in data.subjects %} + + + + + + {% endfor %} + +
FachNotePunkte
{{ subject.name }} + {{ subject.grade }} + {{ subject.points | default('-') }}
+ + {% if data.social_behavior or data.work_behavior %} +

Verhalten

+ + {% if data.social_behavior %} + + + + + {% endif %} + {% if data.work_behavior %} + + + + + {% endif %} +
Sozialverhalten{{ data.social_behavior }}
Arbeitsverhalten{{ data.work_behavior }}
+ {% endif %} + +
+ Versäumte Tage: {{ data.attendance.days_absent | default(0) }} + (davon entschuldigt: {{ data.attendance.days_excused | default(0) }}, + unentschuldigt: {{ data.attendance.days_unexcused | default(0) }}) +
+ + {% if data.remarks %} +
+ Bemerkungen:
+ {{ data.remarks }} +
+ {% endif %} + +
+ Ausgestellt am: {{ data.issue_date }} +
+ +
+
+
{{ data.class_teacher }}
+
Klassenlehrer/in
+
+
+
{{ data.principal }}
+
Schulleiter/in
+
+
+ +
+
Siegel der Schule
+
+ + +""" + + +def get_correction_template_html() -> str: + """Inline HTML-Template fuer Korrektur-Uebersichten.""" + return """ + + + + + Korrektur - {{ data.exam_title }} + + +
+

{{ data.exam_title }}

+
{{ data.subject }} | {{ data.date }}
+
+ +
+ {{ data.student.name }} | Klasse {{ data.student.class_name }} +
+ +
+
+ Note: {{ data.grade }} +
+
+ {{ data.achieved_points }} von {{ data.max_points }} Punkten + ({{ data.percentage | round(1) }}%) +
+
+ +

Detaillierte Auswertung

+
+ {% for item in data.corrections %} +
+
+ {{ item.question }} +
+ {% if item.answer %} +
+ Antwort: {{ item.answer }} +
+ {% endif %} +
+ Punkte: {{ item.points }} +
+ {% if item.feedback %} +
+ {{ item.feedback }} +
+ {% endif %} +
+ {% endfor %} +
+ + {% if data.teacher_notes %} +
+ Lehrerkommentar:
+ {{ data.teacher_notes }} +
+ {% endif %} + + {% if data.ai_feedback %} +
+ KI-Feedback:
+ {{ data.ai_feedback }} +
+ {% endif %} + + {% if data.class_average or data.grade_distribution %} +

Klassenstatistik

+ + {% if data.class_average %} + + + + + {% endif %} + {% if data.grade_distribution %} + + + + + {% endif %} +
Klassendurchschnitt:{{ data.class_average }}
Notenverteilung: + {% for grade, count in data.grade_distribution.items() %} + Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} + +
+

Datum: {{ data.date }}

+
+ +
+ Erstellt mit BreakPilot | {{ generated_at }} +
+ + +""" diff --git a/consent-service/internal/database/database.go b/consent-service/internal/database/database.go index 9f81bf6..8b23fb8 100644 --- a/consent-service/internal/database/database.go +++ b/consent-service/internal/database/database.go @@ -1,10 +1,11 @@ package database import ( - "context" "fmt" "time" + "context" + "github.com/jackc/pgx/v5/pgxpool" ) @@ -48,1270 +49,22 @@ func (db *DB) Close() { db.Pool.Close() } -// Migrate runs database migrations +// Migrate runs all database migrations in order. func Migrate(db *DB) error { - ctx := context.Background() - - // Create tables - migrations := []string{ - // Users table (extended for full auth) - `CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - external_id VARCHAR(255) UNIQUE, - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255), - name VARCHAR(255), - role VARCHAR(50) DEFAULT 'user', - email_verified BOOLEAN DEFAULT FALSE, - email_verified_at TIMESTAMPTZ, - account_status VARCHAR(20) DEFAULT 'active', - last_login_at TIMESTAMPTZ, - failed_login_attempts INT DEFAULT 0, - locked_until TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Legal documents table - `CREATE TABLE IF NOT EXISTS legal_documents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - is_mandatory BOOLEAN DEFAULT true, - is_active BOOLEAN DEFAULT true, - sort_order INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Document versions table - `CREATE TABLE IF NOT EXISTS 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, - language VARCHAR(5) DEFAULT 'de', - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL, - summary TEXT, - status VARCHAR(20) DEFAULT 'draft', - published_at TIMESTAMPTZ, - scheduled_publish_at TIMESTAMPTZ, - created_by UUID REFERENCES users(id), - approved_by UUID REFERENCES users(id), - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(document_id, version, language) - )`, - - // Add scheduled_publish_at column if not exists (migration) - `ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`, - `ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`, - - // User consents table - `CREATE TABLE IF NOT EXISTS 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 categories table - `CREATE TABLE IF NOT EXISTS cookie_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, - 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(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Cookie consents table - `CREATE TABLE IF NOT EXISTS 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) ON DELETE CASCADE, - consented BOOLEAN NOT NULL, - consented_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, category_id) - )`, - - // Audit log table - `CREATE TABLE IF NOT EXISTS consent_audit_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - action VARCHAR(50) NOT NULL, - entity_type VARCHAR(50), - entity_id UUID, - details JSONB, - ip_address INET, - user_agent TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Data export requests table - `CREATE TABLE IF NOT EXISTS data_export_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - status VARCHAR(20) DEFAULT 'pending', - download_url TEXT, - expires_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - completed_at TIMESTAMPTZ - )`, - - // Data deletion requests table - `CREATE TABLE IF NOT EXISTS data_deletion_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - status VARCHAR(20) DEFAULT 'pending', - reason TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - processed_at TIMESTAMPTZ, - processed_by UUID REFERENCES users(id) - )`, - - // ============================================= - // Phase 1: User Management Tables - // ============================================= - - // Email verification tokens - `CREATE TABLE IF NOT EXISTS email_verification_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - token VARCHAR(255) UNIQUE NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Password reset tokens - `CREATE TABLE IF NOT EXISTS password_reset_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - token VARCHAR(255) UNIQUE NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - ip_address INET, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // User sessions (for JWT revocation and session management) - `CREATE TABLE IF NOT EXISTS user_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - token_hash VARCHAR(255) NOT NULL, - device_info TEXT, - ip_address INET, - user_agent TEXT, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - last_activity_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Phase 3: Version Approvals (DSB Workflow) - // ============================================= - - // Version approval tracking - `CREATE TABLE IF NOT EXISTS version_approvals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE, - approver_id UUID REFERENCES users(id), - action VARCHAR(30) NOT NULL, - comment TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Phase 4: Notification System - // ============================================= - - // Notifications - `CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - type VARCHAR(50) NOT NULL, - channel VARCHAR(20) NOT NULL, - title VARCHAR(255) NOT NULL, - body TEXT NOT NULL, - data JSONB, - read_at TIMESTAMPTZ, - sent_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Push subscriptions for Web Push - `CREATE TABLE IF NOT EXISTS push_subscriptions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - endpoint TEXT NOT NULL, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - user_agent TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, endpoint) - )`, - - // Notification preferences per user - `CREATE TABLE IF NOT EXISTS notification_preferences ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, - email_enabled BOOLEAN DEFAULT TRUE, - push_enabled BOOLEAN DEFAULT TRUE, - in_app_enabled BOOLEAN DEFAULT TRUE, - reminder_frequency VARCHAR(20) DEFAULT 'weekly', - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Phase 5: Consent Deadlines & Account Suspension - // ============================================= - - // Consent deadlines per user per version - `CREATE TABLE IF NOT EXISTS consent_deadlines ( - 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) ON DELETE CASCADE, - deadline_at TIMESTAMPTZ NOT NULL, - reminder_count INT DEFAULT 0, - last_reminder_at TIMESTAMPTZ, - consent_given_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, document_version_id) - )`, - - // Account suspensions tracking - `CREATE TABLE IF NOT EXISTS account_suspensions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - reason VARCHAR(50) NOT NULL, - details JSONB, - suspended_at TIMESTAMPTZ DEFAULT NOW(), - lifted_at TIMESTAMPTZ, - lifted_reason TEXT - )`, - - // ============================================= - // Indexes for performance - // ============================================= - `CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`, - `CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`, - `CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`, - `CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`, - `CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`, - - // Phase 1: Auth indexes - `CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`, - `CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`, - `CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`, - - // Phase 3: Approval indexes - `CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`, - - // Phase 4: Notification indexes - `CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`, - `CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`, - - // Phase 5: Deadline indexes - `CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`, - `CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`, - - // ============================================= - // Phase 6: OAuth 2.0 Authorization Code Flow - // ============================================= - - // OAuth 2.0 Clients - `CREATE TABLE IF NOT EXISTS oauth_clients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - client_id VARCHAR(64) UNIQUE NOT NULL, - client_secret VARCHAR(255), - name VARCHAR(255) NOT NULL, - description TEXT, - redirect_uris JSONB NOT NULL DEFAULT '[]', - scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]', - grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]', - is_public BOOLEAN DEFAULT FALSE, - is_active BOOLEAN DEFAULT TRUE, - created_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // OAuth 2.0 Authorization Codes - `CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(255) UNIQUE NOT NULL, - client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - redirect_uri TEXT NOT NULL, - scopes JSONB NOT NULL DEFAULT '[]', - code_challenge VARCHAR(255), - code_challenge_method VARCHAR(10), - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // OAuth 2.0 Access Tokens - `CREATE TABLE IF NOT EXISTS oauth_access_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token_hash VARCHAR(255) UNIQUE NOT NULL, - client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - scopes JSONB NOT NULL DEFAULT '[]', - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // OAuth 2.0 Refresh Tokens - `CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token_hash VARCHAR(255) UNIQUE NOT NULL, - access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE, - client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - scopes JSONB NOT NULL DEFAULT '[]', - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Phase 7: Two-Factor Authentication (2FA/TOTP) - // ============================================= - - // User TOTP secrets and recovery codes - `CREATE TABLE IF NOT EXISTS user_totp ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE, - secret VARCHAR(255) NOT NULL, - verified BOOLEAN DEFAULT FALSE, - recovery_codes JSONB DEFAULT '[]', - enabled_at TIMESTAMPTZ, - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // 2FA challenges during login - `CREATE TABLE IF NOT EXISTS two_factor_challenges ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - challenge_id VARCHAR(255) UNIQUE NOT NULL, - ip_address INET, - user_agent TEXT, - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Add 2FA required flag to users - `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`, - `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`, - - // Phase 6 & 7 Indexes - `CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`, - `CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`, - `CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`, - `CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`, - `CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`, - `CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`, - - // Insert default OAuth client for BreakPilot PWA (public client with PKCE) - `INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public) - VALUES ( - 'breakpilot-pwa', - 'BreakPilot PWA', - 'Official BreakPilot Progressive Web Application', - '["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]', - '["openid", "profile", "email", "consent:read", "consent:write"]', - '["authorization_code", "refresh_token"]', - true - ) ON CONFLICT (client_id) DO NOTHING`, - - // Insert default cookie categories - `INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order) - VALUES - ('necessary', 'Notwendige Cookies', 'Necessary Cookies', - 'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.', - 'These cookies are essential for the basic functions of the website.', - true, 1), - ('functional', 'Funktionale Cookies', 'Functional Cookies', - 'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.', - 'These cookies enable enhanced functionality and personalization.', - false, 2), - ('analytics', 'Analyse Cookies', 'Analytics Cookies', - 'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.', - 'These cookies help us understand how visitors interact with the website.', - false, 3), - ('marketing', 'Marketing Cookies', 'Marketing Cookies', - 'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.', - 'These cookies are used to make advertising more relevant to you.', - false, 4) - ON CONFLICT (name) DO NOTHING`, - - // Insert default legal documents - `INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order) - VALUES - ('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1), - ('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2), - ('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3), - ('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4) - ON CONFLICT DO NOTHING`, - - // ============================================= - // Phase 8: E-Mail Templates (Transactional) - // ============================================= - - // Email templates (like legal_documents) - `CREATE TABLE IF NOT EXISTS email_templates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) UNIQUE NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - is_active BOOLEAN DEFAULT TRUE, - sort_order INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Email template versions (like document_versions) - `CREATE TABLE IF NOT EXISTS email_template_versions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE, - version VARCHAR(20) NOT NULL, - language VARCHAR(5) DEFAULT 'de', - subject VARCHAR(500) NOT NULL, - body_html TEXT NOT NULL, - body_text TEXT NOT NULL, - summary TEXT, - status VARCHAR(20) DEFAULT 'draft', - published_at TIMESTAMPTZ, - scheduled_publish_at TIMESTAMPTZ, - created_by UUID REFERENCES users(id), - approved_by UUID REFERENCES users(id), - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(template_id, version, language) - )`, - - // Email template approvals (like version_approvals) - `CREATE TABLE IF NOT EXISTS email_template_approvals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE, - approver_id UUID REFERENCES users(id), - action VARCHAR(30) NOT NULL, - comment TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Email send logs for audit - `CREATE TABLE IF NOT EXISTS email_send_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL, - recipient VARCHAR(255) NOT NULL, - subject VARCHAR(500) NOT NULL, - status VARCHAR(20) DEFAULT 'queued', - error_msg TEXT, - variables JSONB, - sent_at TIMESTAMPTZ, - delivered_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Global email settings (logo, colors, signature) - `CREATE TABLE IF NOT EXISTS email_template_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - logo_url TEXT, - logo_base64 TEXT, - company_name VARCHAR(255) DEFAULT 'BreakPilot', - sender_name VARCHAR(255) DEFAULT 'BreakPilot', - sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app', - reply_to_email VARCHAR(255), - footer_html TEXT, - footer_text TEXT, - primary_color VARCHAR(7) DEFAULT '#2563eb', - secondary_color VARCHAR(7) DEFAULT '#64748b', - updated_at TIMESTAMPTZ DEFAULT NOW(), - updated_by UUID REFERENCES users(id) - )`, - - // Insert default email settings - `INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color) - VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b') - ON CONFLICT DO NOTHING`, - - // Phase 8 Indexes - `CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`, - `CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`, - `CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`, - `CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`, - `CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`, - `CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`, - - // ============================================= - // Phase 9: Schulverwaltung / School Management - // Matrix-basierte Kommunikation für Schulen - // ============================================= - - // Schools table - `CREATE TABLE IF NOT EXISTS schools ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - short_name VARCHAR(50), - type VARCHAR(50) NOT NULL, - address TEXT, - city VARCHAR(100), - postal_code VARCHAR(20), - state VARCHAR(50), - country VARCHAR(2) DEFAULT 'DE', - phone VARCHAR(50), - email VARCHAR(255), - website VARCHAR(255), - matrix_server_name VARCHAR(255), - logo_url TEXT, - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // School years - `CREATE TABLE IF NOT EXISTS school_years ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - name VARCHAR(20) NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - is_current BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(school_id, name) - )`, - - // Subjects - `CREATE TABLE IF NOT EXISTS subjects ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - name VARCHAR(100) NOT NULL, - short_name VARCHAR(10) NOT NULL, - color VARCHAR(7), - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(school_id, short_name) - )`, - - // Classes - `CREATE TABLE IF NOT EXISTS classes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, - name VARCHAR(20) NOT NULL, - grade INT NOT NULL, - section VARCHAR(5), - room VARCHAR(50), - matrix_info_room VARCHAR(255), - matrix_rep_room VARCHAR(255), - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(school_id, school_year_id, name) - )`, - - // Students - `CREATE TABLE IF NOT EXISTS students ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - student_number VARCHAR(50), - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - date_of_birth DATE, - gender VARCHAR(1), - matrix_user_id VARCHAR(255), - matrix_dm_room VARCHAR(255), - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Teachers - `CREATE TABLE IF NOT EXISTS teachers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - teacher_code VARCHAR(10), - title VARCHAR(20), - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - matrix_user_id VARCHAR(255), - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(school_id, user_id) - )`, - - // Class teachers assignment - `CREATE TABLE IF NOT EXISTS class_teachers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - is_primary BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(class_id, teacher_id) - )`, - - // Teacher subjects assignment - `CREATE TABLE IF NOT EXISTS teacher_subjects ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(teacher_id, subject_id) - )`, - - // Parents - `CREATE TABLE IF NOT EXISTS parents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - matrix_user_id VARCHAR(255), - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - phone VARCHAR(50), - emergency_contact BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id) - )`, - - // Student-parent relationships - `CREATE TABLE IF NOT EXISTS student_parents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, - parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, - relationship VARCHAR(20) NOT NULL, - is_primary BOOLEAN DEFAULT FALSE, - has_custody BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(student_id, parent_id) - )`, - - // Parent representatives - `CREATE TABLE IF NOT EXISTS parent_representatives ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL, - elected_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ, - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Stundenplan / Timetable - // ============================================= - - // Timetable slots (Stundenraster) - `CREATE TABLE IF NOT EXISTS timetable_slots ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - slot_number INT NOT NULL, - start_time TIME NOT NULL, - end_time TIME NOT NULL, - is_break BOOLEAN DEFAULT FALSE, - name VARCHAR(50), - UNIQUE(school_id, slot_number) - )`, - - // Timetable entries (Stundenplan) - `CREATE TABLE IF NOT EXISTS timetable_entries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, - class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, - day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), - room VARCHAR(50), - valid_from DATE NOT NULL, - valid_until DATE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Timetable substitutions (Vertretungsplan) - `CREATE TABLE IF NOT EXISTS timetable_substitutions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE, - date DATE NOT NULL, - substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL, - substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL, - room VARCHAR(50), - type VARCHAR(20) NOT NULL, - note TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES users(id) - )`, - - // ============================================= - // Abwesenheit / Attendance - // ============================================= - - // Attendance records per lesson - `CREATE TABLE IF NOT EXISTS attendance_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, - timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL, - date DATE NOT NULL, - slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, - status VARCHAR(30) NOT NULL, - recorded_by UUID NOT NULL REFERENCES users(id), - note TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(student_id, date, slot_id) - )`, - - // Absence reports (Krankmeldungen) - `CREATE TABLE IF NOT EXISTS absence_reports ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - reason TEXT, - reason_category VARCHAR(30) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'reported', - reported_by UUID NOT NULL REFERENCES users(id), - reported_at TIMESTAMPTZ DEFAULT NOW(), - confirmed_by UUID REFERENCES users(id), - confirmed_at TIMESTAMPTZ, - medical_certificate BOOLEAN DEFAULT FALSE, - certificate_uploaded BOOLEAN DEFAULT FALSE, - matrix_notification_sent BOOLEAN DEFAULT FALSE, - email_notification_sent BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Absence notifications to parents - `CREATE TABLE IF NOT EXISTS absence_notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE, - parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, - channel VARCHAR(20) NOT NULL, - message_content TEXT NOT NULL, - sent_at TIMESTAMPTZ, - read_at TIMESTAMPTZ, - response_received BOOLEAN DEFAULT FALSE, - response_content TEXT, - response_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Notenspiegel / Grades - // ============================================= - - // Grade scales - `CREATE TABLE IF NOT EXISTS grade_scales ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - name VARCHAR(50) NOT NULL, - min_value DECIMAL(5,2) NOT NULL, - max_value DECIMAL(5,2) NOT NULL, - passing_value DECIMAL(5,2) NOT NULL, - is_ascending BOOLEAN DEFAULT FALSE, - is_default BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Grades - `CREATE TABLE IF NOT EXISTS grades ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, - subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, - grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE, - type VARCHAR(30) NOT NULL, - value DECIMAL(5,2) NOT NULL, - weight DECIMAL(3,2) DEFAULT 1.0, - date DATE NOT NULL, - title VARCHAR(100), - description TEXT, - is_visible BOOLEAN DEFAULT TRUE, - semester INT NOT NULL CHECK (semester IN (1, 2)), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Grade comments - `CREATE TABLE IF NOT EXISTS grade_comments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE, - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - comment TEXT NOT NULL, - is_private BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Klassenbuch / Class Diary - // ============================================= - - // Class diary entries - `CREATE TABLE IF NOT EXISTS class_diary_entries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - date DATE NOT NULL, - slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, - subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - topic TEXT, - homework TEXT, - homework_due_date DATE, - materials TEXT, - notes TEXT, - is_cancelled BOOLEAN DEFAULT FALSE, - cancellation_reason TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(class_id, date, slot_id) - )`, - - // ============================================= - // Elterngespräche / Parent Meetings - // ============================================= - - // Parent meeting slots - `CREATE TABLE IF NOT EXISTS parent_meeting_slots ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, - date DATE NOT NULL, - start_time TIME NOT NULL, - end_time TIME NOT NULL, - location VARCHAR(100), - is_online BOOLEAN DEFAULT FALSE, - meeting_link TEXT, - is_booked BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Parent meetings - `CREATE TABLE IF NOT EXISTS parent_meetings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE, - parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, - student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, - topic TEXT, - notes TEXT, - status VARCHAR(20) NOT NULL DEFAULT 'scheduled', - cancelled_at TIMESTAMPTZ, - cancelled_by UUID REFERENCES users(id), - cancel_reason TEXT, - completed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // ============================================= - // Matrix / Communication Integration - // ============================================= - - // Matrix rooms - `CREATE TABLE IF NOT EXISTS matrix_rooms ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - matrix_room_id VARCHAR(255) NOT NULL UNIQUE, - type VARCHAR(30) NOT NULL, - class_id UUID REFERENCES classes(id) ON DELETE SET NULL, - student_id UUID REFERENCES students(id) ON DELETE SET NULL, - name VARCHAR(255) NOT NULL, - is_encrypted BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Matrix room members - `CREATE TABLE IF NOT EXISTS matrix_room_members ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE, - matrix_user_id VARCHAR(255) NOT NULL, - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - power_level INT DEFAULT 0, - can_write BOOLEAN DEFAULT TRUE, - joined_at TIMESTAMPTZ DEFAULT NOW(), - left_at TIMESTAMPTZ, - UNIQUE(matrix_room_id, matrix_user_id) - )`, - - // Parent onboarding tokens (QR codes) - `CREATE TABLE IF NOT EXISTS parent_onboarding_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, - token VARCHAR(255) NOT NULL UNIQUE, - role VARCHAR(30) NOT NULL DEFAULT 'parent', - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - used_by_user_id UUID REFERENCES users(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES users(id) - )`, - - // ============================================= - // Phase 9 Indexes - // ============================================= - `CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`, - `CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`, - `CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`, - `CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`, - `CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`, - `CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`, - `CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`, - `CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`, - `CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`, - `CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`, - `CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`, - `CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`, - `CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`, - `CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`, - `CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`, - `CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`, - `CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`, - `CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`, - `CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`, - `CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`, - `CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`, - `CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`, - `CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`, - `CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`, - `CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`, - `CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`, - `CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`, - `CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`, - `CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`, - `CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`, - `CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`, - `CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`, - `CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`, - `CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`, - `CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`, - - // Insert default grade scales - `INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default) - SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true - FROM schools s - WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)') - ON CONFLICT DO NOTHING`, - - // Insert default timetable slots for schools - `DO $$ - DECLARE - school_rec RECORD; - BEGIN - FOR school_rec IN SELECT id FROM schools LOOP - INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name) - VALUES - (school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'), - (school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'), - (school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'), - (school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'), - (school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'), - (school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'), - (school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'), - (school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'), - (school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'), - (school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'), - (school_rec.id, 11, '14:45', '15:30', false, '8. Stunde') - ON CONFLICT (school_id, slot_number) DO NOTHING; - END LOOP; - END $$`, - - // ============================================= - // Phase 10: DSGVO Betroffenenanfragen (DSR) - // Data Subject Request Management - // ============================================= - - // Sequence for request numbers - `CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`, - - // Main table: Data Subject Requests - `CREATE TABLE IF NOT EXISTS data_subject_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - request_number VARCHAR(50) UNIQUE NOT NULL, - request_type VARCHAR(30) NOT NULL, - status VARCHAR(30) NOT NULL DEFAULT 'intake', - priority VARCHAR(20) DEFAULT 'normal', - source VARCHAR(30) NOT NULL DEFAULT 'api', - requester_email VARCHAR(255) NOT NULL, - requester_name VARCHAR(255), - requester_phone VARCHAR(50), - identity_verified BOOLEAN DEFAULT FALSE, - identity_verified_at TIMESTAMPTZ, - identity_verified_by UUID REFERENCES users(id), - identity_verification_method VARCHAR(50), - request_details JSONB DEFAULT '{}', - deadline_at TIMESTAMPTZ NOT NULL, - legal_deadline_days INT NOT NULL, - extended_deadline_at TIMESTAMPTZ, - extension_reason TEXT, - assigned_to UUID REFERENCES users(id), - processing_notes TEXT, - completed_at TIMESTAMPTZ, - completed_by UUID REFERENCES users(id), - result_summary TEXT, - result_data JSONB, - rejected_at TIMESTAMPTZ, - rejected_by UUID REFERENCES users(id), - rejection_reason TEXT, - rejection_legal_basis TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES users(id) - )`, - - // DSR Status History for audit trail - `CREATE TABLE IF NOT EXISTS dsr_status_history ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, - from_status VARCHAR(30), - to_status VARCHAR(30) NOT NULL, - changed_by UUID REFERENCES users(id), - comment TEXT, - metadata JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // DSR Communications log - `CREATE TABLE IF NOT EXISTS dsr_communications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, - direction VARCHAR(10) NOT NULL, - channel VARCHAR(20) NOT NULL, - communication_type VARCHAR(50) NOT NULL, - template_version_id UUID, - subject VARCHAR(500), - body_html TEXT, - body_text TEXT, - recipient_email VARCHAR(255), - sent_at TIMESTAMPTZ, - error_message TEXT, - attachments JSONB DEFAULT '[]', - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES users(id) - )`, - - // DSR Templates - `CREATE TABLE IF NOT EXISTS dsr_templates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - template_type VARCHAR(50) UNIQUE NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]', - is_active BOOLEAN DEFAULT TRUE, - sort_order INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // DSR Template Versions - `CREATE TABLE IF NOT EXISTS dsr_template_versions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE, - version VARCHAR(20) NOT NULL, - language VARCHAR(5) DEFAULT 'de', - subject VARCHAR(500) NOT NULL, - body_html TEXT NOT NULL, - body_text TEXT NOT NULL, - status VARCHAR(20) DEFAULT 'draft', - published_at TIMESTAMPTZ, - created_by UUID REFERENCES users(id), - approved_by UUID REFERENCES users(id), - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(template_id, version, language) - )`, - - // DSR Exception Checks (for Art. 17(3) erasure exceptions) - `CREATE TABLE IF NOT EXISTS dsr_exception_checks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, - exception_type VARCHAR(50) NOT NULL, - description TEXT NOT NULL, - applies BOOLEAN, - checked_by UUID REFERENCES users(id), - checked_at TIMESTAMPTZ, - notes TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // Phase 10 Indexes - `CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`, - `CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`, - - // Insert default DSR templates - `INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order) - VALUES - ('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1), - ('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2), - ('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3), - ('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4), - ('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5), - ('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6), - ('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7), - ('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8), - ('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9), - ('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10), - ('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11), - ('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12), - ('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13), - ('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14), - ('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15), - ('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16), - ('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17), - ('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18), - ('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19) - ON CONFLICT (template_type) DO NOTHING`, - - // ============================================= - // Phase 11: EduSearch Seeds Management - // Seed URLs for the education search crawler - // ============================================= - - // EduSearch Seed Categories - `CREATE TABLE IF NOT EXISTS edu_search_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(50) UNIQUE NOT NULL, - display_name VARCHAR(100) NOT NULL, - description TEXT, - icon VARCHAR(10), - sort_order INT DEFAULT 0, - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - )`, - - // EduSearch Seeds (crawler seed URLs) - `CREATE TABLE IF NOT EXISTS edu_search_seeds ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - url VARCHAR(500) UNIQUE NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL, - source_type VARCHAR(20) DEFAULT 'GOV', - scope VARCHAR(20) DEFAULT 'FEDERAL', - state VARCHAR(5), - trust_boost DECIMAL(3,2) DEFAULT 0.50, - enabled BOOLEAN DEFAULT TRUE, - crawl_depth INT DEFAULT 2, - crawl_frequency VARCHAR(20) DEFAULT 'weekly', - last_crawled_at TIMESTAMPTZ, - last_crawl_status VARCHAR(20), - last_crawl_docs INT DEFAULT 0, - total_documents INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES users(id) - )`, - - // EduSearch Crawl Runs (history of crawl executions) - `CREATE TABLE IF NOT EXISTS edu_search_crawl_runs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE, - status VARCHAR(20) DEFAULT 'running', - started_at TIMESTAMPTZ DEFAULT NOW(), - completed_at TIMESTAMPTZ, - pages_crawled INT DEFAULT 0, - documents_indexed INT DEFAULT 0, - errors_count INT DEFAULT 0, - error_details JSONB, - triggered_by UUID REFERENCES users(id) - )`, - - // EduSearch Denylist (URLs/domains to never crawl) - `CREATE TABLE IF NOT EXISTS edu_search_denylist ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - pattern VARCHAR(500) UNIQUE NOT NULL, - pattern_type VARCHAR(20) DEFAULT 'domain', - reason TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES users(id) - )`, - - // Phase 11 Indexes - `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`, - `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`, - `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`, - `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`, - `CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`, - `CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`, - - // Insert default EduSearch categories - `INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order) - VALUES - ('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1), - ('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2), - ('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3), - ('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4), - ('schools', 'Schulen', 'Schulwebsites', '🏫', 5), - ('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6), - ('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7), - ('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8) - ON CONFLICT (name) DO NOTHING`, + if err := migrateCore(db); err != nil { + return err } - - for _, migration := range migrations { - if _, err := db.Pool.Exec(ctx, migration); err != nil { - return fmt.Errorf("failed to run migration: %w", err) - } + if err := migrateOAuth(db); err != nil { + return err + } + if err := migrateEmail(db); err != nil { + return err + } + if err := migrateSchool(db); err != nil { + return err + } + if err := migrateDSR(db); err != nil { + return err } - return nil } diff --git a/consent-service/internal/database/migrate_core.go b/consent-service/internal/database/migrate_core.go new file mode 100644 index 0000000..171f8a8 --- /dev/null +++ b/consent-service/internal/database/migrate_core.go @@ -0,0 +1,307 @@ +package database + +import ( + "context" + "fmt" +) + +// migrateCore creates the core tables: users, auth tokens, sessions, +// documents, versions, consents, cookies, audit, notifications, +// deadlines, suspensions, and their indexes (Phases 1-5). +func migrateCore(db *DB) error { + ctx := context.Background() + + migrations := []string{ + // Users table (extended for full auth) + `CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_id VARCHAR(255) UNIQUE, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255), + name VARCHAR(255), + role VARCHAR(50) DEFAULT 'user', + email_verified BOOLEAN DEFAULT FALSE, + email_verified_at TIMESTAMPTZ, + account_status VARCHAR(20) DEFAULT 'active', + last_login_at TIMESTAMPTZ, + failed_login_attempts INT DEFAULT 0, + locked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Legal documents table + `CREATE TABLE IF NOT EXISTS legal_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_mandatory BOOLEAN DEFAULT true, + is_active BOOLEAN DEFAULT true, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Document versions table + `CREATE TABLE IF NOT EXISTS 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, + language VARCHAR(5) DEFAULT 'de', + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + summary TEXT, + status VARCHAR(20) DEFAULT 'draft', + published_at TIMESTAMPTZ, + scheduled_publish_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(document_id, version, language) + )`, + + // Add scheduled_publish_at column if not exists (migration) + `ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`, + `ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`, + + // User consents table + `CREATE TABLE IF NOT EXISTS 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 categories table + `CREATE TABLE IF NOT EXISTS cookie_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + 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(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Cookie consents table + `CREATE TABLE IF NOT EXISTS 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) ON DELETE CASCADE, + consented BOOLEAN NOT NULL, + consented_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, category_id) + )`, + + // Audit log table + `CREATE TABLE IF NOT EXISTS consent_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50), + entity_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Data export requests table + `CREATE TABLE IF NOT EXISTS data_export_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'pending', + download_url TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ + )`, + + // Data deletion requests table + `CREATE TABLE IF NOT EXISTS data_deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'pending', + reason TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES users(id) + )`, + + // ============================================= + // Phase 1: User Management Tables + // ============================================= + + // Email verification tokens + `CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Password reset tokens + `CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + ip_address INET, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // User sessions (for JWT revocation and session management) + `CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + device_info TEXT, + ip_address INET, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_activity_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 3: Version Approvals (DSB Workflow) + // ============================================= + + // Version approval tracking + `CREATE TABLE IF NOT EXISTS version_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE, + approver_id UUID REFERENCES users(id), + action VARCHAR(30) NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 4: Notification System + // ============================================= + + // Notifications + `CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + channel VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data JSONB, + read_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Push subscriptions for Web Push + `CREATE TABLE IF NOT EXISTS push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, endpoint) + )`, + + // Notification preferences per user + `CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + email_enabled BOOLEAN DEFAULT TRUE, + push_enabled BOOLEAN DEFAULT TRUE, + in_app_enabled BOOLEAN DEFAULT TRUE, + reminder_frequency VARCHAR(20) DEFAULT 'weekly', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 5: Consent Deadlines & Account Suspension + // ============================================= + + // Consent deadlines per user per version + `CREATE TABLE IF NOT EXISTS consent_deadlines ( + 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) ON DELETE CASCADE, + deadline_at TIMESTAMPTZ NOT NULL, + reminder_count INT DEFAULT 0, + last_reminder_at TIMESTAMPTZ, + consent_given_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, document_version_id) + )`, + + // Account suspensions tracking + `CREATE TABLE IF NOT EXISTS account_suspensions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + reason VARCHAR(50) NOT NULL, + details JSONB, + suspended_at TIMESTAMPTZ DEFAULT NOW(), + lifted_at TIMESTAMPTZ, + lifted_reason TEXT + )`, + + // ============================================= + // Indexes for performance + // ============================================= + `CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`, + `CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`, + `CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`, + `CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`, + + // Phase 1: Auth indexes + `CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`, + + // Phase 3: Approval indexes + `CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`, + + // Phase 4: Notification indexes + `CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`, + `CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`, + + // Phase 5: Deadline indexes + `CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`, + `CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("migrateCore: %w", err) + } + } + + return nil +} diff --git a/consent-service/internal/database/migrate_dsr.go b/consent-service/internal/database/migrate_dsr.go new file mode 100644 index 0000000..6664b6f --- /dev/null +++ b/consent-service/internal/database/migrate_dsr.go @@ -0,0 +1,267 @@ +package database + +import ( + "context" + "fmt" +) + +// migrateDSR creates DSGVO Data Subject Request tables (Phase 10) +// and EduSearch seed management tables (Phase 11). +func migrateDSR(db *DB) error { + ctx := context.Background() + + migrations := []string{ + // ============================================= + // Phase 10: DSGVO Betroffenenanfragen (DSR) + // Data Subject Request Management + // ============================================= + + // Sequence for request numbers + `CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`, + + // Main table: Data Subject Requests + `CREATE TABLE IF NOT EXISTS data_subject_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + request_number VARCHAR(50) UNIQUE NOT NULL, + request_type VARCHAR(30) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'intake', + priority VARCHAR(20) DEFAULT 'normal', + source VARCHAR(30) NOT NULL DEFAULT 'api', + requester_email VARCHAR(255) NOT NULL, + requester_name VARCHAR(255), + requester_phone VARCHAR(50), + identity_verified BOOLEAN DEFAULT FALSE, + identity_verified_at TIMESTAMPTZ, + identity_verified_by UUID REFERENCES users(id), + identity_verification_method VARCHAR(50), + request_details JSONB DEFAULT '{}', + deadline_at TIMESTAMPTZ NOT NULL, + legal_deadline_days INT NOT NULL, + extended_deadline_at TIMESTAMPTZ, + extension_reason TEXT, + assigned_to UUID REFERENCES users(id), + processing_notes TEXT, + completed_at TIMESTAMPTZ, + completed_by UUID REFERENCES users(id), + result_summary TEXT, + result_data JSONB, + rejected_at TIMESTAMPTZ, + rejected_by UUID REFERENCES users(id), + rejection_reason TEXT, + rejection_legal_basis TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // DSR Status History for audit trail + `CREATE TABLE IF NOT EXISTS dsr_status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, + from_status VARCHAR(30), + to_status VARCHAR(30) NOT NULL, + changed_by UUID REFERENCES users(id), + comment TEXT, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // DSR Communications log + `CREATE TABLE IF NOT EXISTS dsr_communications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, + direction VARCHAR(10) NOT NULL, + channel VARCHAR(20) NOT NULL, + communication_type VARCHAR(50) NOT NULL, + template_version_id UUID, + subject VARCHAR(500), + body_html TEXT, + body_text TEXT, + recipient_email VARCHAR(255), + sent_at TIMESTAMPTZ, + error_message TEXT, + attachments JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // DSR Templates + `CREATE TABLE IF NOT EXISTS dsr_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_type VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]', + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // DSR Template Versions + `CREATE TABLE IF NOT EXISTS dsr_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, + language VARCHAR(5) DEFAULT 'de', + subject VARCHAR(500) NOT NULL, + body_html TEXT NOT NULL, + body_text TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'draft', + published_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(template_id, version, language) + )`, + + // DSR Exception Checks (for Art. 17(3) erasure exceptions) + `CREATE TABLE IF NOT EXISTS dsr_exception_checks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, + exception_type VARCHAR(50) NOT NULL, + description TEXT NOT NULL, + applies BOOLEAN, + checked_by UUID REFERENCES users(id), + checked_at TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Phase 10 Indexes + `CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`, + + // Insert default DSR templates + `INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order) + VALUES + ('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1), + ('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2), + ('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3), + ('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4), + ('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5), + ('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6), + ('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7), + ('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8), + ('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9), + ('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10), + ('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11), + ('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12), + ('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13), + ('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14), + ('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15), + ('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16), + ('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17), + ('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18), + ('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19) + ON CONFLICT (template_type) DO NOTHING`, + + // ============================================= + // Phase 11: EduSearch Seeds Management + // Seed URLs for the education search crawler + // ============================================= + + // EduSearch Seed Categories + `CREATE TABLE IF NOT EXISTS edu_search_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(50) UNIQUE NOT NULL, + display_name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(10), + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // EduSearch Seeds (crawler seed URLs) + `CREATE TABLE IF NOT EXISTS edu_search_seeds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + url VARCHAR(500) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL, + source_type VARCHAR(20) DEFAULT 'GOV', + scope VARCHAR(20) DEFAULT 'FEDERAL', + state VARCHAR(5), + trust_boost DECIMAL(3,2) DEFAULT 0.50, + enabled BOOLEAN DEFAULT TRUE, + crawl_depth INT DEFAULT 2, + crawl_frequency VARCHAR(20) DEFAULT 'weekly', + last_crawled_at TIMESTAMPTZ, + last_crawl_status VARCHAR(20), + last_crawl_docs INT DEFAULT 0, + total_documents INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // EduSearch Crawl Runs (history of crawl executions) + `CREATE TABLE IF NOT EXISTS edu_search_crawl_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'running', + started_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + pages_crawled INT DEFAULT 0, + documents_indexed INT DEFAULT 0, + errors_count INT DEFAULT 0, + error_details JSONB, + triggered_by UUID REFERENCES users(id) + )`, + + // EduSearch Denylist (URLs/domains to never crawl) + `CREATE TABLE IF NOT EXISTS edu_search_denylist ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pattern VARCHAR(500) UNIQUE NOT NULL, + pattern_type VARCHAR(20) DEFAULT 'domain', + reason TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // Phase 11 Indexes + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`, + + // Insert default EduSearch categories + `INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order) + VALUES + ('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1), + ('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2), + ('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3), + ('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4), + ('schools', 'Schulen', 'Schulwebsites', '🏫', 5), + ('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6), + ('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7), + ('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8) + ON CONFLICT (name) DO NOTHING`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("migrateDSR: %w", err) + } + } + + return nil +} diff --git a/consent-service/internal/database/migrate_email.go b/consent-service/internal/database/migrate_email.go new file mode 100644 index 0000000..54d156e --- /dev/null +++ b/consent-service/internal/database/migrate_email.go @@ -0,0 +1,114 @@ +package database + +import ( + "context" + "fmt" +) + +// migrateEmail creates email template tables, settings, and indexes (Phase 8). +func migrateEmail(db *DB) error { + ctx := context.Background() + + migrations := []string{ + // ============================================= + // Phase 8: E-Mail Templates (Transactional) + // ============================================= + + // Email templates (like legal_documents) + `CREATE TABLE IF NOT EXISTS email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Email template versions (like document_versions) + `CREATE TABLE IF NOT EXISTS email_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, + language VARCHAR(5) DEFAULT 'de', + subject VARCHAR(500) NOT NULL, + body_html TEXT NOT NULL, + body_text TEXT NOT NULL, + summary TEXT, + status VARCHAR(20) DEFAULT 'draft', + published_at TIMESTAMPTZ, + scheduled_publish_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(template_id, version, language) + )`, + + // Email template approvals (like version_approvals) + `CREATE TABLE IF NOT EXISTS email_template_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE, + approver_id UUID REFERENCES users(id), + action VARCHAR(30) NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Email send logs for audit + `CREATE TABLE IF NOT EXISTS email_send_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL, + recipient VARCHAR(255) NOT NULL, + subject VARCHAR(500) NOT NULL, + status VARCHAR(20) DEFAULT 'queued', + error_msg TEXT, + variables JSONB, + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Global email settings (logo, colors, signature) + `CREATE TABLE IF NOT EXISTS email_template_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + logo_url TEXT, + logo_base64 TEXT, + company_name VARCHAR(255) DEFAULT 'BreakPilot', + sender_name VARCHAR(255) DEFAULT 'BreakPilot', + sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app', + reply_to_email VARCHAR(255), + footer_html TEXT, + footer_text TEXT, + primary_color VARCHAR(7) DEFAULT '#2563eb', + secondary_color VARCHAR(7) DEFAULT '#64748b', + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES users(id) + )`, + + // Insert default email settings + `INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color) + VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b') + ON CONFLICT DO NOTHING`, + + // Phase 8 Indexes + `CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`, + `CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`, + `CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`, + `CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`, + `CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("migrateEmail: %w", err) + } + } + + return nil +} diff --git a/consent-service/internal/database/migrate_oauth.go b/consent-service/internal/database/migrate_oauth.go new file mode 100644 index 0000000..050dd1b --- /dev/null +++ b/consent-service/internal/database/migrate_oauth.go @@ -0,0 +1,171 @@ +package database + +import ( + "context" + "fmt" +) + +// migrateOAuth creates OAuth 2.0 and 2FA tables (Phases 6-7), +// plus default seed data for OAuth clients, cookie categories, +// and legal documents. +func migrateOAuth(db *DB) error { + ctx := context.Background() + + migrations := []string{ + // ============================================= + // Phase 6: OAuth 2.0 Authorization Code Flow + // ============================================= + + // OAuth 2.0 Clients + `CREATE TABLE IF NOT EXISTS oauth_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id VARCHAR(64) UNIQUE NOT NULL, + client_secret VARCHAR(255), + name VARCHAR(255) NOT NULL, + description TEXT, + redirect_uris JSONB NOT NULL DEFAULT '[]', + scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]', + grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]', + is_public BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // OAuth 2.0 Authorization Codes + `CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scopes JSONB NOT NULL DEFAULT '[]', + code_challenge VARCHAR(255), + code_challenge_method VARCHAR(10), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // OAuth 2.0 Access Tokens + `CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes JSONB NOT NULL DEFAULT '[]', + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // OAuth 2.0 Refresh Tokens + `CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash VARCHAR(255) UNIQUE NOT NULL, + access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes JSONB NOT NULL DEFAULT '[]', + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 7: Two-Factor Authentication (2FA/TOTP) + // ============================================= + + // User TOTP secrets and recovery codes + `CREATE TABLE IF NOT EXISTS user_totp ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE, + secret VARCHAR(255) NOT NULL, + verified BOOLEAN DEFAULT FALSE, + recovery_codes JSONB DEFAULT '[]', + enabled_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // 2FA challenges during login + `CREATE TABLE IF NOT EXISTS two_factor_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + challenge_id VARCHAR(255) UNIQUE NOT NULL, + ip_address INET, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Add 2FA required flag to users + `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`, + + // Phase 6 & 7 Indexes + `CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`, + `CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`, + + // Insert default OAuth client for BreakPilot PWA (public client with PKCE) + `INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public) + VALUES ( + 'breakpilot-pwa', + 'BreakPilot PWA', + 'Official BreakPilot Progressive Web Application', + '["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]', + '["openid", "profile", "email", "consent:read", "consent:write"]', + '["authorization_code", "refresh_token"]', + true + ) ON CONFLICT (client_id) DO NOTHING`, + + // Insert default cookie categories + `INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order) + VALUES + ('necessary', 'Notwendige Cookies', 'Necessary Cookies', + 'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.', + 'These cookies are essential for the basic functions of the website.', + true, 1), + ('functional', 'Funktionale Cookies', 'Functional Cookies', + 'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.', + 'These cookies enable enhanced functionality and personalization.', + false, 2), + ('analytics', 'Analyse Cookies', 'Analytics Cookies', + 'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.', + 'These cookies help us understand how visitors interact with the website.', + false, 3), + ('marketing', 'Marketing Cookies', 'Marketing Cookies', + 'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.', + 'These cookies are used to make advertising more relevant to you.', + false, 4) + ON CONFLICT (name) DO NOTHING`, + + // Insert default legal documents + `INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order) + VALUES + ('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1), + ('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2), + ('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3), + ('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4) + ON CONFLICT DO NOTHING`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("migrateOAuth: %w", err) + } + } + + return nil +} diff --git a/consent-service/internal/database/migrate_school.go b/consent-service/internal/database/migrate_school.go new file mode 100644 index 0000000..0386224 --- /dev/null +++ b/consent-service/internal/database/migrate_school.go @@ -0,0 +1,182 @@ +package database + +import ( + "context" + "fmt" +) + +// migrateSchool creates school management tables: schools, classes, +// students, teachers, parents, timetable, attendance, grades, +// class diary, parent meetings, Matrix integration (Phase 9). +func migrateSchool(db *DB) error { + ctx := context.Background() + + migrations := []string{ + // ============================================= + // Phase 9: Schulverwaltung / School Management + // Matrix-basierte Kommunikation für Schulen + // ============================================= + + // Schools table + `CREATE TABLE IF NOT EXISTS schools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + short_name VARCHAR(50), + type VARCHAR(50) NOT NULL, + address TEXT, + city VARCHAR(100), + postal_code VARCHAR(20), + state VARCHAR(50), + country VARCHAR(2) DEFAULT 'DE', + phone VARCHAR(50), + email VARCHAR(255), + website VARCHAR(255), + matrix_server_name VARCHAR(255), + logo_url TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // School years + `CREATE TABLE IF NOT EXISTS school_years ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + is_current BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, name) + )`, + + // Subjects + `CREATE TABLE IF NOT EXISTS subjects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + short_name VARCHAR(10) NOT NULL, + color VARCHAR(7), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, short_name) + )`, + + // Classes + `CREATE TABLE IF NOT EXISTS classes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + name VARCHAR(20) NOT NULL, + grade INT NOT NULL, + section VARCHAR(5), + room VARCHAR(50), + matrix_info_room VARCHAR(255), + matrix_rep_room VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, school_year_id, name) + )`, + + // Students + `CREATE TABLE IF NOT EXISTS students ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + student_number VARCHAR(50), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + date_of_birth DATE, + gender VARCHAR(1), + matrix_user_id VARCHAR(255), + matrix_dm_room VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Teachers + `CREATE TABLE IF NOT EXISTS teachers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + teacher_code VARCHAR(10), + title VARCHAR(20), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + matrix_user_id VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, user_id) + )`, + + // Class teachers assignment + `CREATE TABLE IF NOT EXISTS class_teachers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id, teacher_id) + )`, + + // Teacher subjects assignment + `CREATE TABLE IF NOT EXISTS teacher_subjects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id, subject_id) + )`, + + // Parents + `CREATE TABLE IF NOT EXISTS parents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + matrix_user_id VARCHAR(255), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + phone VARCHAR(50), + emergency_contact BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id) + )`, + + // Student-parent relationships + `CREATE TABLE IF NOT EXISTS student_parents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + relationship VARCHAR(20) NOT NULL, + is_primary BOOLEAN DEFAULT FALSE, + has_custody BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(student_id, parent_id) + )`, + + // Parent representatives + `CREATE TABLE IF NOT EXISTS parent_representatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + elected_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("migrateSchool: %w", err) + } + } + + // Run the second batch (timetable, attendance, grades, etc.) + return migrateSchoolPart2(db) +} diff --git a/consent-service/internal/database/migrate_school_ext.go b/consent-service/internal/database/migrate_school_ext.go new file mode 100644 index 0000000..131a487 --- /dev/null +++ b/consent-service/internal/database/migrate_school_ext.go @@ -0,0 +1,346 @@ +package database + +import ( + "context" + "fmt" +) + +// migrateSchoolPart2 creates timetable, attendance, grades, diary, +// meetings, Matrix, and Phase 9 indexes/seed data. +func migrateSchoolPart2(db *DB) error { + ctx := context.Background() + + migrations := []string{ + // ============================================= + // Stundenplan / Timetable + // ============================================= + + // Timetable slots (Stundenraster) + `CREATE TABLE IF NOT EXISTS timetable_slots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + slot_number INT NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + is_break BOOLEAN DEFAULT FALSE, + name VARCHAR(50), + UNIQUE(school_id, slot_number) + )`, + + // Timetable entries (Stundenplan) + `CREATE TABLE IF NOT EXISTS timetable_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, + day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), + room VARCHAR(50), + valid_from DATE NOT NULL, + valid_until DATE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Timetable substitutions (Vertretungsplan) + `CREATE TABLE IF NOT EXISTS timetable_substitutions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE, + date DATE NOT NULL, + substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL, + substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL, + room VARCHAR(50), + type VARCHAR(20) NOT NULL, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES users(id) + )`, + + // ============================================= + // Abwesenheit / Attendance + // ============================================= + + // Attendance records per lesson + `CREATE TABLE IF NOT EXISTS attendance_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL, + date DATE NOT NULL, + slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, + status VARCHAR(30) NOT NULL, + recorded_by UUID NOT NULL REFERENCES users(id), + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(student_id, date, slot_id) + )`, + + // Absence reports (Krankmeldungen) + `CREATE TABLE IF NOT EXISTS absence_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + reason TEXT, + reason_category VARCHAR(30) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'reported', + reported_by UUID NOT NULL REFERENCES users(id), + reported_at TIMESTAMPTZ DEFAULT NOW(), + confirmed_by UUID REFERENCES users(id), + confirmed_at TIMESTAMPTZ, + medical_certificate BOOLEAN DEFAULT FALSE, + certificate_uploaded BOOLEAN DEFAULT FALSE, + matrix_notification_sent BOOLEAN DEFAULT FALSE, + email_notification_sent BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Absence notifications to parents + `CREATE TABLE IF NOT EXISTS absence_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + channel VARCHAR(20) NOT NULL, + message_content TEXT NOT NULL, + sent_at TIMESTAMPTZ, + read_at TIMESTAMPTZ, + response_received BOOLEAN DEFAULT FALSE, + response_content TEXT, + response_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Notenspiegel / Grades + // ============================================= + + // Grade scales + `CREATE TABLE IF NOT EXISTS grade_scales ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + min_value DECIMAL(5,2) NOT NULL, + max_value DECIMAL(5,2) NOT NULL, + passing_value DECIMAL(5,2) NOT NULL, + is_ascending BOOLEAN DEFAULT FALSE, + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Grades + `CREATE TABLE IF NOT EXISTS grades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE, + type VARCHAR(30) NOT NULL, + value DECIMAL(5,2) NOT NULL, + weight DECIMAL(3,2) DEFAULT 1.0, + date DATE NOT NULL, + title VARCHAR(100), + description TEXT, + is_visible BOOLEAN DEFAULT TRUE, + semester INT NOT NULL CHECK (semester IN (1, 2)), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Grade comments + `CREATE TABLE IF NOT EXISTS grade_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + comment TEXT NOT NULL, + is_private BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Klassenbuch / Class Diary + // ============================================= + + // Class diary entries + `CREATE TABLE IF NOT EXISTS class_diary_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + date DATE NOT NULL, + slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + topic TEXT, + homework TEXT, + homework_due_date DATE, + materials TEXT, + notes TEXT, + is_cancelled BOOLEAN DEFAULT FALSE, + cancellation_reason TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id, date, slot_id) + )`, + + // ============================================= + // Elterngespräche / Parent Meetings + // ============================================= + + // Parent meeting slots + `CREATE TABLE IF NOT EXISTS parent_meeting_slots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + date DATE NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + location VARCHAR(100), + is_online BOOLEAN DEFAULT FALSE, + meeting_link TEXT, + is_booked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Parent meetings + `CREATE TABLE IF NOT EXISTS parent_meetings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + topic TEXT, + notes TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'scheduled', + cancelled_at TIMESTAMPTZ, + cancelled_by UUID REFERENCES users(id), + cancel_reason TEXT, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Matrix / Communication Integration + // ============================================= + + // Matrix rooms + `CREATE TABLE IF NOT EXISTS matrix_rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + matrix_room_id VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(30) NOT NULL, + class_id UUID REFERENCES classes(id) ON DELETE SET NULL, + student_id UUID REFERENCES students(id) ON DELETE SET NULL, + name VARCHAR(255) NOT NULL, + is_encrypted BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Matrix room members + `CREATE TABLE IF NOT EXISTS matrix_room_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE, + matrix_user_id VARCHAR(255) NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + power_level INT DEFAULT 0, + can_write BOOLEAN DEFAULT TRUE, + joined_at TIMESTAMPTZ DEFAULT NOW(), + left_at TIMESTAMPTZ, + UNIQUE(matrix_room_id, matrix_user_id) + )`, + + // Parent onboarding tokens (QR codes) + `CREATE TABLE IF NOT EXISTS parent_onboarding_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + role VARCHAR(30) NOT NULL DEFAULT 'parent', + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + used_by_user_id UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES users(id) + )`, + + // ============================================= + // Phase 9 Indexes + // ============================================= + `CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`, + `CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`, + `CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`, + `CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`, + `CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`, + `CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`, + `CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`, + `CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`, + `CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`, + + // Insert default grade scales + `INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default) + SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true + FROM schools s + WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)') + ON CONFLICT DO NOTHING`, + + // Insert default timetable slots for schools + `DO $$ + DECLARE + school_rec RECORD; + BEGIN + FOR school_rec IN SELECT id FROM schools LOOP + INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name) + VALUES + (school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'), + (school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'), + (school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'), + (school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'), + (school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'), + (school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'), + (school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'), + (school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'), + (school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'), + (school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'), + (school_rec.id, 11, '14:45', '15:30', false, '8. Stunde') + ON CONFLICT (school_id, slot_number) DO NOTHING; + END LOOP; + END $$`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("migrateSchool: %w", err) + } + } + + return nil +} diff --git a/consent-service/internal/handlers/admin_approval.go b/consent-service/internal/handlers/admin_approval.go new file mode 100644 index 0000000..e31d7df --- /dev/null +++ b/consent-service/internal/handlers/admin_approval.go @@ -0,0 +1,455 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// ADMIN ENDPOINTS - Version Approval Workflow (DSB) +// ======================================== + +// AdminSubmitForReview submits a version for DSB review +func (h *Handler) AdminSubmitForReview(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Check current status + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "draft" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"}) + return + } + + // Update status to review + _, err = h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'review', updated_at = NOW() + WHERE id = $1 + `, versionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"}) + return + } + + // Log approval action + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO version_approvals (version_id, approver_id, action, comment) + VALUES ($1, $2, 'submitted', 'Submitted for DSB review') + `, versionID, userID) + + h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"}) +} + +// AdminApproveVersion approves a version with scheduled publish date (DSB only) +func (h *Handler) AdminApproveVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + // Check if user is DSB or Admin (for dev purposes) + if !middleware.IsDSB(c) && !middleware.IsAdmin(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"}) + return + } + + var req struct { + Comment string `json:"comment"` + ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z" + } + c.ShouldBindJSON(&req) + + // Validate scheduled publish date + var scheduledAt *time.Time + if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" { + parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"}) + return + } + if parsed.Before(time.Now()) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"}) + return + } + scheduledAt = &parsed + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Check current status + var status string + var createdBy *uuid.UUID + err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"}) + return + } + + // Four-eyes principle: DSB cannot approve their own version + // Exception: Admins can approve their own versions for development/testing purposes + role, _ := c.Get("role") + roleStr, _ := role.(string) + if createdBy != nil && *createdBy == userID && roleStr != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"}) + return + } + + // Determine new status: 'scheduled' if date set, otherwise 'approved' + newStatus := "approved" + if scheduledAt != nil { + newStatus = "scheduled" + } + + // Update status to approved/scheduled + _, err = h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW() + WHERE id = $1 + `, versionID, newStatus, userID, scheduledAt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"}) + return + } + + // Log approval action + comment := req.Comment + if comment == "" { + if scheduledAt != nil { + comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04") + } else { + comment = "Approved by DSB" + } + } + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO version_approvals (version_id, approver_id, action, comment) + VALUES ($1, $2, 'approved', $3) + `, versionID, userID, comment) + + h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent) + + response := gin.H{"message": "Version approved", "status": newStatus} + if scheduledAt != nil { + response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339) + } + c.JSON(http.StatusOK, response) +} + +// AdminRejectVersion rejects a version (DSB only) +func (h *Handler) AdminRejectVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + // Check if user is DSB + if !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"}) + return + } + + var req struct { + Comment string `json:"comment" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Check current status + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "review" && status != "approved" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"}) + return + } + + // Update status back to draft + _, err = h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'draft', approved_by = NULL, updated_at = NOW() + WHERE id = $1 + `, versionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"}) + return + } + + // Log rejection + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO version_approvals (version_id, approver_id, action, comment) + VALUES ($1, $2, 'rejected', $3) + `, versionID, userID, req.Comment) + + h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"}) +} + +// AdminCompareVersions returns two versions for side-by-side comparison +func (h *Handler) AdminCompareVersions(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + // Get the current version and its document + var currentVersion models.DocumentVersion + var documentID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at + FROM document_versions + WHERE id = $1 + `, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language, + ¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status, + ¤tVersion.CreatedAt, ¤tVersion.UpdatedAt) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + // Get the currently published version (if any) + var publishedVersion *models.DocumentVersion + var pv models.DocumentVersion + err = h.db.Pool.QueryRow(ctx, ` + SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at + FROM document_versions + WHERE document_id = $1 AND language = $2 AND status = 'published' + ORDER BY published_at DESC + LIMIT 1 + `, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language, + &pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt) + + if err == nil && pv.ID != currentVersion.ID { + publishedVersion = &pv + } + + // Get approval history + rows, err := h.db.Pool.Query(ctx, ` + SELECT va.action, va.comment, va.created_at, u.email + FROM version_approvals va + LEFT JOIN users u ON va.approver_id = u.id + WHERE va.version_id = $1 + ORDER BY va.created_at DESC + `, versionID) + + var approvalHistory []map[string]interface{} + if err == nil { + defer rows.Close() + for rows.Next() { + var action, email string + var comment *string + var createdAt time.Time + if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil { + approvalHistory = append(approvalHistory, map[string]interface{}{ + "action": action, + "comment": comment, + "created_at": createdAt, + "approver": email, + }) + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "current_version": currentVersion, + "published_version": publishedVersion, + "approval_history": approvalHistory, + }) +} + +// AdminGetApprovalHistory returns the approval history for a version +func (h *Handler) AdminGetApprovalHistory(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name + FROM version_approvals va + LEFT JOIN users u ON va.approver_id = u.id + WHERE va.version_id = $1 + ORDER BY va.created_at DESC + `, versionID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"}) + return + } + defer rows.Close() + + var history []map[string]interface{} + for rows.Next() { + var id uuid.UUID + var action string + var comment *string + var createdAt time.Time + var email, name *string + + if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil { + continue + } + + history = append(history, map[string]interface{}{ + "id": id, + "action": action, + "comment": comment, + "created_at": createdAt, + "approver": email, + "name": name, + }) + } + + c.JSON(http.StatusOK, gin.H{"approval_history": history}) +} + +// ======================================== +// SCHEDULED PUBLISHING +// ======================================== + +// ProcessScheduledPublishing publishes all versions that are due +// This should be called by a cron job or scheduler +func (h *Handler) ProcessScheduledPublishing(c *gin.Context) { + ctx := context.Background() + + // Find all scheduled versions that are due + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, document_id, version + FROM document_versions + WHERE status = 'scheduled' + AND scheduled_publish_at IS NOT NULL + AND scheduled_publish_at <= NOW() + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"}) + return + } + defer rows.Close() + + var published []string + for rows.Next() { + var versionID, docID uuid.UUID + var version string + if err := rows.Scan(&versionID, &docID, &version); err != nil { + continue + } + + // Publish this version + _, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'published', published_at = NOW(), updated_at = NOW() + WHERE id = $1 + `, versionID) + + if err == nil { + // Archive previous published versions for this document + h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'archived', updated_at = NOW() + WHERE document_id = $1 AND id != $2 AND status = 'published' + `, docID, versionID) + + // Log the publishing + details := fmt.Sprintf("Version %s automatically published by scheduler", version) + h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler") + + published = append(published, version) + } + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Scheduled publishing processed", + "published_count": len(published), + "published_versions": published, + }) +} + +// GetScheduledVersions returns all versions scheduled for publishing +func (h *Handler) GetScheduledVersions(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name + FROM document_versions dv + JOIN legal_documents ld ON ld.id = dv.document_id + WHERE dv.status = 'scheduled' + AND dv.scheduled_publish_at IS NOT NULL + ORDER BY dv.scheduled_publish_at ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"}) + return + } + defer rows.Close() + + type ScheduledVersion struct { + ID uuid.UUID `json:"id"` + DocumentID uuid.UUID `json:"document_id"` + Version string `json:"version"` + Title string `json:"title"` + ScheduledPublishAt *time.Time `json:"scheduled_publish_at"` + DocumentName string `json:"document_name"` + } + + var versions []ScheduledVersion + for rows.Next() { + var v ScheduledVersion + if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil { + continue + } + versions = append(versions, v) + } + + c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions}) +} diff --git a/consent-service/internal/handlers/admin_documents.go b/consent-service/internal/handlers/admin_documents.go new file mode 100644 index 0000000..fa0f174 --- /dev/null +++ b/consent-service/internal/handlers/admin_documents.go @@ -0,0 +1,391 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// ADMIN ENDPOINTS - Document Management +// ======================================== + +// AdminGetDocuments returns all documents (including inactive) for admin +func (h *Handler) AdminGetDocuments(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at + FROM legal_documents + ORDER BY sort_order ASC, created_at DESC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) + return + } + defer rows.Close() + + var documents []models.LegalDocument + for rows.Next() { + var doc models.LegalDocument + if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, + &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { + continue + } + documents = append(documents, doc) + } + + c.JSON(http.StatusOK, gin.H{"documents": documents}) +} + +// AdminCreateDocument creates a new legal document +func (h *Handler) AdminCreateDocument(c *gin.Context) { + var req models.CreateDocumentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + var docID uuid.UUID + err := h.db.Pool.QueryRow(ctx, ` + INSERT INTO legal_documents (type, name, description, is_mandatory) + VALUES ($1, $2, $3, $4) + RETURNING id + `, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Document created successfully", + "id": docID, + }) +} + +// AdminUpdateDocument updates a legal document +func (h *Handler) AdminUpdateDocument(c *gin.Context) { + docID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + IsMandatory *bool `json:"is_mandatory"` + IsActive *bool `json:"is_active"` + SortOrder *int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE legal_documents + SET name = COALESCE($2, name), + description = COALESCE($3, description), + is_mandatory = COALESCE($4, is_mandatory), + is_active = COALESCE($5, is_active), + sort_order = COALESCE($6, sort_order), + updated_at = NOW() + WHERE id = $1 + `, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"}) +} + +// AdminDeleteDocument soft-deletes a document (sets is_active to false) +func (h *Handler) AdminDeleteDocument(c *gin.Context) { + docID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE legal_documents + SET is_active = false, updated_at = NOW() + WHERE id = $1 + `, docID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) +} + +// ======================================== +// ADMIN ENDPOINTS - Version Management +// ======================================== + +// AdminGetVersions returns all versions for a document +func (h *Handler) AdminGetVersions(c *gin.Context) { + docID, err := uuid.Parse(c.Param("docId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, document_id, version, language, title, content, summary, status, + published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at + FROM document_versions + WHERE document_id = $1 + ORDER BY created_at DESC + `, docID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) + return + } + defer rows.Close() + + var versions []models.DocumentVersion + for rows.Next() { + var v models.DocumentVersion + if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content, + &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil { + continue + } + versions = append(versions, v) + } + + c.JSON(http.StatusOK, gin.H{"versions": versions}) +} + +// AdminCreateVersion creates a new document version +func (h *Handler) AdminCreateVersion(c *gin.Context) { + var req models.CreateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + docID, err := uuid.Parse(req.DocumentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + + var versionID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7) + RETURNING id + `, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID) + + if err != nil { + // Check for unique constraint violation + errStr := err.Error() + if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") { + c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"}) + return + } + // Log the actual error for debugging + fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Version created successfully", + "id": versionID, + }) +} + +// AdminUpdateVersion updates a document version +func (h *Handler) AdminUpdateVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + var req models.UpdateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + // Check if version is in draft or review status (only these can be edited) + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "draft" && status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"}) + return + } + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET title = COALESCE($2, title), + content = COALESCE($3, content), + summary = COALESCE($4, summary), + status = COALESCE($5, status), + updated_at = NOW() + WHERE id = $1 + `, versionID, req.Title, req.Content, req.Summary, req.Status) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"}) +} + +// AdminPublishVersion publishes a document version +func (h *Handler) AdminPublishVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + + // Check current status + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "approved" && status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"}) + return + } + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'published', + published_at = NOW(), + approved_by = $2, + updated_at = NOW() + WHERE id = $1 + `, versionID, userID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"}) +} + +// AdminArchiveVersion archives a document version +func (h *Handler) AdminArchiveVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'archived', updated_at = NOW() + WHERE id = $1 + `, versionID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"}) +} + +// AdminDeleteVersion permanently deletes a draft/rejected version +// Only draft and rejected versions can be deleted. Published versions must be archived. +func (h *Handler) AdminDeleteVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + // First check the version status - only draft/rejected can be deleted + var status string + var version string + var docID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + SELECT status, version, document_id FROM document_versions WHERE id = $1 + `, versionID).Scan(&status, &version, &docID) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + // Only allow deletion of draft and rejected versions + if status != "draft" && status != "rejected" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Cannot delete version", + "message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.", + "status": status, + }) + return + } + + // Delete the version + result, err := h.db.Pool.Exec(ctx, ` + DELETE FROM document_versions WHERE id = $1 + `, versionID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"}) + return + } + + // Log the deletion + userID, _ := c.Get("user_id") + h.db.Pool.Exec(ctx, ` + INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent) + VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5) + `, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent()) + + c.JSON(http.StatusOK, gin.H{ + "message": "Version deleted successfully", + "deleted_version": version, + "version_id": versionID, + }) +} diff --git a/consent-service/internal/handlers/admin_operations.go b/consent-service/internal/handlers/admin_operations.go new file mode 100644 index 0000000..ca69a67 --- /dev/null +++ b/consent-service/internal/handlers/admin_operations.go @@ -0,0 +1,319 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// ADMIN ENDPOINTS - Cookie Categories +// ======================================== + +// AdminGetCookieCategories returns all cookie categories +func (h *Handler) AdminGetCookieCategories(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, name, display_name_de, display_name_en, description_de, description_en, + is_mandatory, sort_order, is_active, created_at, updated_at + FROM cookie_categories + ORDER BY sort_order ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"}) + return + } + defer rows.Close() + + var categories []models.CookieCategory + for rows.Next() { + var cat models.CookieCategory + if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN, + &cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder, + &cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil { + continue + } + categories = append(categories, cat) + } + + c.JSON(http.StatusOK, gin.H{"categories": categories}) +} + +// AdminCreateCookieCategory creates a new cookie category +func (h *Handler) AdminCreateCookieCategory(c *gin.Context) { + var req models.CreateCookieCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + var catID uuid.UUID + err := h.db.Pool.QueryRow(ctx, ` + INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Cookie category created successfully", + "id": catID, + }) +} + +// AdminUpdateCookieCategory updates a cookie category +func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) { + catID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"}) + return + } + + var req struct { + DisplayNameDE *string `json:"display_name_de"` + DisplayNameEN *string `json:"display_name_en"` + DescriptionDE *string `json:"description_de"` + DescriptionEN *string `json:"description_en"` + IsMandatory *bool `json:"is_mandatory"` + SortOrder *int `json:"sort_order"` + IsActive *bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE cookie_categories + SET display_name_de = COALESCE($2, display_name_de), + display_name_en = COALESCE($3, display_name_en), + description_de = COALESCE($4, description_de), + description_en = COALESCE($5, description_en), + is_mandatory = COALESCE($6, is_mandatory), + sort_order = COALESCE($7, sort_order), + is_active = COALESCE($8, is_active), + updated_at = NOW() + WHERE id = $1 + `, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, + req.IsMandatory, req.SortOrder, req.IsActive) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"}) +} + +// AdminDeleteCookieCategory soft-deletes a cookie category +func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) { + catID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE cookie_categories + SET is_active = false, updated_at = NOW() + WHERE id = $1 + `, catID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"}) +} + +// ======================================== +// ADMIN ENDPOINTS - Statistics & Audit +// ======================================== + +// GetConsentStats returns consent statistics +func (h *Handler) GetConsentStats(c *gin.Context) { + ctx := context.Background() + docType := c.Query("document_type") + + var stats models.ConsentStats + + // Total users + h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers) + + // Consented users (with active consent) + query := ` + SELECT COUNT(DISTINCT uc.user_id) + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.consented = true AND uc.withdrawn_at IS NULL + ` + if docType != "" { + query += ` AND ld.type = $1` + h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers) + } else { + h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers) + } + + // Calculate consent rate + if stats.TotalUsers > 0 { + stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100 + } + + // Recent consents (last 7 days) + h.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM user_consents + WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days' + `).Scan(&stats.RecentConsents) + + // Recent withdrawals + h.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM user_consents + WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days' + `).Scan(&stats.RecentWithdrawals) + + c.JSON(http.StatusOK, stats) +} + +// GetCookieStats returns cookie consent statistics +func (h *Handler) GetCookieStats(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT cat.name, + COUNT(DISTINCT u.id) as total_users, + COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users + FROM cookie_categories cat + CROSS JOIN users u + LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id + WHERE cat.is_active = true + GROUP BY cat.id, cat.name + ORDER BY cat.sort_order + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"}) + return + } + defer rows.Close() + + var stats []models.CookieStats + for rows.Next() { + var s models.CookieStats + if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil { + continue + } + if s.TotalUsers > 0 { + s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100 + } + stats = append(stats, s) + } + + c.JSON(http.StatusOK, gin.H{"cookie_stats": stats}) +} + +// GetAuditLog returns audit log entries +func (h *Handler) GetAuditLog(c *gin.Context) { + ctx := context.Background() + + // Pagination + limit := 50 + offset := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + // Filters + userIDFilter := c.Query("user_id") + actionFilter := c.Query("action") + + query := ` + SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details, + al.ip_address, al.user_agent, al.created_at, u.email + FROM consent_audit_log al + LEFT JOIN users u ON al.user_id = u.id + WHERE 1=1 + ` + args := []interface{}{} + argCount := 0 + + if userIDFilter != "" { + argCount++ + query += fmt.Sprintf(" AND al.user_id = $%d", argCount) + args = append(args, userIDFilter) + } + if actionFilter != "" { + argCount++ + query += fmt.Sprintf(" AND al.action = $%d", argCount) + args = append(args, actionFilter) + } + + query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2) + args = append(args, limit, offset) + + rows, err := h.db.Pool.Query(ctx, query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"}) + return + } + defer rows.Close() + + var logs []map[string]interface{} + for rows.Next() { + var ( + id uuid.UUID + userIDPtr *uuid.UUID + action string + entityType *string + entityID *uuid.UUID + details *string + ipAddress *string + userAgent *string + createdAt time.Time + email *string + ) + + if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details, + &ipAddress, &userAgent, &createdAt, &email); err != nil { + continue + } + + logs = append(logs, map[string]interface{}{ + "id": id, + "user_id": userIDPtr, + "user_email": email, + "action": action, + "entity_type": entityType, + "entity_id": entityID, + "details": details, + "ip_address": ipAddress, + "user_agent": userAgent, + "created_at": createdAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"audit_log": logs}) +} diff --git a/consent-service/internal/handlers/banner_config_handlers.go b/consent-service/internal/handlers/banner_config_handlers.go new file mode 100644 index 0000000..1f699b0 --- /dev/null +++ b/consent-service/internal/handlers/banner_config_handlers.go @@ -0,0 +1,265 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// GetSiteConfig gibt die Konfiguration für eine Site zurück +// GET /api/v1/banner/config/:siteId +func (h *Handler) GetSiteConfig(c *gin.Context) { + siteID := c.Param("siteId") + + // Standard-Kategorien (aus Datenbank oder Default) + categories := []CategoryConfig{ + { + ID: "essential", + Name: map[string]string{ + "de": "Essentiell", + "en": "Essential", + }, + Description: map[string]string{ + "de": "Notwendig für die Grundfunktionen der Website.", + "en": "Required for basic website functionality.", + }, + Required: true, + Vendors: []VendorConfig{}, + }, + { + ID: "functional", + Name: map[string]string{ + "de": "Funktional", + "en": "Functional", + }, + Description: map[string]string{ + "de": "Ermöglicht Personalisierung und Komfortfunktionen.", + "en": "Enables personalization and comfort features.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + { + ID: "analytics", + Name: map[string]string{ + "de": "Statistik", + "en": "Analytics", + }, + Description: map[string]string{ + "de": "Hilft uns, die Website zu verbessern.", + "en": "Helps us improve the website.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + { + ID: "marketing", + Name: map[string]string{ + "de": "Marketing", + "en": "Marketing", + }, + Description: map[string]string{ + "de": "Ermöglicht personalisierte Werbung.", + "en": "Enables personalized advertising.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + { + ID: "social", + Name: map[string]string{ + "de": "Soziale Medien", + "en": "Social Media", + }, + Description: map[string]string{ + "de": "Ermöglicht Inhalte von sozialen Netzwerken.", + "en": "Enables content from social networks.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + } + + config := SiteConfig{ + SiteID: siteID, + SiteName: "BreakPilot", + Categories: categories, + UI: UIConfig{ + Theme: "auto", + Position: "bottom", + }, + Legal: LegalConfig{ + PrivacyPolicyURL: "/datenschutz", + ImprintURL: "/impressum", + }, + } + + c.JSON(http.StatusOK, config) +} + +// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20) +// GET /api/v1/banner/consent/export?userId=xxx +func (h *Handler) ExportBannerConsent(c *gin.Context) { + userID := c.Query("userId") + + if userID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "missing_user_id", + "message": "userId parameter is required", + }) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, site_id, device_fingerprint, categories, vendors, + version, created_at, updated_at, revoked_at + FROM banner_consents + WHERE user_id = $1 + ORDER BY created_at DESC + `, userID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "export_failed", + "message": "Failed to export consent data", + }) + return + } + defer rows.Close() + + var consents []map[string]interface{} + for rows.Next() { + var id, siteID, deviceFingerprint, version string + var categoriesJSON, vendorsJSON []byte + var createdAt, updatedAt time.Time + var revokedAt *time.Time + + rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON, + &version, &createdAt, &updatedAt, &revokedAt) + + var categories, vendors map[string]bool + json.Unmarshal(categoriesJSON, &categories) + json.Unmarshal(vendorsJSON, &vendors) + + consent := map[string]interface{}{ + "consentId": id, + "siteId": siteID, + "consent": map[string]interface{}{ + "categories": categories, + "vendors": vendors, + }, + "createdAt": createdAt.UTC().Format(time.RFC3339), + "revokedAt": nil, + } + + if revokedAt != nil { + consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339) + } + + consents = append(consents, consent) + } + + c.JSON(http.StatusOK, gin.H{ + "userId": userID, + "exportedAt": time.Now().UTC().Format(time.RFC3339), + "consents": consents, + }) +} + +// GetBannerStats gibt anonymisierte Statistiken zurück (Admin) +// GET /api/v1/banner/admin/stats/:siteId +func (h *Handler) GetBannerStats(c *gin.Context) { + siteID := c.Param("siteId") + + ctx := context.Background() + + // Gesamtanzahl Consents + var totalConsents int + h.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM banner_consents + WHERE site_id = $1 AND revoked_at IS NULL + `, siteID).Scan(&totalConsents) + + // Consent-Rate pro Kategorie + categoryStats := make(map[string]map[string]interface{}) + + rows, _ := h.db.Pool.Query(ctx, ` + SELECT + key as category, + COUNT(*) FILTER (WHERE value::text = 'true') as accepted, + COUNT(*) as total + FROM banner_consents, + jsonb_each(categories::jsonb) + WHERE site_id = $1 AND revoked_at IS NULL + GROUP BY key + `, siteID) + + if rows != nil { + defer rows.Close() + for rows.Next() { + var category string + var accepted, total int + rows.Scan(&category, &accepted, &total) + + rate := float64(0) + if total > 0 { + rate = float64(accepted) / float64(total) + } + + categoryStats[category] = map[string]interface{}{ + "accepted": accepted, + "rate": rate, + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "siteId": siteID, + "period": gin.H{ + "from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), + "to": time.Now().Format("2006-01-02"), + }, + "totalConsents": totalConsents, + "consentByCategory": categoryStats, + }) +} + +// ======================================== +// Helper Functions +// ======================================== + +// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform) +func anonymizeIP(ip string) string { + // IPv4: Letztes Oktett auf 0 + parts := strings.Split(ip, ".") + if len(parts) == 4 { + parts[3] = "0" + anonymized := strings.Join(parts, ".") + hash := sha256.Sum256([]byte(anonymized)) + return hex.EncodeToString(hash[:])[:16] + } + + // IPv6: Hash + hash := sha256.Sum256([]byte(ip)) + return hex.EncodeToString(hash[:])[:16] +} + +// logBannerConsentAudit schreibt einen Audit-Log-Eintrag +func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) { + details, _ := json.Marshal(req) + + h.db.Pool.Exec(ctx, ` + INSERT INTO banner_consent_audit_log ( + id, consent_id, action, details, ip_hash, created_at + ) VALUES ($1, $2, $3, $4, $5, NOW()) + `, uuid.New().String(), consentID, action, string(details), ipHash) +} diff --git a/consent-service/internal/handlers/banner_handlers.go b/consent-service/internal/handlers/banner_handlers.go index 71aa7b8..aa87bc1 100644 --- a/consent-service/internal/handlers/banner_handlers.go +++ b/consent-service/internal/handlers/banner_handlers.go @@ -2,11 +2,8 @@ package handlers import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "net/http" - "strings" "time" "github.com/gin-gonic/gin" @@ -308,254 +305,3 @@ func (h *Handler) RevokeBannerConsent(c *gin.Context) { "revokedAt": time.Now().UTC().Format(time.RFC3339), }) } - -// GetSiteConfig gibt die Konfiguration für eine Site zurück -// GET /api/v1/banner/config/:siteId -func (h *Handler) GetSiteConfig(c *gin.Context) { - siteID := c.Param("siteId") - - // Standard-Kategorien (aus Datenbank oder Default) - categories := []CategoryConfig{ - { - ID: "essential", - Name: map[string]string{ - "de": "Essentiell", - "en": "Essential", - }, - Description: map[string]string{ - "de": "Notwendig für die Grundfunktionen der Website.", - "en": "Required for basic website functionality.", - }, - Required: true, - Vendors: []VendorConfig{}, - }, - { - ID: "functional", - Name: map[string]string{ - "de": "Funktional", - "en": "Functional", - }, - Description: map[string]string{ - "de": "Ermöglicht Personalisierung und Komfortfunktionen.", - "en": "Enables personalization and comfort features.", - }, - Required: false, - Vendors: []VendorConfig{}, - }, - { - ID: "analytics", - Name: map[string]string{ - "de": "Statistik", - "en": "Analytics", - }, - Description: map[string]string{ - "de": "Hilft uns, die Website zu verbessern.", - "en": "Helps us improve the website.", - }, - Required: false, - Vendors: []VendorConfig{}, - }, - { - ID: "marketing", - Name: map[string]string{ - "de": "Marketing", - "en": "Marketing", - }, - Description: map[string]string{ - "de": "Ermöglicht personalisierte Werbung.", - "en": "Enables personalized advertising.", - }, - Required: false, - Vendors: []VendorConfig{}, - }, - { - ID: "social", - Name: map[string]string{ - "de": "Soziale Medien", - "en": "Social Media", - }, - Description: map[string]string{ - "de": "Ermöglicht Inhalte von sozialen Netzwerken.", - "en": "Enables content from social networks.", - }, - Required: false, - Vendors: []VendorConfig{}, - }, - } - - config := SiteConfig{ - SiteID: siteID, - SiteName: "BreakPilot", - Categories: categories, - UI: UIConfig{ - Theme: "auto", - Position: "bottom", - }, - Legal: LegalConfig{ - PrivacyPolicyURL: "/datenschutz", - ImprintURL: "/impressum", - }, - } - - c.JSON(http.StatusOK, config) -} - -// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20) -// GET /api/v1/banner/consent/export?userId=xxx -func (h *Handler) ExportBannerConsent(c *gin.Context) { - userID := c.Query("userId") - - if userID == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "missing_user_id", - "message": "userId parameter is required", - }) - return - } - - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, site_id, device_fingerprint, categories, vendors, - version, created_at, updated_at, revoked_at - FROM banner_consents - WHERE user_id = $1 - ORDER BY created_at DESC - `, userID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "export_failed", - "message": "Failed to export consent data", - }) - return - } - defer rows.Close() - - var consents []map[string]interface{} - for rows.Next() { - var id, siteID, deviceFingerprint, version string - var categoriesJSON, vendorsJSON []byte - var createdAt, updatedAt time.Time - var revokedAt *time.Time - - rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON, - &version, &createdAt, &updatedAt, &revokedAt) - - var categories, vendors map[string]bool - json.Unmarshal(categoriesJSON, &categories) - json.Unmarshal(vendorsJSON, &vendors) - - consent := map[string]interface{}{ - "consentId": id, - "siteId": siteID, - "consent": map[string]interface{}{ - "categories": categories, - "vendors": vendors, - }, - "createdAt": createdAt.UTC().Format(time.RFC3339), - "revokedAt": nil, - } - - if revokedAt != nil { - consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339) - } - - consents = append(consents, consent) - } - - c.JSON(http.StatusOK, gin.H{ - "userId": userID, - "exportedAt": time.Now().UTC().Format(time.RFC3339), - "consents": consents, - }) -} - -// GetBannerStats gibt anonymisierte Statistiken zurück (Admin) -// GET /api/v1/banner/admin/stats/:siteId -func (h *Handler) GetBannerStats(c *gin.Context) { - siteID := c.Param("siteId") - - ctx := context.Background() - - // Gesamtanzahl Consents - var totalConsents int - h.db.Pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM banner_consents - WHERE site_id = $1 AND revoked_at IS NULL - `, siteID).Scan(&totalConsents) - - // Consent-Rate pro Kategorie - categoryStats := make(map[string]map[string]interface{}) - - rows, _ := h.db.Pool.Query(ctx, ` - SELECT - key as category, - COUNT(*) FILTER (WHERE value::text = 'true') as accepted, - COUNT(*) as total - FROM banner_consents, - jsonb_each(categories::jsonb) - WHERE site_id = $1 AND revoked_at IS NULL - GROUP BY key - `, siteID) - - if rows != nil { - defer rows.Close() - for rows.Next() { - var category string - var accepted, total int - rows.Scan(&category, &accepted, &total) - - rate := float64(0) - if total > 0 { - rate = float64(accepted) / float64(total) - } - - categoryStats[category] = map[string]interface{}{ - "accepted": accepted, - "rate": rate, - } - } - } - - c.JSON(http.StatusOK, gin.H{ - "siteId": siteID, - "period": gin.H{ - "from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), - "to": time.Now().Format("2006-01-02"), - }, - "totalConsents": totalConsents, - "consentByCategory": categoryStats, - }) -} - -// ======================================== -// Helper Functions -// ======================================== - -// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform) -func anonymizeIP(ip string) string { - // IPv4: Letztes Oktett auf 0 - parts := strings.Split(ip, ".") - if len(parts) == 4 { - parts[3] = "0" - anonymized := strings.Join(parts, ".") - hash := sha256.Sum256([]byte(anonymized)) - return hex.EncodeToString(hash[:])[:16] - } - - // IPv6: Hash - hash := sha256.Sum256([]byte(ip)) - return hex.EncodeToString(hash[:])[:16] -} - -// logBannerConsentAudit schreibt einen Audit-Log-Eintrag -func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) { - details, _ := json.Marshal(req) - - h.db.Pool.Exec(ctx, ` - INSERT INTO banner_consent_audit_log ( - id, consent_id, action, details, ip_hash, created_at - ) VALUES ($1, $2, $3, $4, $5, NOW()) - `, uuid.New().String(), consentID, action, string(details), ipHash) -} diff --git a/consent-service/internal/handlers/communication_handlers.go b/consent-service/internal/handlers/communication_handlers.go index 8c45607..e72d251 100644 --- a/consent-service/internal/handlers/communication_handlers.go +++ b/consent-service/internal/handlers/communication_handlers.go @@ -273,239 +273,3 @@ func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) { "user_id": resp.UserID, }) } - -// ======================================== -// Jitsi Video Conference Endpoints -// ======================================== - -// CreateMeetingRequest for creating Jitsi meetings -type CreateMeetingRequest struct { - Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class" - Title string `json:"title,omitempty"` - DisplayName string `json:"display_name"` - Email string `json:"email,omitempty"` - Duration int `json:"duration,omitempty"` // minutes - ClassName string `json:"class_name,omitempty"` - ParentName string `json:"parent_name,omitempty"` - StudentName string `json:"student_name,omitempty"` - Subject string `json:"subject,omitempty"` - StartTime time.Time `json:"start_time,omitempty"` -} - -// CreateMeeting creates a new Jitsi meeting -func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) { - if h.jitsiService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) - return - } - - var req CreateMeetingRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - ctx := c.Request.Context() - var link *jitsi.MeetingLink - var err error - - switch req.Type { - case "quick": - link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName) - case "training": - link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration) - case "parent_teacher": - link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime) - case "class": - link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject) - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"}) - return - } - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "room_name": link.RoomName, - "url": link.URL, - "join_url": link.JoinURL, - "moderator_url": link.ModeratorURL, - "password": link.Password, - "expires_at": link.ExpiresAt, - }) -} - -// GetEmbedURLRequest for embedding Jitsi -type GetEmbedURLRequest struct { - RoomName string `json:"room_name" binding:"required"` - DisplayName string `json:"display_name"` - AudioMuted bool `json:"audio_muted"` - VideoMuted bool `json:"video_muted"` -} - -// GetEmbedURL returns an embeddable Jitsi URL -func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) { - if h.jitsiService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) - return - } - - var req GetEmbedURLRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - config := &jitsi.MeetingConfig{ - StartWithAudioMuted: req.AudioMuted, - StartWithVideoMuted: req.VideoMuted, - DisableDeepLinking: true, - } - - embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config) - iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600) - - c.JSON(http.StatusOK, gin.H{ - "embed_url": embedURL, - "iframe_code": iframeCode, - }) -} - -// GetJitsiInfo returns Jitsi server information -func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) { - if h.jitsiService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) - return - } - - info := h.jitsiService.GetServerInfo() - c.JSON(http.StatusOK, info) -} - -// ======================================== -// Admin Statistics Endpoints (for Admin Panel) -// ======================================== - -// CommunicationStats holds communication service statistics -type CommunicationStats struct { - Matrix MatrixStats `json:"matrix"` - Jitsi JitsiStats `json:"jitsi"` -} - -// MatrixStats holds Matrix-specific statistics -type MatrixStats struct { - Enabled bool `json:"enabled"` - Healthy bool `json:"healthy"` - ServerName string `json:"server_name"` - // TODO: Add real stats from Matrix Synapse Admin API - TotalUsers int `json:"total_users"` - TotalRooms int `json:"total_rooms"` - ActiveToday int `json:"active_today"` - MessagesToday int `json:"messages_today"` -} - -// JitsiStats holds Jitsi-specific statistics -type JitsiStats struct { - Enabled bool `json:"enabled"` - Healthy bool `json:"healthy"` - BaseURL string `json:"base_url"` - AuthEnabled bool `json:"auth_enabled"` - // TODO: Add real stats from Jitsi SRTP API or Jicofo - ActiveMeetings int `json:"active_meetings"` - TotalParticipants int `json:"total_participants"` - MeetingsToday int `json:"meetings_today"` - AvgDurationMin int `json:"avg_duration_min"` -} - -// GetAdminStats returns admin statistics for Matrix and Jitsi -func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) { - ctx := c.Request.Context() - - stats := CommunicationStats{} - - // Matrix Stats - if h.matrixService != nil { - matrixErr := h.matrixService.HealthCheck(ctx) - stats.Matrix = MatrixStats{ - Enabled: true, - Healthy: matrixErr == nil, - ServerName: h.matrixService.GetServerName(), - // Placeholder stats - in production these would come from Synapse Admin API - TotalUsers: 0, - TotalRooms: 0, - ActiveToday: 0, - MessagesToday: 0, - } - } else { - stats.Matrix = MatrixStats{Enabled: false} - } - - // Jitsi Stats - if h.jitsiService != nil { - jitsiErr := h.jitsiService.HealthCheck(ctx) - serverInfo := h.jitsiService.GetServerInfo() - stats.Jitsi = JitsiStats{ - Enabled: true, - Healthy: jitsiErr == nil, - BaseURL: serverInfo["base_url"], - AuthEnabled: serverInfo["auth_enabled"] == "true", - // Placeholder stats - in production these would come from Jicofo/JVB stats - ActiveMeetings: 0, - TotalParticipants: 0, - MeetingsToday: 0, - AvgDurationMin: 0, - } - } else { - stats.Jitsi = JitsiStats{Enabled: false} - } - - c.JSON(http.StatusOK, stats) -} - -// ======================================== -// Helper Functions -// ======================================== - -func errToString(err error) string { - if err == nil { - return "" - } - return err.Error() -} - -// RegisterRoutes registers all communication routes -func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) { - comm := router.Group("/communication") - { - // Public health check - comm.GET("/status", h.GetCommunicationStatus) - - // Protected routes - protected := comm.Group("") - protected.Use(authMiddleware) - { - // Matrix - protected.POST("/rooms", h.CreateRoom) - protected.POST("/rooms/invite", h.InviteUser) - protected.POST("/messages", h.SendMessage) - protected.POST("/notifications", h.SendNotification) - - // Jitsi - protected.POST("/meetings", h.CreateMeeting) - protected.POST("/meetings/embed", h.GetEmbedURL) - protected.GET("/jitsi/info", h.GetJitsiInfo) - } - - // Admin routes (for Matrix user registration and stats) - admin := comm.Group("/admin") - admin.Use(authMiddleware) - // TODO: Add AdminOnly middleware - { - admin.POST("/matrix/users", h.RegisterMatrixUser) - admin.GET("/stats", h.GetAdminStats) - } - } -} diff --git a/consent-service/internal/handlers/communication_jitsi_handlers.go b/consent-service/internal/handlers/communication_jitsi_handlers.go new file mode 100644 index 0000000..ca5018d --- /dev/null +++ b/consent-service/internal/handlers/communication_jitsi_handlers.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/services/jitsi" + "github.com/gin-gonic/gin" +) + +// ======================================== +// Jitsi Video Conference Endpoints +// ======================================== + +// CreateMeetingRequest for creating Jitsi meetings +type CreateMeetingRequest struct { + Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class" + Title string `json:"title,omitempty"` + DisplayName string `json:"display_name"` + Email string `json:"email,omitempty"` + Duration int `json:"duration,omitempty"` // minutes + ClassName string `json:"class_name,omitempty"` + ParentName string `json:"parent_name,omitempty"` + StudentName string `json:"student_name,omitempty"` + Subject string `json:"subject,omitempty"` + StartTime time.Time `json:"start_time,omitempty"` +} + +// CreateMeeting creates a new Jitsi meeting +func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) { + if h.jitsiService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) + return + } + + var req CreateMeetingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + var link *jitsi.MeetingLink + var err error + + switch req.Type { + case "quick": + link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName) + case "training": + link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration) + case "parent_teacher": + link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime) + case "class": + link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"}) + return + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "room_name": link.RoomName, + "url": link.URL, + "join_url": link.JoinURL, + "moderator_url": link.ModeratorURL, + "password": link.Password, + "expires_at": link.ExpiresAt, + }) +} + +// GetEmbedURLRequest for embedding Jitsi +type GetEmbedURLRequest struct { + RoomName string `json:"room_name" binding:"required"` + DisplayName string `json:"display_name"` + AudioMuted bool `json:"audio_muted"` + VideoMuted bool `json:"video_muted"` +} + +// GetEmbedURL returns an embeddable Jitsi URL +func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) { + if h.jitsiService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) + return + } + + var req GetEmbedURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config := &jitsi.MeetingConfig{ + StartWithAudioMuted: req.AudioMuted, + StartWithVideoMuted: req.VideoMuted, + DisableDeepLinking: true, + } + + embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config) + iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600) + + c.JSON(http.StatusOK, gin.H{ + "embed_url": embedURL, + "iframe_code": iframeCode, + }) +} + +// GetJitsiInfo returns Jitsi server information +func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) { + if h.jitsiService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) + return + } + + info := h.jitsiService.GetServerInfo() + c.JSON(http.StatusOK, info) +} + +// ======================================== +// Admin Statistics Endpoints (for Admin Panel) +// ======================================== + +// CommunicationStats holds communication service statistics +type CommunicationStats struct { + Matrix MatrixStats `json:"matrix"` + Jitsi JitsiStats `json:"jitsi"` +} + +// MatrixStats holds Matrix-specific statistics +type MatrixStats struct { + Enabled bool `json:"enabled"` + Healthy bool `json:"healthy"` + ServerName string `json:"server_name"` + // TODO: Add real stats from Matrix Synapse Admin API + TotalUsers int `json:"total_users"` + TotalRooms int `json:"total_rooms"` + ActiveToday int `json:"active_today"` + MessagesToday int `json:"messages_today"` +} + +// JitsiStats holds Jitsi-specific statistics +type JitsiStats struct { + Enabled bool `json:"enabled"` + Healthy bool `json:"healthy"` + BaseURL string `json:"base_url"` + AuthEnabled bool `json:"auth_enabled"` + // TODO: Add real stats from Jitsi SRTP API or Jicofo + ActiveMeetings int `json:"active_meetings"` + TotalParticipants int `json:"total_participants"` + MeetingsToday int `json:"meetings_today"` + AvgDurationMin int `json:"avg_duration_min"` +} + +// GetAdminStats returns admin statistics for Matrix and Jitsi +func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) { + ctx := c.Request.Context() + + stats := CommunicationStats{} + + // Matrix Stats + if h.matrixService != nil { + matrixErr := h.matrixService.HealthCheck(ctx) + stats.Matrix = MatrixStats{ + Enabled: true, + Healthy: matrixErr == nil, + ServerName: h.matrixService.GetServerName(), + // Placeholder stats - in production these would come from Synapse Admin API + TotalUsers: 0, + TotalRooms: 0, + ActiveToday: 0, + MessagesToday: 0, + } + } else { + stats.Matrix = MatrixStats{Enabled: false} + } + + // Jitsi Stats + if h.jitsiService != nil { + jitsiErr := h.jitsiService.HealthCheck(ctx) + serverInfo := h.jitsiService.GetServerInfo() + stats.Jitsi = JitsiStats{ + Enabled: true, + Healthy: jitsiErr == nil, + BaseURL: serverInfo["base_url"], + AuthEnabled: serverInfo["auth_enabled"] == "true", + // Placeholder stats - in production these would come from Jicofo/JVB stats + ActiveMeetings: 0, + TotalParticipants: 0, + MeetingsToday: 0, + AvgDurationMin: 0, + } + } else { + stats.Jitsi = JitsiStats{Enabled: false} + } + + c.JSON(http.StatusOK, stats) +} + +// ======================================== +// Helper Functions +// ======================================== + +func errToString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// RegisterRoutes registers all communication routes +func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) { + comm := router.Group("/communication") + { + // Public health check + comm.GET("/status", h.GetCommunicationStatus) + + // Protected routes + protected := comm.Group("") + protected.Use(authMiddleware) + { + // Matrix + protected.POST("/rooms", h.CreateRoom) + protected.POST("/rooms/invite", h.InviteUser) + protected.POST("/messages", h.SendMessage) + protected.POST("/notifications", h.SendNotification) + + // Jitsi + protected.POST("/meetings", h.CreateMeeting) + protected.POST("/meetings/embed", h.GetEmbedURL) + protected.GET("/jitsi/info", h.GetJitsiInfo) + } + + // Admin routes (for Matrix user registration and stats) + admin := comm.Group("/admin") + admin.Use(authMiddleware) + // TODO: Add AdminOnly middleware + { + admin.POST("/matrix/users", h.RegisterMatrixUser) + admin.GET("/stats", h.GetAdminStats) + } + } +} diff --git a/consent-service/internal/handlers/consents_public.go b/consent-service/internal/handlers/consents_public.go new file mode 100644 index 0000000..04d9325 --- /dev/null +++ b/consent-service/internal/handlers/consents_public.go @@ -0,0 +1,244 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// PUBLIC ENDPOINTS - Consent +// ======================================== + +// CreateConsent creates a new user consent +func (h *Handler) CreateConsent(c *gin.Context) { + var req models.CreateConsentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + versionID, err := uuid.Parse(req.VersionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Upsert consent + var consentID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, document_version_id) + DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL + RETURNING id + `, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent) + + c.JSON(http.StatusCreated, gin.H{ + "message": "Consent saved successfully", + "consent_id": consentID, + }) +} + +// GetMyConsents returns all consents for the current user +func (h *Handler) GetMyConsents(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at, + ld.id, ld.type, ld.name, ld.is_mandatory, + dv.id, dv.version, dv.language, dv.title + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.user_id = $1 + ORDER BY uc.consented_at DESC + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"}) + return + } + defer rows.Close() + + var consents []map[string]interface{} + for rows.Next() { + var ( + consentID uuid.UUID + consented bool + consentedAt time.Time + withdrawnAt *time.Time + docID uuid.UUID + docType string + docName string + isMandatory bool + versionID uuid.UUID + version string + language string + title string + ) + + if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt, + &docID, &docType, &docName, &isMandatory, + &versionID, &version, &language, &title); err != nil { + continue + } + + consents = append(consents, map[string]interface{}{ + "consent_id": consentID, + "consented": consented, + "consented_at": consentedAt, + "withdrawn_at": withdrawnAt, + "document": map[string]interface{}{ + "id": docID, + "type": docType, + "name": docName, + "is_mandatory": isMandatory, + }, + "version": map[string]interface{}{ + "id": versionID, + "version": version, + "language": language, + "title": title, + }, + }) + } + + c.JSON(http.StatusOK, gin.H{"consents": consents}) +} + +// CheckConsent checks if the user has consented to a document +func (h *Handler) CheckConsent(c *gin.Context) { + docType := c.Param("documentType") + language := c.DefaultQuery("language", "de") + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + + // Get latest published version + var latestVersionID uuid.UUID + var latestVersion string + err = h.db.Pool.QueryRow(ctx, ` + SELECT dv.id, dv.version + FROM document_versions dv + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' + ORDER BY dv.published_at DESC + LIMIT 1 + `, docType, language).Scan(&latestVersionID, &latestVersion) + + if err != nil { + c.JSON(http.StatusOK, models.ConsentCheckResponse{ + HasConsent: false, + NeedsUpdate: false, + }) + return + } + + // Check if user has consented to this version + var consentedVersionID uuid.UUID + var consentedVersion string + var consentedAt time.Time + err = h.db.Pool.QueryRow(ctx, ` + SELECT dv.id, dv.version, uc.consented_at + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL + ORDER BY uc.consented_at DESC + LIMIT 1 + `, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt) + + if err != nil { + // No consent found + latestIDStr := latestVersionID.String() + c.JSON(http.StatusOK, models.ConsentCheckResponse{ + HasConsent: false, + CurrentVersionID: &latestIDStr, + NeedsUpdate: true, + }) + return + } + + // Check if consent is for latest version + needsUpdate := consentedVersionID != latestVersionID + latestIDStr := latestVersionID.String() + consentedVerStr := consentedVersion + + c.JSON(http.StatusOK, models.ConsentCheckResponse{ + HasConsent: true, + CurrentVersionID: &latestIDStr, + ConsentedVersion: &consentedVerStr, + NeedsUpdate: needsUpdate, + ConsentedAt: &consentedAt, + }) +} + +// WithdrawConsent withdraws a consent +func (h *Handler) WithdrawConsent(c *gin.Context) { + consentID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"}) + return + } + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Update consent + result, err := h.db.Pool.Exec(ctx, ` + UPDATE user_consents + SET withdrawn_at = NOW(), consented = false + WHERE id = $1 AND user_id = $2 + `, consentID, userID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"}) +} diff --git a/consent-service/internal/handlers/cookies_public.go b/consent-service/internal/handlers/cookies_public.go new file mode 100644 index 0000000..f3b7ff5 --- /dev/null +++ b/consent-service/internal/handlers/cookies_public.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// PUBLIC ENDPOINTS - Cookie Consent +// ======================================== + +// GetCookieCategories returns all active cookie categories +func (h *Handler) GetCookieCategories(c *gin.Context) { + language := c.DefaultQuery("language", "de") + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, name, display_name_de, display_name_en, description_de, description_en, + is_mandatory, sort_order + FROM cookie_categories + WHERE is_active = true + ORDER BY sort_order ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"}) + return + } + defer rows.Close() + + var categories []map[string]interface{} + for rows.Next() { + var cat models.CookieCategory + if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN, + &cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil { + continue + } + + // Return localized data + displayName := cat.DisplayNameDE + description := cat.DescriptionDE + if language == "en" && cat.DisplayNameEN != nil { + displayName = *cat.DisplayNameEN + if cat.DescriptionEN != nil { + description = cat.DescriptionEN + } + } + + categories = append(categories, map[string]interface{}{ + "id": cat.ID, + "name": cat.Name, + "display_name": displayName, + "description": description, + "is_mandatory": cat.IsMandatory, + }) + } + + c.JSON(http.StatusOK, gin.H{"categories": categories}) +} + +// SetCookieConsent sets cookie preferences for a user +func (h *Handler) SetCookieConsent(c *gin.Context) { + var req models.CookieConsentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Process each category + for _, cat := range req.Categories { + categoryID, err := uuid.Parse(cat.CategoryID) + if err != nil { + continue + } + + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO cookie_consents (user_id, category_id, consented) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, category_id) + DO UPDATE SET consented = $3, updated_at = NOW() + `, userID, categoryID, cat.Consented) + + if err != nil { + continue + } + } + + // Log to audit trail + h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"}) +} + +// GetMyCookieConsent returns cookie preferences for the current user +func (h *Handler) GetMyCookieConsent(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT cc.category_id, cc.consented, cc.updated_at, + cat.name, cat.display_name_de, cat.is_mandatory + FROM cookie_consents cc + JOIN cookie_categories cat ON cc.category_id = cat.id + WHERE cc.user_id = $1 + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"}) + return + } + defer rows.Close() + + var consents []map[string]interface{} + for rows.Next() { + var ( + categoryID uuid.UUID + consented bool + updatedAt time.Time + name string + displayName string + isMandatory bool + ) + + if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil { + continue + } + + consents = append(consents, map[string]interface{}{ + "category_id": categoryID, + "name": name, + "display_name": displayName, + "consented": consented, + "is_mandatory": isMandatory, + "updated_at": updatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"cookie_consents": consents}) +} diff --git a/consent-service/internal/handlers/documents_public.go b/consent-service/internal/handlers/documents_public.go new file mode 100644 index 0000000..ba783bb --- /dev/null +++ b/consent-service/internal/handlers/documents_public.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" +) + +// ======================================== +// PUBLIC ENDPOINTS - Documents +// ======================================== + +// GetDocuments returns all active legal documents +func (h *Handler) GetDocuments(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at + FROM legal_documents + WHERE is_active = true + ORDER BY sort_order ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) + return + } + defer rows.Close() + + var documents []models.LegalDocument + for rows.Next() { + var doc models.LegalDocument + if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, + &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { + continue + } + documents = append(documents, doc) + } + + c.JSON(http.StatusOK, gin.H{"documents": documents}) +} + +// GetDocumentByType returns a document by its type +func (h *Handler) GetDocumentByType(c *gin.Context) { + docType := c.Param("type") + ctx := context.Background() + + var doc models.LegalDocument + err := h.db.Pool.QueryRow(ctx, ` + SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at + FROM legal_documents + WHERE type = $1 AND is_active = true + `, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, + &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + c.JSON(http.StatusOK, doc) +} + +// GetLatestDocumentVersion returns the latest published version of a document +func (h *Handler) GetLatestDocumentVersion(c *gin.Context) { + docType := c.Param("type") + language := c.DefaultQuery("language", "de") + ctx := context.Background() + + var version models.DocumentVersion + err := h.db.Pool.QueryRow(ctx, ` + SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content, + dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at + FROM document_versions dv + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' + ORDER BY dv.published_at DESC + LIMIT 1 + `, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language, + &version.Title, &version.Content, &version.Summary, &version.Status, + &version.PublishedAt, &version.CreatedAt, &version.UpdatedAt) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"}) + return + } + + c.JSON(http.StatusOK, version) +} diff --git a/consent-service/internal/handlers/dsr_handlers.go b/consent-service/internal/handlers/dsr_handlers.go index af8e83b..278a52c 100644 --- a/consent-service/internal/handlers/dsr_handlers.go +++ b/consent-service/internal/handlers/dsr_handlers.go @@ -3,8 +3,6 @@ package handlers import ( "context" "net/http" - "strconv" - "time" "github.com/breakpilot/consent-service/internal/middleware" "github.com/breakpilot/consent-service/internal/models" @@ -135,814 +133,3 @@ func (h *DSRHandler) CancelMyDSR(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"}) } - -// ======================================== -// ADMIN ENDPOINTS -// ======================================== - -// AdminListDSR returns all DSRs with filters (admin only) -func (h *DSRHandler) AdminListDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - // Parse pagination - limit := 20 - offset := 0 - if l := c.Query("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } - } - if o := c.Query("offset"); o != "" { - if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { - offset = parsed - } - } - - // Parse filters - filters := models.DSRListFilters{} - if status := c.Query("status"); status != "" { - filters.Status = &status - } - if reqType := c.Query("request_type"); reqType != "" { - filters.RequestType = &reqType - } - if assignedTo := c.Query("assigned_to"); assignedTo != "" { - filters.AssignedTo = &assignedTo - } - if priority := c.Query("priority"); priority != "" { - filters.Priority = &priority - } - if c.Query("overdue_only") == "true" { - filters.OverdueOnly = true - } - if search := c.Query("search"); search != "" { - filters.Search = &search - } - if from := c.Query("from_date"); from != "" { - if t, err := time.Parse("2006-01-02", from); err == nil { - filters.FromDate = &t - } - } - if to := c.Query("to_date"); to != "" { - if t, err := time.Parse("2006-01-02", to); err == nil { - filters.ToDate = &t - } - } - - dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "requests": dsrs, - "total": total, - "limit": limit, - "offset": offset, - }) -} - -// AdminGetDSR returns a specific DSR (admin only) -func (h *DSRHandler) AdminGetDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"}) - return - } - - c.JSON(http.StatusOK, dsr) -} - -// AdminCreateDSR creates a DSR manually (admin only) -func (h *DSRHandler) AdminCreateDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.CreateDSRRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) - return - } - - // Set source as admin_panel - if req.Source == "" { - req.Source = "admin_panel" - } - - dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Anfrage wurde erstellt", - "request_number": dsr.RequestNumber, - "dsr": dsr, - }) -} - -// AdminUpdateDSR updates a DSR (admin only) -func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.UpdateDSRRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := c.Request.Context() - - // Update status if provided - if req.Status != nil { - err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - } - - // Update processing notes - if req.ProcessingNotes != nil { - h.dsrService.GetPool().Exec(ctx, ` - UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2 - `, *req.ProcessingNotes, dsrID) - } - - // Update priority - if req.Priority != nil { - h.dsrService.GetPool().Exec(ctx, ` - UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2 - `, *req.Priority, dsrID) - } - - // Get updated DSR - dsr, _ := h.dsrService.GetByID(ctx, dsrID) - - c.JSON(http.StatusOK, gin.H{ - "message": "Anfrage wurde aktualisiert", - "dsr": dsr, - }) -} - -// AdminGetDSRStats returns dashboard statistics -func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - stats, err := h.dsrService.GetDashboardStats(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"}) - return - } - - c.JSON(http.StatusOK, stats) -} - -// AdminVerifyIdentity verifies the identity of a requester -func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.VerifyDSRIdentityRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"}) -} - -// AdminAssignDSR assigns a DSR to a user -func (h *DSRHandler) AdminAssignDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req struct { - AssigneeID string `json:"assignee_id" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - assigneeID, err := uuid.Parse(req.AssigneeID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"}) - return - } - - err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"}) -} - -// AdminExtendDSRDeadline extends the deadline for a DSR -func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.ExtendDSRDeadlineRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"}) -} - -// AdminCompleteDSR marks a DSR as completed -func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.CompleteDSRRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"}) -} - -// AdminRejectDSR rejects a DSR -func (h *DSRHandler) AdminRejectDSR(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.RejectDSRRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"}) -} - -// AdminGetDSRHistory returns the status history for a DSR -func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"}) - return - } - - c.JSON(http.StatusOK, gin.H{"history": history}) -} - -// AdminGetDSRCommunications returns communications for a DSR -func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"}) - return - } - - c.JSON(http.StatusOK, gin.H{"communications": comms}) -} - -// AdminSendDSRCommunication sends a communication for a DSR -func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req models.SendDSRCommunicationRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"}) -} - -// AdminUpdateDSRStatus updates the status of a DSR -func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req struct { - Status string `json:"status" binding:"required"` - Comment string `json:"comment"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"}) -} - -// ======================================== -// EXCEPTION CHECKS (Art. 17) -// ======================================== - -// AdminGetExceptionChecks returns exception checks for an erasure DSR -func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"}) - return - } - - c.JSON(http.StatusOK, gin.H{"exception_checks": checks}) -} - -// AdminInitExceptionChecks initializes exception checks for an erasure DSR -func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - dsrID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) - return - } - - err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"}) -} - -// AdminUpdateExceptionCheck updates a single exception check -func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - checkID, err := uuid.Parse(c.Param("checkId")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req struct { - Applies bool `json:"applies"` - Notes *string `json:"notes"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"}) -} - -// ======================================== -// TEMPLATE ENDPOINTS -// ======================================== - -// AdminGetDSRTemplates returns all DSR templates -func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - ctx := c.Request.Context() - rows, err := h.dsrService.GetPool().Query(ctx, ` - SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at - FROM dsr_templates ORDER BY sort_order, name - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) - return - } - defer rows.Close() - - var templates []map[string]interface{} - for rows.Next() { - var id uuid.UUID - var templateType, name string - var description *string - var requestTypes []byte - var isActive bool - var sortOrder int - var createdAt, updatedAt time.Time - - err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt) - if err != nil { - continue - } - - templates = append(templates, map[string]interface{}{ - "id": id, - "template_type": templateType, - "name": name, - "description": description, - "request_types": string(requestTypes), - "is_active": isActive, - "sort_order": sortOrder, - "created_at": createdAt, - "updated_at": updatedAt, - }) - } - - c.JSON(http.StatusOK, gin.H{"templates": templates}) -} - -// AdminGetDSRTemplateVersions returns versions for a template -func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - templateID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"}) - return - } - - ctx := c.Request.Context() - rows, err := h.dsrService.GetPool().Query(ctx, ` - SELECT id, template_id, version, language, subject, body_html, body_text, - status, published_at, created_by, approved_by, approved_at, created_at, updated_at - FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC - `, templateID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) - return - } - defer rows.Close() - - var versions []map[string]interface{} - for rows.Next() { - var id, tempID uuid.UUID - var version, language, subject, bodyHTML, bodyText, status string - var publishedAt, approvedAt *time.Time - var createdBy, approvedBy *uuid.UUID - var createdAt, updatedAt time.Time - - err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText, - &status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt) - if err != nil { - continue - } - - versions = append(versions, map[string]interface{}{ - "id": id, - "template_id": tempID, - "version": version, - "language": language, - "subject": subject, - "body_html": bodyHTML, - "body_text": bodyText, - "status": status, - "published_at": publishedAt, - "created_by": createdBy, - "approved_by": approvedBy, - "approved_at": approvedAt, - "created_at": createdAt, - "updated_at": updatedAt, - }) - } - - c.JSON(http.StatusOK, gin.H{"versions": versions}) -} - -// AdminCreateDSRTemplateVersion creates a new template version -func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - templateID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - var req struct { - Version string `json:"version" binding:"required"` - Language string `json:"language"` - Subject string `json:"subject" binding:"required"` - BodyHTML string `json:"body_html" binding:"required"` - BodyText string `json:"body_text"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - if req.Language == "" { - req.Language = "de" - } - - ctx := c.Request.Context() - var versionID uuid.UUID - err = h.dsrService.GetPool().QueryRow(ctx, ` - INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id - `, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Version wurde erstellt", - "id": versionID, - }) -} - -// AdminPublishDSRTemplateVersion publishes a template version -func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - versionID, err := uuid.Parse(c.Param("versionId")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - - ctx := c.Request.Context() - _, err = h.dsrService.GetPool().Exec(ctx, ` - UPDATE dsr_template_versions - SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW() - WHERE id = $2 - `, userID, versionID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"}) -} - -// AdminGetPublishedDSRTemplates returns all published templates for selection -func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) { - if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) - return - } - - requestType := c.Query("request_type") - language := c.DefaultQuery("language", "de") - - ctx := c.Request.Context() - query := ` - SELECT t.id, t.template_type, t.name, t.description, - v.id as version_id, v.version, v.subject, v.body_html, v.body_text - FROM dsr_templates t - JOIN dsr_template_versions v ON t.id = v.template_id - WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1 - ` - args := []interface{}{language} - - if requestType != "" { - query += ` AND t.request_types @> $2::jsonb` - args = append(args, `["`+requestType+`"]`) - } - - query += " ORDER BY t.sort_order, t.name" - - rows, err := h.dsrService.GetPool().Query(ctx, query, args...) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) - return - } - defer rows.Close() - - var templates []map[string]interface{} - for rows.Next() { - var templateID, versionID uuid.UUID - var templateType, name, version, subject, bodyHTML, bodyText string - var description *string - - err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText) - if err != nil { - continue - } - - templates = append(templates, map[string]interface{}{ - "template_id": templateID, - "template_type": templateType, - "name": name, - "description": description, - "version_id": versionID, - "version": version, - "subject": subject, - "body_html": bodyHTML, - "body_text": bodyText, - }) - } - - c.JSON(http.StatusOK, gin.H{"templates": templates}) -} - -// ======================================== -// DEADLINE PROCESSING -// ======================================== - -// ProcessDeadlines triggers deadline checking (called by scheduler) -func (h *DSRHandler) ProcessDeadlines(c *gin.Context) { - err := h.dsrService.ProcessDeadlines(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"}) -} diff --git a/consent-service/internal/handlers/dsr_handlers_admin.go b/consent-service/internal/handlers/dsr_handlers_admin.go new file mode 100644 index 0000000..43e1839 --- /dev/null +++ b/consent-service/internal/handlers/dsr_handlers_admin.go @@ -0,0 +1,388 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// ADMIN ENDPOINTS — CRUD & Workflow +// ======================================== + +// AdminListDSR returns all DSRs with filters (admin only) +func (h *DSRHandler) AdminListDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + // Parse pagination + limit := 20 + offset := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + // Parse filters + filters := models.DSRListFilters{} + if status := c.Query("status"); status != "" { + filters.Status = &status + } + if reqType := c.Query("request_type"); reqType != "" { + filters.RequestType = &reqType + } + if assignedTo := c.Query("assigned_to"); assignedTo != "" { + filters.AssignedTo = &assignedTo + } + if priority := c.Query("priority"); priority != "" { + filters.Priority = &priority + } + if c.Query("overdue_only") == "true" { + filters.OverdueOnly = true + } + if search := c.Query("search"); search != "" { + filters.Search = &search + } + if from := c.Query("from_date"); from != "" { + if t, err := time.Parse("2006-01-02", from); err == nil { + filters.FromDate = &t + } + } + if to := c.Query("to_date"); to != "" { + if t, err := time.Parse("2006-01-02", to); err == nil { + filters.ToDate = &t + } + } + + dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "requests": dsrs, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// AdminGetDSR returns a specific DSR (admin only) +func (h *DSRHandler) AdminGetDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"}) + return + } + + c.JSON(http.StatusOK, dsr) +} + +// AdminCreateDSR creates a DSR manually (admin only) +func (h *DSRHandler) AdminCreateDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.CreateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Set source as admin_panel + if req.Source == "" { + req.Source = "admin_panel" + } + + dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Anfrage wurde erstellt", + "request_number": dsr.RequestNumber, + "dsr": dsr, + }) +} + +// AdminUpdateDSR updates a DSR (admin only) +func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.UpdateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := c.Request.Context() + + // Update status if provided + if req.Status != nil { + err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + + // Update processing notes + if req.ProcessingNotes != nil { + h.dsrService.GetPool().Exec(ctx, ` + UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2 + `, *req.ProcessingNotes, dsrID) + } + + // Update priority + if req.Priority != nil { + h.dsrService.GetPool().Exec(ctx, ` + UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2 + `, *req.Priority, dsrID) + } + + // Get updated DSR + dsr, _ := h.dsrService.GetByID(ctx, dsrID) + + c.JSON(http.StatusOK, gin.H{ + "message": "Anfrage wurde aktualisiert", + "dsr": dsr, + }) +} + +// AdminGetDSRStats returns dashboard statistics +func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + stats, err := h.dsrService.GetDashboardStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// AdminVerifyIdentity verifies the identity of a requester +func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.VerifyDSRIdentityRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"}) +} + +// AdminAssignDSR assigns a DSR to a user +func (h *DSRHandler) AdminAssignDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + AssigneeID string `json:"assignee_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + assigneeID, err := uuid.Parse(req.AssigneeID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"}) + return + } + + err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"}) +} + +// AdminExtendDSRDeadline extends the deadline for a DSR +func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.ExtendDSRDeadlineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"}) +} + +// AdminCompleteDSR marks a DSR as completed +func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.CompleteDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"}) +} + +// AdminRejectDSR rejects a DSR +func (h *DSRHandler) AdminRejectDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.RejectDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"}) +} + +// AdminGetDSRHistory returns the status history for a DSR +func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"}) + return + } + + c.JSON(http.StatusOK, gin.H{"history": history}) +} diff --git a/consent-service/internal/handlers/dsr_handlers_ops.go b/consent-service/internal/handlers/dsr_handlers_ops.go new file mode 100644 index 0000000..8902836 --- /dev/null +++ b/consent-service/internal/handlers/dsr_handlers_ops.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// ADMIN — Communications & Status +// ======================================== + +// AdminGetDSRCommunications returns communications for a DSR +func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"}) + return + } + + c.JSON(http.StatusOK, gin.H{"communications": comms}) +} + +// AdminSendDSRCommunication sends a communication for a DSR +func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.SendDSRCommunicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"}) +} + +// AdminUpdateDSRStatus updates the status of a DSR +func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + Status string `json:"status" binding:"required"` + Comment string `json:"comment"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"}) +} + +// ======================================== +// EXCEPTION CHECKS (Art. 17) +// ======================================== + +// AdminGetExceptionChecks returns exception checks for an erasure DSR +func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"}) + return + } + + c.JSON(http.StatusOK, gin.H{"exception_checks": checks}) +} + +// AdminInitExceptionChecks initializes exception checks for an erasure DSR +func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"}) +} + +// AdminUpdateExceptionCheck updates a single exception check +func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + checkID, err := uuid.Parse(c.Param("checkId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + Applies bool `json:"applies"` + Notes *string `json:"notes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"}) +} + +// ======================================== +// DEADLINE PROCESSING +// ======================================== + +// ProcessDeadlines triggers deadline checking (called by scheduler) +func (h *DSRHandler) ProcessDeadlines(c *gin.Context) { + err := h.dsrService.ProcessDeadlines(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"}) +} diff --git a/consent-service/internal/handlers/dsr_handlers_templates.go b/consent-service/internal/handlers/dsr_handlers_templates.go new file mode 100644 index 0000000..45b37b8 --- /dev/null +++ b/consent-service/internal/handlers/dsr_handlers_templates.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// TEMPLATE ENDPOINTS +// ======================================== + +// AdminGetDSRTemplates returns all DSR templates +func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + ctx := c.Request.Context() + rows, err := h.dsrService.GetPool().Query(ctx, ` + SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at + FROM dsr_templates ORDER BY sort_order, name + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) + return + } + defer rows.Close() + + var templates []map[string]interface{} + for rows.Next() { + var id uuid.UUID + var templateType, name string + var description *string + var requestTypes []byte + var isActive bool + var sortOrder int + var createdAt, updatedAt time.Time + + err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt) + if err != nil { + continue + } + + templates = append(templates, map[string]interface{}{ + "id": id, + "template_type": templateType, + "name": name, + "description": description, + "request_types": string(requestTypes), + "is_active": isActive, + "sort_order": sortOrder, + "created_at": createdAt, + "updated_at": updatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"templates": templates}) +} + +// AdminGetDSRTemplateVersions returns versions for a template +func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + templateID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"}) + return + } + + ctx := c.Request.Context() + rows, err := h.dsrService.GetPool().Query(ctx, ` + SELECT id, template_id, version, language, subject, body_html, body_text, + status, published_at, created_by, approved_by, approved_at, created_at, updated_at + FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC + `, templateID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) + return + } + defer rows.Close() + + var versions []map[string]interface{} + for rows.Next() { + var id, tempID uuid.UUID + var version, language, subject, bodyHTML, bodyText, status string + var publishedAt, approvedAt *time.Time + var createdBy, approvedBy *uuid.UUID + var createdAt, updatedAt time.Time + + err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText, + &status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt) + if err != nil { + continue + } + + versions = append(versions, map[string]interface{}{ + "id": id, + "template_id": tempID, + "version": version, + "language": language, + "subject": subject, + "body_html": bodyHTML, + "body_text": bodyText, + "status": status, + "published_at": publishedAt, + "created_by": createdBy, + "approved_by": approvedBy, + "approved_at": approvedAt, + "created_at": createdAt, + "updated_at": updatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"versions": versions}) +} + +// AdminCreateDSRTemplateVersion creates a new template version +func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + templateID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + Version string `json:"version" binding:"required"` + Language string `json:"language"` + Subject string `json:"subject" binding:"required"` + BodyHTML string `json:"body_html" binding:"required"` + BodyText string `json:"body_text"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Language == "" { + req.Language = "de" + } + + ctx := c.Request.Context() + var versionID uuid.UUID + err = h.dsrService.GetPool().QueryRow(ctx, ` + INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Version wurde erstellt", + "id": versionID, + }) +} + +// AdminPublishDSRTemplateVersion publishes a template version +func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + versionID, err := uuid.Parse(c.Param("versionId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + ctx := c.Request.Context() + _, err = h.dsrService.GetPool().Exec(ctx, ` + UPDATE dsr_template_versions + SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW() + WHERE id = $2 + `, userID, versionID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"}) +} + +// AdminGetPublishedDSRTemplates returns all published templates for selection +func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + requestType := c.Query("request_type") + language := c.DefaultQuery("language", "de") + + ctx := c.Request.Context() + query := ` + SELECT t.id, t.template_type, t.name, t.description, + v.id as version_id, v.version, v.subject, v.body_html, v.body_text + FROM dsr_templates t + JOIN dsr_template_versions v ON t.id = v.template_id + WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1 + ` + args := []interface{}{language} + + if requestType != "" { + query += ` AND t.request_types @> $2::jsonb` + args = append(args, `["`+requestType+`"]`) + } + + query += " ORDER BY t.sort_order, t.name" + + rows, err := h.dsrService.GetPool().Query(ctx, query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) + return + } + defer rows.Close() + + var templates []map[string]interface{} + for rows.Next() { + var templateID, versionID uuid.UUID + var templateType, name, version, subject, bodyHTML, bodyText string + var description *string + + err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText) + if err != nil { + continue + } + + templates = append(templates, map[string]interface{}{ + "template_id": templateID, + "template_type": templateType, + "name": name, + "description": description, + "version_id": versionID, + "version": version, + "subject": subject, + "body_html": bodyHTML, + "body_text": bodyText, + }) + } + + c.JSON(http.StatusOK, gin.H{"templates": templates}) +} diff --git a/consent-service/internal/handlers/email_template_handlers.go b/consent-service/internal/handlers/email_template_handlers.go index d2b6a86..e0a7c46 100644 --- a/consent-service/internal/handlers/email_template_handlers.go +++ b/consent-service/internal/handlers/email_template_handlers.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "strconv" "time" "github.com/breakpilot/consent-service/internal/models" @@ -261,268 +260,3 @@ func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "version rejected"}) } - -// PublishVersion publishes an approved version -// POST /api/v1/admin/email-template-versions/:id/publish -func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) { - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) - return - } - - role, exists := c.Get("user_role") - if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") { - c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) - return - } - - userID, _ := c.Get("user_id") - uid, _ := uuid.Parse(userID.(string)) - - if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "version published"}) -} - -// GetApprovals returns approval history for a version -// GET /api/v1/admin/email-template-versions/:id/approvals -func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) { - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) - return - } - - approvals, err := h.service.GetApprovals(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"approvals": approvals}) -} - -// PreviewVersion renders a preview of an email template version -// POST /api/v1/admin/email-template-versions/:id/preview -func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) { - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) - return - } - - var req struct { - Variables map[string]string `json:"variables"` - } - c.ShouldBindJSON(&req) - - version, err := h.service.GetVersionByID(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) - return - } - - // Use default test values if not provided - if req.Variables == nil { - req.Variables = map[string]string{ - "user_name": "Max Mustermann", - "user_email": "max@example.com", - "login_url": "https://breakpilot.app/login", - "support_email": "support@breakpilot.app", - "verification_url": "https://breakpilot.app/verify?token=abc123", - "verification_code": "123456", - "expires_in": "24 Stunden", - "reset_url": "https://breakpilot.app/reset?token=xyz789", - "reset_code": "RESET123", - "ip_address": "192.168.1.1", - "device_info": "Chrome auf Windows 11", - "changed_at": time.Now().Format("02.01.2006 15:04"), - "enabled_at": time.Now().Format("02.01.2006 15:04"), - "disabled_at": time.Now().Format("02.01.2006 15:04"), - "support_url": "https://breakpilot.app/support", - "security_url": "https://breakpilot.app/account/security", - "login_time": time.Now().Format("02.01.2006 15:04"), - "location": "Berlin, Deutschland", - "activity_type": "Mehrere fehlgeschlagene Login-Versuche", - "activity_time": time.Now().Format("02.01.2006 15:04"), - "locked_at": time.Now().Format("02.01.2006 15:04"), - "reason": "Zu viele fehlgeschlagene Login-Versuche", - "unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"), - "unlocked_at": time.Now().Format("02.01.2006 15:04"), - "requested_at": time.Now().Format("02.01.2006"), - "deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"), - "cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123", - "data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs", - "deleted_at": time.Now().Format("02.01.2006"), - "feedback_url": "https://breakpilot.app/feedback", - "download_url": "https://breakpilot.app/export/download?token=export123", - "file_size": "2.3 MB", - "old_email": "alt@example.com", - "new_email": "neu@example.com", - "document_name": "Datenschutzerklärung", - "document_type": "privacy", - "version": "2.0.0", - "consent_url": "https://breakpilot.app/consent", - "deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"), - "days_left": "7", - "hours_left": "24 Stunden", - "consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.", - "suspended_at": time.Now().Format("02.01.2006 15:04"), - "documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0", - } - } - - preview, err := h.service.RenderTemplate(version, req.Variables) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, preview) -} - -// SendTestEmail sends a test email -// POST /api/v1/admin/email-template-versions/:id/send-test -func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) { - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) - return - } - - var req models.SendTestEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - req.VersionID = idStr - - version, err := h.service.GetVersionByID(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) - return - } - - // Get template to find type - template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "template not found"}) - return - } - - userID, _ := c.Get("user_id") - uid, _ := uuid.Parse(userID.(string)) - - // Send test email - if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "test email sent"}) -} - -// GetSettings returns global email settings -// GET /api/v1/admin/email-templates/settings -func (h *EmailTemplateHandler) GetSettings(c *gin.Context) { - settings, err := h.service.GetSettings(c.Request.Context()) - if err != nil { - // Return default settings if none exist - c.JSON(http.StatusOK, gin.H{ - "company_name": "BreakPilot", - "sender_name": "BreakPilot", - "sender_email": "noreply@breakpilot.app", - "primary_color": "#2563eb", - "secondary_color": "#64748b", - }) - return - } - c.JSON(http.StatusOK, settings) -} - -// UpdateSettings updates global email settings -// PUT /api/v1/admin/email-templates/settings -func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) { - var req models.UpdateEmailTemplateSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID, _ := c.Get("user_id") - uid, _ := uuid.Parse(userID.(string)) - - if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "settings updated"}) -} - -// GetEmailStats returns email statistics -// GET /api/v1/admin/email-templates/stats -func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) { - stats, err := h.service.GetEmailStats(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, stats) -} - -// GetSendLogs returns email send logs -// GET /api/v1/admin/email-templates/logs -func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) { - limitStr := c.DefaultQuery("limit", "50") - offsetStr := c.DefaultQuery("offset", "0") - - limit, _ := strconv.Atoi(limitStr) - offset, _ := strconv.Atoi(offsetStr) - - if limit > 100 { - limit = 100 - } - - logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total}) -} - -// GetDefaultContent returns default template content for a type -// GET /api/v1/admin/email-templates/default/:type -func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) { - templateType := c.Param("type") - language := c.DefaultQuery("language", "de") - - subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language) - - c.JSON(http.StatusOK, gin.H{ - "subject": subject, - "body_html": bodyHTML, - "body_text": bodyText, - }) -} - -// InitializeTemplates initializes default email templates -// POST /api/v1/admin/email-templates/initialize -func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) { - role, exists := c.Get("user_role") - if !exists || (role != "admin" && role != "super_admin") { - c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) - return - } - - if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"}) -} diff --git a/consent-service/internal/handlers/email_template_ops_handlers.go b/consent-service/internal/handlers/email_template_ops_handlers.go new file mode 100644 index 0000000..2ab2160 --- /dev/null +++ b/consent-service/internal/handlers/email_template_ops_handlers.go @@ -0,0 +1,276 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// PublishVersion publishes an approved version +// POST /api/v1/admin/email-template-versions/:id/publish +func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + role, exists := c.Get("user_role") + if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "version published"}) +} + +// GetApprovals returns approval history for a version +// GET /api/v1/admin/email-template-versions/:id/approvals +func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + approvals, err := h.service.GetApprovals(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"approvals": approvals}) +} + +// PreviewVersion renders a preview of an email template version +// POST /api/v1/admin/email-template-versions/:id/preview +func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + var req struct { + Variables map[string]string `json:"variables"` + } + c.ShouldBindJSON(&req) + + version, err := h.service.GetVersionByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) + return + } + + // Use default test values if not provided + if req.Variables == nil { + req.Variables = map[string]string{ + "user_name": "Max Mustermann", + "user_email": "max@example.com", + "login_url": "https://breakpilot.app/login", + "support_email": "support@breakpilot.app", + "verification_url": "https://breakpilot.app/verify?token=abc123", + "verification_code": "123456", + "expires_in": "24 Stunden", + "reset_url": "https://breakpilot.app/reset?token=xyz789", + "reset_code": "RESET123", + "ip_address": "192.168.1.1", + "device_info": "Chrome auf Windows 11", + "changed_at": time.Now().Format("02.01.2006 15:04"), + "enabled_at": time.Now().Format("02.01.2006 15:04"), + "disabled_at": time.Now().Format("02.01.2006 15:04"), + "support_url": "https://breakpilot.app/support", + "security_url": "https://breakpilot.app/account/security", + "login_time": time.Now().Format("02.01.2006 15:04"), + "location": "Berlin, Deutschland", + "activity_type": "Mehrere fehlgeschlagene Login-Versuche", + "activity_time": time.Now().Format("02.01.2006 15:04"), + "locked_at": time.Now().Format("02.01.2006 15:04"), + "reason": "Zu viele fehlgeschlagene Login-Versuche", + "unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"), + "unlocked_at": time.Now().Format("02.01.2006 15:04"), + "requested_at": time.Now().Format("02.01.2006"), + "deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"), + "cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123", + "data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs", + "deleted_at": time.Now().Format("02.01.2006"), + "feedback_url": "https://breakpilot.app/feedback", + "download_url": "https://breakpilot.app/export/download?token=export123", + "file_size": "2.3 MB", + "old_email": "alt@example.com", + "new_email": "neu@example.com", + "document_name": "Datenschutzerklärung", + "document_type": "privacy", + "version": "2.0.0", + "consent_url": "https://breakpilot.app/consent", + "deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"), + "days_left": "7", + "hours_left": "24 Stunden", + "consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.", + "suspended_at": time.Now().Format("02.01.2006 15:04"), + "documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0", + } + } + + preview, err := h.service.RenderTemplate(version, req.Variables) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, preview) +} + +// SendTestEmail sends a test email +// POST /api/v1/admin/email-template-versions/:id/send-test +func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + var req models.SendTestEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.VersionID = idStr + + version, err := h.service.GetVersionByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) + return + } + + // Get template to find type + template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "template not found"}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + // Send test email + if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "test email sent"}) +} + +// GetSettings returns global email settings +// GET /api/v1/admin/email-templates/settings +func (h *EmailTemplateHandler) GetSettings(c *gin.Context) { + settings, err := h.service.GetSettings(c.Request.Context()) + if err != nil { + // Return default settings if none exist + c.JSON(http.StatusOK, gin.H{ + "company_name": "BreakPilot", + "sender_name": "BreakPilot", + "sender_email": "noreply@breakpilot.app", + "primary_color": "#2563eb", + "secondary_color": "#64748b", + }) + return + } + c.JSON(http.StatusOK, settings) +} + +// UpdateSettings updates global email settings +// PUT /api/v1/admin/email-templates/settings +func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) { + var req models.UpdateEmailTemplateSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "settings updated"}) +} + +// GetEmailStats returns email statistics +// GET /api/v1/admin/email-templates/stats +func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) { + stats, err := h.service.GetEmailStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} + +// GetSendLogs returns email send logs +// GET /api/v1/admin/email-templates/logs +func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) { + limitStr := c.DefaultQuery("limit", "50") + offsetStr := c.DefaultQuery("offset", "0") + + limit, _ := strconv.Atoi(limitStr) + offset, _ := strconv.Atoi(offsetStr) + + if limit > 100 { + limit = 100 + } + + logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total}) +} + +// GetDefaultContent returns default template content for a type +// GET /api/v1/admin/email-templates/default/:type +func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) { + templateType := c.Param("type") + language := c.DefaultQuery("language", "de") + + subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language) + + c.JSON(http.StatusOK, gin.H{ + "subject": subject, + "body_html": bodyHTML, + "body_text": bodyText, + }) +} + +// InitializeTemplates initializes default email templates +// POST /api/v1/admin/email-templates/initialize +func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) { + role, exists := c.Get("user_role") + if !exists || (role != "admin" && role != "super_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"}) +} diff --git a/consent-service/internal/handlers/gdpr.go b/consent-service/internal/handlers/gdpr.go new file mode 100644 index 0000000..06d74d1 --- /dev/null +++ b/consent-service/internal/handlers/gdpr.go @@ -0,0 +1,168 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// GDPR / DATA SUBJECT RIGHTS +// ======================================== + +// GetMyData returns all data we have about the user +func (h *Handler) GetMyData(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Get user info + var user models.User + err = h.db.Pool.QueryRow(ctx, ` + SELECT id, external_id, email, role, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt) + + // Get consents + consentRows, _ := h.db.Pool.Query(ctx, ` + SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.user_id = $1 + `, userID) + defer consentRows.Close() + + var consents []map[string]interface{} + for consentRows.Next() { + var consented bool + var consentedAt time.Time + var docType, docName, version string + consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version) + consents = append(consents, map[string]interface{}{ + "document_type": docType, + "document_name": docName, + "version": version, + "consented": consented, + "consented_at": consentedAt, + }) + } + + // Get cookie consents + cookieRows, _ := h.db.Pool.Query(ctx, ` + SELECT cat.name, cc.consented, cc.updated_at + FROM cookie_consents cc + JOIN cookie_categories cat ON cc.category_id = cat.id + WHERE cc.user_id = $1 + `, userID) + defer cookieRows.Close() + + var cookieConsents []map[string]interface{} + for cookieRows.Next() { + var name string + var consented bool + var updatedAt time.Time + cookieRows.Scan(&name, &consented, &updatedAt) + cookieConsents = append(cookieConsents, map[string]interface{}{ + "category": name, + "consented": consented, + "updated_at": updatedAt, + }) + } + + // Log data access + h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{ + "user": map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "created_at": user.CreatedAt, + }, + "consents": consents, + "cookie_consents": cookieConsents, + "exported_at": time.Now(), + }) +} + +// RequestDataExport creates a data export request +func (h *Handler) RequestDataExport(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + var requestID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO data_export_requests (user_id, status) + VALUES ($1, 'pending') + RETURNING id + `, userID).Scan(&requestID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent) + + c.JSON(http.StatusAccepted, gin.H{ + "message": "Export request created. You will be notified when ready.", + "request_id": requestID, + }) +} + +// RequestDataDeletion creates a data deletion request +func (h *Handler) RequestDataDeletion(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + var req struct { + Reason string `json:"reason"` + } + c.ShouldBindJSON(&req) + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + var requestID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO data_deletion_requests (user_id, status, reason) + VALUES ($1, 'pending', $2) + RETURNING id + `, userID, req.Reason).Scan(&requestID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent) + + c.JSON(http.StatusAccepted, gin.H{ + "message": "Deletion request created. We will process your request within 30 days.", + "request_id": requestID, + }) +} diff --git a/consent-service/internal/handlers/handlers.go b/consent-service/internal/handlers/handlers.go index c57d80a..f2dedbe 100644 --- a/consent-service/internal/handlers/handlers.go +++ b/consent-service/internal/handlers/handlers.go @@ -2,16 +2,9 @@ package handlers import ( "context" - "fmt" - "net/http" "strconv" - "strings" - "time" "github.com/breakpilot/consent-service/internal/database" - "github.com/breakpilot/consent-service/internal/middleware" - "github.com/breakpilot/consent-service/internal/models" - "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -25,1648 +18,6 @@ func New(db *database.DB) *Handler { return &Handler{db: db} } -// ======================================== -// PUBLIC ENDPOINTS - Documents -// ======================================== - -// GetDocuments returns all active legal documents -func (h *Handler) GetDocuments(c *gin.Context) { - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at - FROM legal_documents - WHERE is_active = true - ORDER BY sort_order ASC - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) - return - } - defer rows.Close() - - var documents []models.LegalDocument - for rows.Next() { - var doc models.LegalDocument - if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, - &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { - continue - } - documents = append(documents, doc) - } - - c.JSON(http.StatusOK, gin.H{"documents": documents}) -} - -// GetDocumentByType returns a document by its type -func (h *Handler) GetDocumentByType(c *gin.Context) { - docType := c.Param("type") - ctx := context.Background() - - var doc models.LegalDocument - err := h.db.Pool.QueryRow(ctx, ` - SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at - FROM legal_documents - WHERE type = $1 AND is_active = true - `, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, - &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt) - - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) - return - } - - c.JSON(http.StatusOK, doc) -} - -// GetLatestDocumentVersion returns the latest published version of a document -func (h *Handler) GetLatestDocumentVersion(c *gin.Context) { - docType := c.Param("type") - language := c.DefaultQuery("language", "de") - ctx := context.Background() - - var version models.DocumentVersion - err := h.db.Pool.QueryRow(ctx, ` - SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content, - dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at - FROM document_versions dv - JOIN legal_documents ld ON dv.document_id = ld.id - WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' - ORDER BY dv.published_at DESC - LIMIT 1 - `, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language, - &version.Title, &version.Content, &version.Summary, &version.Status, - &version.PublishedAt, &version.CreatedAt, &version.UpdatedAt) - - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"}) - return - } - - c.JSON(http.StatusOK, version) -} - -// ======================================== -// PUBLIC ENDPOINTS - Consent -// ======================================== - -// CreateConsent creates a new user consent -func (h *Handler) CreateConsent(c *gin.Context) { - var req models.CreateConsentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - versionID, err := uuid.Parse(req.VersionID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Upsert consent - var consentID uuid.UUID - err = h.db.Pool.QueryRow(ctx, ` - INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, document_version_id) - DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL - RETURNING id - `, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"}) - return - } - - // Log to audit trail - h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent) - - c.JSON(http.StatusCreated, gin.H{ - "message": "Consent saved successfully", - "consent_id": consentID, - }) -} - -// GetMyConsents returns all consents for the current user -func (h *Handler) GetMyConsents(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at, - ld.id, ld.type, ld.name, ld.is_mandatory, - dv.id, dv.version, dv.language, dv.title - FROM user_consents uc - JOIN document_versions dv ON uc.document_version_id = dv.id - JOIN legal_documents ld ON dv.document_id = ld.id - WHERE uc.user_id = $1 - ORDER BY uc.consented_at DESC - `, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"}) - return - } - defer rows.Close() - - var consents []map[string]interface{} - for rows.Next() { - var ( - consentID uuid.UUID - consented bool - consentedAt time.Time - withdrawnAt *time.Time - docID uuid.UUID - docType string - docName string - isMandatory bool - versionID uuid.UUID - version string - language string - title string - ) - - if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt, - &docID, &docType, &docName, &isMandatory, - &versionID, &version, &language, &title); err != nil { - continue - } - - consents = append(consents, map[string]interface{}{ - "consent_id": consentID, - "consented": consented, - "consented_at": consentedAt, - "withdrawn_at": withdrawnAt, - "document": map[string]interface{}{ - "id": docID, - "type": docType, - "name": docName, - "is_mandatory": isMandatory, - }, - "version": map[string]interface{}{ - "id": versionID, - "version": version, - "language": language, - "title": title, - }, - }) - } - - c.JSON(http.StatusOK, gin.H{"consents": consents}) -} - -// CheckConsent checks if the user has consented to a document -func (h *Handler) CheckConsent(c *gin.Context) { - docType := c.Param("documentType") - language := c.DefaultQuery("language", "de") - - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - - // Get latest published version - var latestVersionID uuid.UUID - var latestVersion string - err = h.db.Pool.QueryRow(ctx, ` - SELECT dv.id, dv.version - FROM document_versions dv - JOIN legal_documents ld ON dv.document_id = ld.id - WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' - ORDER BY dv.published_at DESC - LIMIT 1 - `, docType, language).Scan(&latestVersionID, &latestVersion) - - if err != nil { - c.JSON(http.StatusOK, models.ConsentCheckResponse{ - HasConsent: false, - NeedsUpdate: false, - }) - return - } - - // Check if user has consented to this version - var consentedVersionID uuid.UUID - var consentedVersion string - var consentedAt time.Time - err = h.db.Pool.QueryRow(ctx, ` - SELECT dv.id, dv.version, uc.consented_at - FROM user_consents uc - JOIN document_versions dv ON uc.document_version_id = dv.id - JOIN legal_documents ld ON dv.document_id = ld.id - WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL - ORDER BY uc.consented_at DESC - LIMIT 1 - `, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt) - - if err != nil { - // No consent found - latestIDStr := latestVersionID.String() - c.JSON(http.StatusOK, models.ConsentCheckResponse{ - HasConsent: false, - CurrentVersionID: &latestIDStr, - NeedsUpdate: true, - }) - return - } - - // Check if consent is for latest version - needsUpdate := consentedVersionID != latestVersionID - latestIDStr := latestVersionID.String() - consentedVerStr := consentedVersion - - c.JSON(http.StatusOK, models.ConsentCheckResponse{ - HasConsent: true, - CurrentVersionID: &latestIDStr, - ConsentedVersion: &consentedVerStr, - NeedsUpdate: needsUpdate, - ConsentedAt: &consentedAt, - }) -} - -// WithdrawConsent withdraws a consent -func (h *Handler) WithdrawConsent(c *gin.Context) { - consentID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"}) - return - } - - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Update consent - result, err := h.db.Pool.Exec(ctx, ` - UPDATE user_consents - SET withdrawn_at = NOW(), consented = false - WHERE id = $1 AND user_id = $2 - `, consentID, userID) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"}) - return - } - - // Log to audit trail - h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent) - - c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"}) -} - -// ======================================== -// PUBLIC ENDPOINTS - Cookie Consent -// ======================================== - -// GetCookieCategories returns all active cookie categories -func (h *Handler) GetCookieCategories(c *gin.Context) { - language := c.DefaultQuery("language", "de") - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, name, display_name_de, display_name_en, description_de, description_en, - is_mandatory, sort_order - FROM cookie_categories - WHERE is_active = true - ORDER BY sort_order ASC - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"}) - return - } - defer rows.Close() - - var categories []map[string]interface{} - for rows.Next() { - var cat models.CookieCategory - if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN, - &cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil { - continue - } - - // Return localized data - displayName := cat.DisplayNameDE - description := cat.DescriptionDE - if language == "en" && cat.DisplayNameEN != nil { - displayName = *cat.DisplayNameEN - if cat.DescriptionEN != nil { - description = cat.DescriptionEN - } - } - - categories = append(categories, map[string]interface{}{ - "id": cat.ID, - "name": cat.Name, - "display_name": displayName, - "description": description, - "is_mandatory": cat.IsMandatory, - }) - } - - c.JSON(http.StatusOK, gin.H{"categories": categories}) -} - -// SetCookieConsent sets cookie preferences for a user -func (h *Handler) SetCookieConsent(c *gin.Context) { - var req models.CookieConsentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Process each category - for _, cat := range req.Categories { - categoryID, err := uuid.Parse(cat.CategoryID) - if err != nil { - continue - } - - _, err = h.db.Pool.Exec(ctx, ` - INSERT INTO cookie_consents (user_id, category_id, consented) - VALUES ($1, $2, $3) - ON CONFLICT (user_id, category_id) - DO UPDATE SET consented = $3, updated_at = NOW() - `, userID, categoryID, cat.Consented) - - if err != nil { - continue - } - } - - // Log to audit trail - h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent) - - c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"}) -} - -// GetMyCookieConsent returns cookie preferences for the current user -func (h *Handler) GetMyCookieConsent(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT cc.category_id, cc.consented, cc.updated_at, - cat.name, cat.display_name_de, cat.is_mandatory - FROM cookie_consents cc - JOIN cookie_categories cat ON cc.category_id = cat.id - WHERE cc.user_id = $1 - `, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"}) - return - } - defer rows.Close() - - var consents []map[string]interface{} - for rows.Next() { - var ( - categoryID uuid.UUID - consented bool - updatedAt time.Time - name string - displayName string - isMandatory bool - ) - - if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil { - continue - } - - consents = append(consents, map[string]interface{}{ - "category_id": categoryID, - "name": name, - "display_name": displayName, - "consented": consented, - "is_mandatory": isMandatory, - "updated_at": updatedAt, - }) - } - - c.JSON(http.StatusOK, gin.H{"cookie_consents": consents}) -} - -// ======================================== -// GDPR / DATA SUBJECT RIGHTS -// ======================================== - -// GetMyData returns all data we have about the user -func (h *Handler) GetMyData(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Get user info - var user models.User - err = h.db.Pool.QueryRow(ctx, ` - SELECT id, external_id, email, role, created_at, updated_at - FROM users WHERE id = $1 - `, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt) - - // Get consents - consentRows, _ := h.db.Pool.Query(ctx, ` - SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version - FROM user_consents uc - JOIN document_versions dv ON uc.document_version_id = dv.id - JOIN legal_documents ld ON dv.document_id = ld.id - WHERE uc.user_id = $1 - `, userID) - defer consentRows.Close() - - var consents []map[string]interface{} - for consentRows.Next() { - var consented bool - var consentedAt time.Time - var docType, docName, version string - consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version) - consents = append(consents, map[string]interface{}{ - "document_type": docType, - "document_name": docName, - "version": version, - "consented": consented, - "consented_at": consentedAt, - }) - } - - // Get cookie consents - cookieRows, _ := h.db.Pool.Query(ctx, ` - SELECT cat.name, cc.consented, cc.updated_at - FROM cookie_consents cc - JOIN cookie_categories cat ON cc.category_id = cat.id - WHERE cc.user_id = $1 - `, userID) - defer cookieRows.Close() - - var cookieConsents []map[string]interface{} - for cookieRows.Next() { - var name string - var consented bool - var updatedAt time.Time - cookieRows.Scan(&name, &consented, &updatedAt) - cookieConsents = append(cookieConsents, map[string]interface{}{ - "category": name, - "consented": consented, - "updated_at": updatedAt, - }) - } - - // Log data access - h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent) - - c.JSON(http.StatusOK, gin.H{ - "user": map[string]interface{}{ - "id": user.ID, - "email": user.Email, - "created_at": user.CreatedAt, - }, - "consents": consents, - "cookie_consents": cookieConsents, - "exported_at": time.Now(), - }) -} - -// RequestDataExport creates a data export request -func (h *Handler) RequestDataExport(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - var requestID uuid.UUID - err = h.db.Pool.QueryRow(ctx, ` - INSERT INTO data_export_requests (user_id, status) - VALUES ($1, 'pending') - RETURNING id - `, userID).Scan(&requestID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"}) - return - } - - // Log to audit trail - h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent) - - c.JSON(http.StatusAccepted, gin.H{ - "message": "Export request created. You will be notified when ready.", - "request_id": requestID, - }) -} - -// RequestDataDeletion creates a data deletion request -func (h *Handler) RequestDataDeletion(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) - return - } - - var req struct { - Reason string `json:"reason"` - } - c.ShouldBindJSON(&req) - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - var requestID uuid.UUID - err = h.db.Pool.QueryRow(ctx, ` - INSERT INTO data_deletion_requests (user_id, status, reason) - VALUES ($1, 'pending', $2) - RETURNING id - `, userID, req.Reason).Scan(&requestID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"}) - return - } - - // Log to audit trail - h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent) - - c.JSON(http.StatusAccepted, gin.H{ - "message": "Deletion request created. We will process your request within 30 days.", - "request_id": requestID, - }) -} - -// ======================================== -// ADMIN ENDPOINTS - Document Management -// ======================================== - -// AdminGetDocuments returns all documents (including inactive) for admin -func (h *Handler) AdminGetDocuments(c *gin.Context) { - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at - FROM legal_documents - ORDER BY sort_order ASC, created_at DESC - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) - return - } - defer rows.Close() - - var documents []models.LegalDocument - for rows.Next() { - var doc models.LegalDocument - if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, - &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { - continue - } - documents = append(documents, doc) - } - - c.JSON(http.StatusOK, gin.H{"documents": documents}) -} - -// AdminCreateDocument creates a new legal document -func (h *Handler) AdminCreateDocument(c *gin.Context) { - var req models.CreateDocumentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - - var docID uuid.UUID - err := h.db.Pool.QueryRow(ctx, ` - INSERT INTO legal_documents (type, name, description, is_mandatory) - VALUES ($1, $2, $3, $4) - RETURNING id - `, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Document created successfully", - "id": docID, - }) -} - -// AdminUpdateDocument updates a legal document -func (h *Handler) AdminUpdateDocument(c *gin.Context) { - docID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) - return - } - - var req struct { - Name *string `json:"name"` - Description *string `json:"description"` - IsMandatory *bool `json:"is_mandatory"` - IsActive *bool `json:"is_active"` - SortOrder *int `json:"sort_order"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE legal_documents - SET name = COALESCE($2, name), - description = COALESCE($3, description), - is_mandatory = COALESCE($4, is_mandatory), - is_active = COALESCE($5, is_active), - sort_order = COALESCE($6, sort_order), - updated_at = NOW() - WHERE id = $1 - `, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"}) -} - -// AdminDeleteDocument soft-deletes a document (sets is_active to false) -func (h *Handler) AdminDeleteDocument(c *gin.Context) { - docID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) - return - } - - ctx := context.Background() - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE legal_documents - SET is_active = false, updated_at = NOW() - WHERE id = $1 - `, docID) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) -} - -// ======================================== -// ADMIN ENDPOINTS - Version Management -// ======================================== - -// AdminGetVersions returns all versions for a document -func (h *Handler) AdminGetVersions(c *gin.Context) { - docID, err := uuid.Parse(c.Param("docId")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) - return - } - - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, document_id, version, language, title, content, summary, status, - published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at - FROM document_versions - WHERE document_id = $1 - ORDER BY created_at DESC - `, docID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) - return - } - defer rows.Close() - - var versions []models.DocumentVersion - for rows.Next() { - var v models.DocumentVersion - if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content, - &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil { - continue - } - versions = append(versions, v) - } - - c.JSON(http.StatusOK, gin.H{"versions": versions}) -} - -// AdminCreateVersion creates a new document version -func (h *Handler) AdminCreateVersion(c *gin.Context) { - var req models.CreateVersionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - docID, err := uuid.Parse(req.DocumentID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - ctx := context.Background() - - var versionID uuid.UUID - err = h.db.Pool.QueryRow(ctx, ` - INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by) - VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7) - RETURNING id - `, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID) - - if err != nil { - // Check for unique constraint violation - errStr := err.Error() - if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") { - c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"}) - return - } - // Log the actual error for debugging - fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Version created successfully", - "id": versionID, - }) -} - -// AdminUpdateVersion updates a document version -func (h *Handler) AdminUpdateVersion(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - var req models.UpdateVersionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - - // Check if version is in draft or review status (only these can be edited) - var status string - err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - if status != "draft" && status != "review" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"}) - return - } - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET title = COALESCE($2, title), - content = COALESCE($3, content), - summary = COALESCE($4, summary), - status = COALESCE($5, status), - updated_at = NOW() - WHERE id = $1 - `, versionID, req.Title, req.Content, req.Summary, req.Status) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"}) -} - -// AdminPublishVersion publishes a document version -func (h *Handler) AdminPublishVersion(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - ctx := context.Background() - - // Check current status - var status string - err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - if status != "approved" && status != "review" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"}) - return - } - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = 'published', - published_at = NOW(), - approved_by = $2, - updated_at = NOW() - WHERE id = $1 - `, versionID, userID) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"}) -} - -// AdminArchiveVersion archives a document version -func (h *Handler) AdminArchiveVersion(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - ctx := context.Background() - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = 'archived', updated_at = NOW() - WHERE id = $1 - `, versionID) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"}) -} - -// AdminDeleteVersion permanently deletes a draft/rejected version -// Only draft and rejected versions can be deleted. Published versions must be archived. -func (h *Handler) AdminDeleteVersion(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - ctx := context.Background() - - // First check the version status - only draft/rejected can be deleted - var status string - var version string - var docID uuid.UUID - err = h.db.Pool.QueryRow(ctx, ` - SELECT status, version, document_id FROM document_versions WHERE id = $1 - `, versionID).Scan(&status, &version, &docID) - - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - // Only allow deletion of draft and rejected versions - if status != "draft" && status != "rejected" { - c.JSON(http.StatusForbidden, gin.H{ - "error": "Cannot delete version", - "message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.", - "status": status, - }) - return - } - - // Delete the version - result, err := h.db.Pool.Exec(ctx, ` - DELETE FROM document_versions WHERE id = $1 - `, versionID) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"}) - return - } - - // Log the deletion - userID, _ := c.Get("user_id") - h.db.Pool.Exec(ctx, ` - INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent) - VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5) - `, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent()) - - c.JSON(http.StatusOK, gin.H{ - "message": "Version deleted successfully", - "deleted_version": version, - "version_id": versionID, - }) -} - -// ======================================== -// ADMIN ENDPOINTS - Cookie Categories -// ======================================== - -// AdminGetCookieCategories returns all cookie categories -func (h *Handler) AdminGetCookieCategories(c *gin.Context) { - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, name, display_name_de, display_name_en, description_de, description_en, - is_mandatory, sort_order, is_active, created_at, updated_at - FROM cookie_categories - ORDER BY sort_order ASC - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"}) - return - } - defer rows.Close() - - var categories []models.CookieCategory - for rows.Next() { - var cat models.CookieCategory - if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN, - &cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder, - &cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil { - continue - } - categories = append(categories, cat) - } - - c.JSON(http.StatusOK, gin.H{"categories": categories}) -} - -// AdminCreateCookieCategory creates a new cookie category -func (h *Handler) AdminCreateCookieCategory(c *gin.Context) { - var req models.CreateCookieCategoryRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - - var catID uuid.UUID - err := h.db.Pool.QueryRow(ctx, ` - INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id - `, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Cookie category created successfully", - "id": catID, - }) -} - -// AdminUpdateCookieCategory updates a cookie category -func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) { - catID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"}) - return - } - - var req struct { - DisplayNameDE *string `json:"display_name_de"` - DisplayNameEN *string `json:"display_name_en"` - DescriptionDE *string `json:"description_de"` - DescriptionEN *string `json:"description_en"` - IsMandatory *bool `json:"is_mandatory"` - SortOrder *int `json:"sort_order"` - IsActive *bool `json:"is_active"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE cookie_categories - SET display_name_de = COALESCE($2, display_name_de), - display_name_en = COALESCE($3, display_name_en), - description_de = COALESCE($4, description_de), - description_en = COALESCE($5, description_en), - is_mandatory = COALESCE($6, is_mandatory), - sort_order = COALESCE($7, sort_order), - is_active = COALESCE($8, is_active), - updated_at = NOW() - WHERE id = $1 - `, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, - req.IsMandatory, req.SortOrder, req.IsActive) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"}) -} - -// AdminDeleteCookieCategory soft-deletes a cookie category -func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) { - catID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"}) - return - } - - ctx := context.Background() - - result, err := h.db.Pool.Exec(ctx, ` - UPDATE cookie_categories - SET is_active = false, updated_at = NOW() - WHERE id = $1 - `, catID) - - if err != nil || result.RowsAffected() == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"}) -} - -// ======================================== -// ADMIN ENDPOINTS - Statistics & Audit -// ======================================== - -// GetConsentStats returns consent statistics -func (h *Handler) GetConsentStats(c *gin.Context) { - ctx := context.Background() - docType := c.Query("document_type") - - var stats models.ConsentStats - - // Total users - h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers) - - // Consented users (with active consent) - query := ` - SELECT COUNT(DISTINCT uc.user_id) - FROM user_consents uc - JOIN document_versions dv ON uc.document_version_id = dv.id - JOIN legal_documents ld ON dv.document_id = ld.id - WHERE uc.consented = true AND uc.withdrawn_at IS NULL - ` - if docType != "" { - query += ` AND ld.type = $1` - h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers) - } else { - h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers) - } - - // Calculate consent rate - if stats.TotalUsers > 0 { - stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100 - } - - // Recent consents (last 7 days) - h.db.Pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM user_consents - WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days' - `).Scan(&stats.RecentConsents) - - // Recent withdrawals - h.db.Pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM user_consents - WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days' - `).Scan(&stats.RecentWithdrawals) - - c.JSON(http.StatusOK, stats) -} - -// GetCookieStats returns cookie consent statistics -func (h *Handler) GetCookieStats(c *gin.Context) { - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT cat.name, - COUNT(DISTINCT u.id) as total_users, - COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users - FROM cookie_categories cat - CROSS JOIN users u - LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id - WHERE cat.is_active = true - GROUP BY cat.id, cat.name - ORDER BY cat.sort_order - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"}) - return - } - defer rows.Close() - - var stats []models.CookieStats - for rows.Next() { - var s models.CookieStats - if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil { - continue - } - if s.TotalUsers > 0 { - s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100 - } - stats = append(stats, s) - } - - c.JSON(http.StatusOK, gin.H{"cookie_stats": stats}) -} - -// GetAuditLog returns audit log entries -func (h *Handler) GetAuditLog(c *gin.Context) { - ctx := context.Background() - - // Pagination - limit := 50 - offset := 0 - if l := c.Query("limit"); l != "" { - if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 { - limit = parsed - } - } - if o := c.Query("offset"); o != "" { - if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 { - offset = parsed - } - } - - // Filters - userIDFilter := c.Query("user_id") - actionFilter := c.Query("action") - - query := ` - SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details, - al.ip_address, al.user_agent, al.created_at, u.email - FROM consent_audit_log al - LEFT JOIN users u ON al.user_id = u.id - WHERE 1=1 - ` - args := []interface{}{} - argCount := 0 - - if userIDFilter != "" { - argCount++ - query += fmt.Sprintf(" AND al.user_id = $%d", argCount) - args = append(args, userIDFilter) - } - if actionFilter != "" { - argCount++ - query += fmt.Sprintf(" AND al.action = $%d", argCount) - args = append(args, actionFilter) - } - - query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2) - args = append(args, limit, offset) - - rows, err := h.db.Pool.Query(ctx, query, args...) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"}) - return - } - defer rows.Close() - - var logs []map[string]interface{} - for rows.Next() { - var ( - id uuid.UUID - userIDPtr *uuid.UUID - action string - entityType *string - entityID *uuid.UUID - details *string - ipAddress *string - userAgent *string - createdAt time.Time - email *string - ) - - if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details, - &ipAddress, &userAgent, &createdAt, &email); err != nil { - continue - } - - logs = append(logs, map[string]interface{}{ - "id": id, - "user_id": userIDPtr, - "user_email": email, - "action": action, - "entity_type": entityType, - "entity_id": entityID, - "details": details, - "ip_address": ipAddress, - "user_agent": userAgent, - "created_at": createdAt, - }) - } - - c.JSON(http.StatusOK, gin.H{"audit_log": logs}) -} - -// ======================================== -// ADMIN ENDPOINTS - Version Approval Workflow (DSB) -// ======================================== - -// AdminSubmitForReview submits a version for DSB review -func (h *Handler) AdminSubmitForReview(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - userID, _ := middleware.GetUserID(c) - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Check current status - var status string - err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - if status != "draft" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"}) - return - } - - // Update status to review - _, err = h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = 'review', updated_at = NOW() - WHERE id = $1 - `, versionID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"}) - return - } - - // Log approval action - _, err = h.db.Pool.Exec(ctx, ` - INSERT INTO version_approvals (version_id, approver_id, action, comment) - VALUES ($1, $2, 'submitted', 'Submitted for DSB review') - `, versionID, userID) - - h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent) - - c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"}) -} - -// AdminApproveVersion approves a version with scheduled publish date (DSB only) -func (h *Handler) AdminApproveVersion(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - // Check if user is DSB or Admin (for dev purposes) - if !middleware.IsDSB(c) && !middleware.IsAdmin(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"}) - return - } - - var req struct { - Comment string `json:"comment"` - ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z" - } - c.ShouldBindJSON(&req) - - // Validate scheduled publish date - var scheduledAt *time.Time - if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" { - parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"}) - return - } - if parsed.Before(time.Now()) { - c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"}) - return - } - scheduledAt = &parsed - } - - userID, _ := middleware.GetUserID(c) - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Check current status - var status string - var createdBy *uuid.UUID - err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - if status != "review" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"}) - return - } - - // Four-eyes principle: DSB cannot approve their own version - // Exception: Admins can approve their own versions for development/testing purposes - role, _ := c.Get("role") - roleStr, _ := role.(string) - if createdBy != nil && *createdBy == userID && roleStr != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"}) - return - } - - // Determine new status: 'scheduled' if date set, otherwise 'approved' - newStatus := "approved" - if scheduledAt != nil { - newStatus = "scheduled" - } - - // Update status to approved/scheduled - _, err = h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW() - WHERE id = $1 - `, versionID, newStatus, userID, scheduledAt) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"}) - return - } - - // Log approval action - comment := req.Comment - if comment == "" { - if scheduledAt != nil { - comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04") - } else { - comment = "Approved by DSB" - } - } - _, err = h.db.Pool.Exec(ctx, ` - INSERT INTO version_approvals (version_id, approver_id, action, comment) - VALUES ($1, $2, 'approved', $3) - `, versionID, userID, comment) - - h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent) - - response := gin.H{"message": "Version approved", "status": newStatus} - if scheduledAt != nil { - response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339) - } - c.JSON(http.StatusOK, response) -} - -// AdminRejectVersion rejects a version (DSB only) -func (h *Handler) AdminRejectVersion(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - // Check if user is DSB - if !middleware.IsDSB(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"}) - return - } - - var req struct { - Comment string `json:"comment" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"}) - return - } - - userID, _ := middleware.GetUserID(c) - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Check current status - var status string - err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - if status != "review" && status != "approved" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"}) - return - } - - // Update status back to draft - _, err = h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = 'draft', approved_by = NULL, updated_at = NOW() - WHERE id = $1 - `, versionID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"}) - return - } - - // Log rejection - _, err = h.db.Pool.Exec(ctx, ` - INSERT INTO version_approvals (version_id, approver_id, action, comment) - VALUES ($1, $2, 'rejected', $3) - `, versionID, userID, req.Comment) - - h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent) - - c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"}) -} - -// AdminCompareVersions returns two versions for side-by-side comparison -func (h *Handler) AdminCompareVersions(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - ctx := context.Background() - - // Get the current version and its document - var currentVersion models.DocumentVersion - var documentID uuid.UUID - err = h.db.Pool.QueryRow(ctx, ` - SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at - FROM document_versions - WHERE id = $1 - `, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language, - ¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status, - ¤tVersion.CreatedAt, ¤tVersion.UpdatedAt) - - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) - return - } - - // Get the currently published version (if any) - var publishedVersion *models.DocumentVersion - var pv models.DocumentVersion - err = h.db.Pool.QueryRow(ctx, ` - SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at - FROM document_versions - WHERE document_id = $1 AND language = $2 AND status = 'published' - ORDER BY published_at DESC - LIMIT 1 - `, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language, - &pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt) - - if err == nil && pv.ID != currentVersion.ID { - publishedVersion = &pv - } - - // Get approval history - rows, err := h.db.Pool.Query(ctx, ` - SELECT va.action, va.comment, va.created_at, u.email - FROM version_approvals va - LEFT JOIN users u ON va.approver_id = u.id - WHERE va.version_id = $1 - ORDER BY va.created_at DESC - `, versionID) - - var approvalHistory []map[string]interface{} - if err == nil { - defer rows.Close() - for rows.Next() { - var action, email string - var comment *string - var createdAt time.Time - if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil { - approvalHistory = append(approvalHistory, map[string]interface{}{ - "action": action, - "comment": comment, - "created_at": createdAt, - "approver": email, - }) - } - } - } - - c.JSON(http.StatusOK, gin.H{ - "current_version": currentVersion, - "published_version": publishedVersion, - "approval_history": approvalHistory, - }) -} - -// AdminGetApprovalHistory returns the approval history for a version -func (h *Handler) AdminGetApprovalHistory(c *gin.Context) { - versionID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) - return - } - - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name - FROM version_approvals va - LEFT JOIN users u ON va.approver_id = u.id - WHERE va.version_id = $1 - ORDER BY va.created_at DESC - `, versionID) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"}) - return - } - defer rows.Close() - - var history []map[string]interface{} - for rows.Next() { - var id uuid.UUID - var action string - var comment *string - var createdAt time.Time - var email, name *string - - if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil { - continue - } - - history = append(history, map[string]interface{}{ - "id": id, - "action": action, - "comment": comment, - "created_at": createdAt, - "approver": email, - "name": name, - }) - } - - c.JSON(http.StatusOK, gin.H{"approval_history": history}) -} - // ======================================== // HELPER FUNCTIONS // ======================================== @@ -1681,103 +32,3 @@ func (h *Handler) logAudit(ctx context.Context, userID *uuid.UUID, action, entit func parseIntFromQuery(s string) (int, error) { return strconv.Atoi(s) } - -// ======================================== -// SCHEDULED PUBLISHING -// ======================================== - -// ProcessScheduledPublishing publishes all versions that are due -// This should be called by a cron job or scheduler -func (h *Handler) ProcessScheduledPublishing(c *gin.Context) { - ctx := context.Background() - - // Find all scheduled versions that are due - rows, err := h.db.Pool.Query(ctx, ` - SELECT id, document_id, version - FROM document_versions - WHERE status = 'scheduled' - AND scheduled_publish_at IS NOT NULL - AND scheduled_publish_at <= NOW() - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"}) - return - } - defer rows.Close() - - var published []string - for rows.Next() { - var versionID, docID uuid.UUID - var version string - if err := rows.Scan(&versionID, &docID, &version); err != nil { - continue - } - - // Publish this version - _, err := h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = 'published', published_at = NOW(), updated_at = NOW() - WHERE id = $1 - `, versionID) - - if err == nil { - // Archive previous published versions for this document - h.db.Pool.Exec(ctx, ` - UPDATE document_versions - SET status = 'archived', updated_at = NOW() - WHERE document_id = $1 AND id != $2 AND status = 'published' - `, docID, versionID) - - // Log the publishing - details := fmt.Sprintf("Version %s automatically published by scheduler", version) - h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler") - - published = append(published, version) - } - } - - c.JSON(http.StatusOK, gin.H{ - "message": "Scheduled publishing processed", - "published_count": len(published), - "published_versions": published, - }) -} - -// GetScheduledVersions returns all versions scheduled for publishing -func (h *Handler) GetScheduledVersions(c *gin.Context) { - ctx := context.Background() - - rows, err := h.db.Pool.Query(ctx, ` - SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name - FROM document_versions dv - JOIN legal_documents ld ON ld.id = dv.document_id - WHERE dv.status = 'scheduled' - AND dv.scheduled_publish_at IS NOT NULL - ORDER BY dv.scheduled_publish_at ASC - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"}) - return - } - defer rows.Close() - - type ScheduledVersion struct { - ID uuid.UUID `json:"id"` - DocumentID uuid.UUID `json:"document_id"` - Version string `json:"version"` - Title string `json:"title"` - ScheduledPublishAt *time.Time `json:"scheduled_publish_at"` - DocumentName string `json:"document_name"` - } - - var versions []ScheduledVersion - for rows.Next() { - var v ScheduledVersion - if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil { - continue - } - versions = append(versions, v) - } - - c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions}) -} diff --git a/consent-service/internal/handlers/oauth_2fa_handlers.go b/consent-service/internal/handlers/oauth_2fa_handlers.go new file mode 100644 index 0000000..71d51cf --- /dev/null +++ b/consent-service/internal/handlers/oauth_2fa_handlers.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// 2FA (TOTP) Endpoints +// ======================================== + +// Setup2FA initiates 2FA setup +// POST /auth/2fa/setup +func (h *OAuthHandler) Setup2FA(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + // Get user email + ctx := context.Background() + user, err := h.authService.GetUserByID(ctx, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + return + } + + // Setup 2FA + response, err := h.totpService.Setup2FA(ctx, userID, user.Email) + if err != nil { + switch err { + case services.ErrTOTPAlreadyEnabled: + c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"}) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// Verify2FASetup verifies the 2FA setup with a code +// POST /auth/2fa/verify-setup +func (h *OAuthHandler) Verify2FASetup(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + var req models.Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + err = h.totpService.Verify2FASetup(ctx, userID, req.Code) + if err != nil { + switch err { + case services.ErrTOTPAlreadyEnabled: + c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) +} + +// Verify2FAChallenge verifies a 2FA challenge during login +// POST /auth/2fa/verify +func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) { + var req models.Verify2FAChallengeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + var userID *uuid.UUID + var err error + + if req.RecoveryCode != "" { + // Verify with recovery code + userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode) + } else { + // Verify with TOTP code + userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code) + } + + if err != nil { + switch err { + case services.ErrTOTPChallengeExpired: + c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + case services.ErrRecoveryCodeInvalid: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"}) + } + return + } + + // Get user and generate tokens + user, err := h.authService.GetUserByID(ctx, *userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + return + } + + // Generate access token + accessToken, err := h.authService.GenerateAccessToken(user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + // Generate refresh token + refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"}) + return + } + + // Store session + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // We need direct DB access for this, or we need to add a method to AuthService + // For now, we'll return the tokens and let the caller handle session storage + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "refresh_token": refreshToken, + "token_type": "Bearer", + "expires_in": 3600, + "user": map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + }, + "_session_hash": refreshTokenHash, + "_ip": ipAddress, + "_user_agent": userAgent, + }) +} + +// Disable2FA disables 2FA for the current user +// POST /auth/2fa/disable +func (h *OAuthHandler) Disable2FA(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + var req models.Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + err = h.totpService.Disable2FA(ctx, userID, req.Code) + if err != nil { + switch err { + case services.ErrTOTPNotEnabled: + c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) +} + +// Get2FAStatus returns the 2FA status for the current user +// GET /auth/2fa/status +func (h *OAuthHandler) Get2FAStatus(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + ctx := context.Background() + status, err := h.totpService.GetStatus(ctx, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"}) + return + } + + c.JSON(http.StatusOK, status) +} + +// RegenerateRecoveryCodes generates new recovery codes +// POST /auth/2fa/recovery-codes +func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + var req models.Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code) + if err != nil { + switch err { + case services.ErrTOTPNotEnabled: + c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"recovery_codes": codes}) +} + +// ======================================== +// Enhanced Login with 2FA +// ======================================== + +// LoginWith2FA handles login with optional 2FA +// POST /auth/login +func (h *OAuthHandler) LoginWith2FA(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Attempt login + response, err := h.authService.Login(ctx, &req, ipAddress, userAgent) + if err != nil { + switch err { + case services.ErrInvalidCredentials: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + case services.ErrAccountLocked: + c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"}) + case services.ErrAccountSuspended: + c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) + } + return + } + + // Check if 2FA is enabled + twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID) + + if twoFactorEnabled { + // Create 2FA challenge + challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"}) + return + } + + // Return 2FA required response + c.JSON(http.StatusOK, gin.H{ + "requires_2fa": true, + "challenge_id": challengeID, + "message": "2FA verification required", + }) + return + } + + // No 2FA required, return tokens + c.JSON(http.StatusOK, gin.H{ + "requires_2fa": false, + "access_token": response.AccessToken, + "refresh_token": response.RefreshToken, + "token_type": "Bearer", + "expires_in": response.ExpiresIn, + "user": map[string]interface{}{ + "id": response.User.ID, + "email": response.User.Email, + "name": response.User.Name, + "role": response.User.Role, + }, + }) +} + +// ======================================== +// Registration with mandatory 2FA setup +// ======================================== + +// RegisterWith2FA handles registration with mandatory 2FA setup +// POST /auth/register +func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) { + var req models.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + // Validate password strength + if len(req.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"}) + return + } + + // Register user + user, verificationToken, err := h.authService.Register(ctx, &req) + if err != nil { + switch err { + case services.ErrUserExists: + c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"}) + } + return + } + + // Setup 2FA immediately + twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email) + if err != nil { + // Non-fatal - user can set up 2FA later, but log it + c.JSON(http.StatusCreated, gin.H{ + "message": "Registration successful. Please verify your email.", + "user_id": user.ID, + "verification_token": verificationToken, // In production, this would be sent via email + "two_factor_setup": nil, + "two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Registration successful. Please verify your email and complete 2FA setup.", + "user_id": user.ID, + "verification_token": verificationToken, // In production, this would be sent via email + "two_factor_setup": map[string]interface{}{ + "secret": twoFAResponse.Secret, + "qr_code": twoFAResponse.QRCodeDataURL, + "recovery_codes": twoFAResponse.RecoveryCodes, + "setup_required": true, + "setup_endpoint": "/auth/2fa/verify-setup", + }, + }) +} diff --git a/consent-service/internal/handlers/oauth_handlers.go b/consent-service/internal/handlers/oauth_handlers.go index c796a9e..a79169b 100644 --- a/consent-service/internal/handlers/oauth_handlers.go +++ b/consent-service/internal/handlers/oauth_handlers.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/breakpilot/consent-service/internal/middleware" - "github.com/breakpilot/consent-service/internal/models" "github.com/breakpilot/consent-service/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -292,366 +291,6 @@ func (h *OAuthHandler) Introspect(c *gin.Context) { }) } -// ======================================== -// 2FA (TOTP) Endpoints -// ======================================== - -// Setup2FA initiates 2FA setup -// POST /auth/2fa/setup -func (h *OAuthHandler) Setup2FA(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - // Get user email - ctx := context.Background() - user, err := h.authService.GetUserByID(ctx, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) - return - } - - // Setup 2FA - response, err := h.totpService.Setup2FA(ctx, userID, user.Email) - if err != nil { - switch err { - case services.ErrTOTPAlreadyEnabled: - c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"}) - } - return - } - - c.JSON(http.StatusOK, response) -} - -// Verify2FASetup verifies the 2FA setup with a code -// POST /auth/2fa/verify-setup -func (h *OAuthHandler) Verify2FASetup(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - var req models.Verify2FARequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - err = h.totpService.Verify2FASetup(ctx, userID, req.Code) - if err != nil { - switch err { - case services.ErrTOTPAlreadyEnabled: - c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"}) - case services.ErrTOTPInvalidCode: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"}) - } - return - } - - c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) -} - -// Verify2FAChallenge verifies a 2FA challenge during login -// POST /auth/2fa/verify -func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) { - var req models.Verify2FAChallengeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - var userID *uuid.UUID - var err error - - if req.RecoveryCode != "" { - // Verify with recovery code - userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode) - } else { - // Verify with TOTP code - userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code) - } - - if err != nil { - switch err { - case services.ErrTOTPChallengeExpired: - c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"}) - case services.ErrTOTPInvalidCode: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) - case services.ErrRecoveryCodeInvalid: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"}) - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"}) - } - return - } - - // Get user and generate tokens - user, err := h.authService.GetUserByID(ctx, *userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) - return - } - - // Generate access token - accessToken, err := h.authService.GenerateAccessToken(user) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) - return - } - - // Generate refresh token - refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"}) - return - } - - // Store session - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // We need direct DB access for this, or we need to add a method to AuthService - // For now, we'll return the tokens and let the caller handle session storage - c.JSON(http.StatusOK, gin.H{ - "access_token": accessToken, - "refresh_token": refreshToken, - "token_type": "Bearer", - "expires_in": 3600, - "user": map[string]interface{}{ - "id": user.ID, - "email": user.Email, - "name": user.Name, - "role": user.Role, - }, - "_session_hash": refreshTokenHash, - "_ip": ipAddress, - "_user_agent": userAgent, - }) -} - -// Disable2FA disables 2FA for the current user -// POST /auth/2fa/disable -func (h *OAuthHandler) Disable2FA(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - var req models.Verify2FARequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - err = h.totpService.Disable2FA(ctx, userID, req.Code) - if err != nil { - switch err { - case services.ErrTOTPNotEnabled: - c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"}) - case services.ErrTOTPInvalidCode: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"}) - } - return - } - - c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) -} - -// Get2FAStatus returns the 2FA status for the current user -// GET /auth/2fa/status -func (h *OAuthHandler) Get2FAStatus(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - ctx := context.Background() - status, err := h.totpService.GetStatus(ctx, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"}) - return - } - - c.JSON(http.StatusOK, status) -} - -// RegenerateRecoveryCodes generates new recovery codes -// POST /auth/2fa/recovery-codes -func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) { - userID, err := middleware.GetUserID(c) - if err != nil || userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - var req models.Verify2FARequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code) - if err != nil { - switch err { - case services.ErrTOTPNotEnabled: - c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"}) - case services.ErrTOTPInvalidCode: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"}) - } - return - } - - c.JSON(http.StatusOK, gin.H{"recovery_codes": codes}) -} - -// ======================================== -// Enhanced Login with 2FA -// ======================================== - -// LoginWith2FA handles login with optional 2FA -// POST /auth/login -func (h *OAuthHandler) LoginWith2FA(c *gin.Context) { - var req models.LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - ipAddress := middleware.GetClientIP(c) - userAgent := middleware.GetUserAgent(c) - - // Attempt login - response, err := h.authService.Login(ctx, &req, ipAddress, userAgent) - if err != nil { - switch err { - case services.ErrInvalidCredentials: - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) - case services.ErrAccountLocked: - c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"}) - case services.ErrAccountSuspended: - c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) - } - return - } - - // Check if 2FA is enabled - twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID) - - if twoFactorEnabled { - // Create 2FA challenge - challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"}) - return - } - - // Return 2FA required response - c.JSON(http.StatusOK, gin.H{ - "requires_2fa": true, - "challenge_id": challengeID, - "message": "2FA verification required", - }) - return - } - - // No 2FA required, return tokens - c.JSON(http.StatusOK, gin.H{ - "requires_2fa": false, - "access_token": response.AccessToken, - "refresh_token": response.RefreshToken, - "token_type": "Bearer", - "expires_in": response.ExpiresIn, - "user": map[string]interface{}{ - "id": response.User.ID, - "email": response.User.Email, - "name": response.User.Name, - "role": response.User.Role, - }, - }) -} - -// ======================================== -// Registration with mandatory 2FA setup -// ======================================== - -// RegisterWith2FA handles registration with mandatory 2FA setup -// POST /auth/register -func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) { - var req models.RegisterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - ctx := context.Background() - - // Validate password strength - if len(req.Password) < 8 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"}) - return - } - - // Register user - user, verificationToken, err := h.authService.Register(ctx, &req) - if err != nil { - switch err { - case services.ErrUserExists: - c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"}) - } - return - } - - // Setup 2FA immediately - twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email) - if err != nil { - // Non-fatal - user can set up 2FA later, but log it - c.JSON(http.StatusCreated, gin.H{ - "message": "Registration successful. Please verify your email.", - "user_id": user.ID, - "verification_token": verificationToken, // In production, this would be sent via email - "two_factor_setup": nil, - "two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.", - }) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Registration successful. Please verify your email and complete 2FA setup.", - "user_id": user.ID, - "verification_token": verificationToken, // In production, this would be sent via email - "two_factor_setup": map[string]interface{}{ - "secret": twoFAResponse.Secret, - "qr_code": twoFAResponse.QRCodeDataURL, - "recovery_codes": twoFAResponse.RecoveryCodes, - "setup_required": true, - "setup_endpoint": "/auth/2fa/verify-setup", - }, - }) -} - // ======================================== // OAuth Client Management (Admin) // ======================================== diff --git a/consent-service/internal/handlers/school_attendance_handlers.go b/consent-service/internal/handlers/school_attendance_handlers.go new file mode 100644 index 0000000..8de3da3 --- /dev/null +++ b/consent-service/internal/handlers/school_attendance_handlers.go @@ -0,0 +1,243 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// Attendance Handlers +// ======================================== + +// RecordAttendance records attendance for a student +// POST /api/v1/attendance +func (h *SchoolHandlers) RecordAttendance(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req models.RecordAttendanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, record) +} + +// RecordBulkAttendance records attendance for multiple students +// POST /api/v1/classes/:id/attendance +func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + var req struct { + Date string `json:"date" binding:"required"` + SlotID string `json:"slot_id" binding:"required"` + Records []struct { + StudentID string `json:"student_id"` + Status string `json:"status"` + Note *string `json:"note"` + } `json:"records" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + slotID, err := uuid.Parse(req.SlotID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"}) + return + } + + // Convert to the expected type (without JSON tags) + records := make([]struct { + StudentID string + Status string + Note *string + }, len(req.Records)) + for i, r := range req.Records { + records[i] = struct { + StudentID string + Status string + Note *string + }{ + StudentID: r.StudentID, + Status: r.Status, + Note: r.Note, + } + } + + err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"}) +} + +// GetClassAttendance gets attendance for a class on a specific date +// GET /api/v1/classes/:id/attendance?date=... +func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + date := c.Query("date") + if date == "" { + date = time.Now().Format("2006-01-02") + } + + overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, overview) +} + +// GetStudentAttendance gets attendance history for a student +// GET /api/v1/students/:id/attendance?start_date=...&end_date=... +func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) { + studentIDStr := c.Param("id") + studentID, err := uuid.Parse(studentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + startDateStr := c.Query("start_date") + endDateStr := c.Query("end_date") + + var startDate, endDate time.Time + if startDateStr == "" { + startDate = time.Now().AddDate(0, -1, 0) // Last month + } else { + startDate, _ = time.Parse("2006-01-02", startDateStr) + } + + if endDateStr == "" { + endDate = time.Now() + } else { + endDate, _ = time.Parse("2006-01-02", endDateStr) + } + + records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, records) +} + +// ======================================== +// Absence Report Handlers +// ======================================== + +// ReportAbsence allows parents to report absence +// POST /api/v1/absence/report +func (h *SchoolHandlers) ReportAbsence(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req models.ReportAbsenceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, report) +} + +// ConfirmAbsence allows teachers to confirm absence +// PUT /api/v1/absence/:id/confirm +func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + reportIDStr := c.Param("id") + reportID, err := uuid.Parse(reportIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + var req struct { + Status string `json:"status" binding:"required"` // "excused" or "unexcused" + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"}) +} + +// GetPendingAbsenceReports gets pending absence reports for a class +// GET /api/v1/classes/:id/absence/pending +func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, reports) +} diff --git a/consent-service/internal/handlers/school_grade_handlers.go b/consent-service/internal/handlers/school_grade_handlers.go new file mode 100644 index 0000000..8579474 --- /dev/null +++ b/consent-service/internal/handlers/school_grade_handlers.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// Grade Handlers +// ======================================== + +// CreateGrade creates a new grade +// POST /api/v1/grades +func (h *SchoolHandlers) CreateGrade(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req models.CreateGradeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get teacher ID from user ID + teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"}) + return + } + + grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, grade) +} + +// GetStudentGrades gets all grades for a student +// GET /api/v1/students/:id/grades?school_year_id=... +func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) { + studentIDStr := c.Param("id") + studentID, err := uuid.Parse(studentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) + return + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, grades) +} + +// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel) +// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=... +func (h *SchoolHandlers) GetClassGrades(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + subjectIDStr := c.Param("subjectId") + subjectID, err := uuid.Parse(subjectIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) + return + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + semesterStr := c.DefaultQuery("semester", "1") + var semester int + if semesterStr == "1" { + semester = 1 + } else { + semester = 2 + } + + overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, overviews) +} + +// GetGradeStatistics gets grade statistics for a class/subject +// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=... +func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + subjectIDStr := c.Param("subjectId") + subjectID, err := uuid.Parse(subjectIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) + return + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + semesterStr := c.DefaultQuery("semester", "1") + var semester int + if semesterStr == "1" { + semester = 1 + } else { + semester = 2 + } + + stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ======================================== +// Parent Onboarding Handlers +// ======================================== + +// GenerateOnboardingToken generates a QR code token for parent onboarding +// POST /api/v1/onboarding/tokens +func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req struct { + SchoolID string `json:"school_id" binding:"required"` + ClassID string `json:"class_id" binding:"required"` + StudentID string `json:"student_id" binding:"required"` + Role string `json:"role"` // "parent" or "parent_representative" + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + schoolID, err := uuid.Parse(req.SchoolID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + classID, err := uuid.Parse(req.ClassID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + studentID, err := uuid.Parse(req.StudentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + role := req.Role + if role == "" { + role = "parent" + } + + token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Generate QR code URL + qrURL := "/onboard-parent?token=" + token.Token + + c.JSON(http.StatusCreated, gin.H{ + "token": token.Token, + "qr_url": qrURL, + "expires_at": token.ExpiresAt, + }) +} + +// ValidateOnboardingToken validates an onboarding token +// GET /api/v1/onboarding/validate?token=... +func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) + return + } + + onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"}) + return + } + + // Get student and school info + student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "valid": true, + "role": onboardingToken.Role, + "student_name": student.FirstName + " " + student.LastName, + "class_name": class.Name, + "school_name": school.Name, + "expires_at": onboardingToken.ExpiresAt, + }) +} + +// RedeemOnboardingToken redeems a token and creates parent account +// POST /api/v1/onboarding/redeem +func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req struct { + Token string `json:"token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"}) +} diff --git a/consent-service/internal/handlers/school_handlers.go b/consent-service/internal/handlers/school_handlers.go index aa52167..1d9b198 100644 --- a/consent-service/internal/handlers/school_handlers.go +++ b/consent-service/internal/handlers/school_handlers.go @@ -355,531 +355,6 @@ func (h *SchoolHandlers) ListSubjects(c *gin.Context) { c.JSON(http.StatusOK, subjects) } -// ======================================== -// Attendance Handlers -// ======================================== - -// RecordAttendance records attendance for a student -// POST /api/v1/attendance -func (h *SchoolHandlers) RecordAttendance(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - var req models.RecordAttendanceRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, record) -} - -// RecordBulkAttendance records attendance for multiple students -// POST /api/v1/classes/:id/attendance -func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - classIDStr := c.Param("id") - classID, err := uuid.Parse(classIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) - return - } - - var req struct { - Date string `json:"date" binding:"required"` - SlotID string `json:"slot_id" binding:"required"` - Records []struct { - StudentID string `json:"student_id"` - Status string `json:"status"` - Note *string `json:"note"` - } `json:"records" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - slotID, err := uuid.Parse(req.SlotID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"}) - return - } - - // Convert to the expected type (without JSON tags) - records := make([]struct { - StudentID string - Status string - Note *string - }, len(req.Records)) - for i, r := range req.Records { - records[i] = struct { - StudentID string - Status string - Note *string - }{ - StudentID: r.StudentID, - Status: r.Status, - Note: r.Note, - } - } - - err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"}) -} - -// GetClassAttendance gets attendance for a class on a specific date -// GET /api/v1/classes/:id/attendance?date=... -func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) { - classIDStr := c.Param("id") - classID, err := uuid.Parse(classIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) - return - } - - date := c.Query("date") - if date == "" { - date = time.Now().Format("2006-01-02") - } - - overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, overview) -} - -// GetStudentAttendance gets attendance history for a student -// GET /api/v1/students/:id/attendance?start_date=...&end_date=... -func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) { - studentIDStr := c.Param("id") - studentID, err := uuid.Parse(studentIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) - return - } - - startDateStr := c.Query("start_date") - endDateStr := c.Query("end_date") - - var startDate, endDate time.Time - if startDateStr == "" { - startDate = time.Now().AddDate(0, -1, 0) // Last month - } else { - startDate, _ = time.Parse("2006-01-02", startDateStr) - } - - if endDateStr == "" { - endDate = time.Now() - } else { - endDate, _ = time.Parse("2006-01-02", endDateStr) - } - - records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, records) -} - -// ======================================== -// Absence Report Handlers -// ======================================== - -// ReportAbsence allows parents to report absence -// POST /api/v1/absence/report -func (h *SchoolHandlers) ReportAbsence(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - var req models.ReportAbsenceRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, report) -} - -// ConfirmAbsence allows teachers to confirm absence -// PUT /api/v1/absence/:id/confirm -func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - reportIDStr := c.Param("id") - reportID, err := uuid.Parse(reportIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - var req struct { - Status string `json:"status" binding:"required"` // "excused" or "unexcused" - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"}) -} - -// GetPendingAbsenceReports gets pending absence reports for a class -// GET /api/v1/classes/:id/absence/pending -func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) { - classIDStr := c.Param("id") - classID, err := uuid.Parse(classIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) - return - } - - reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, reports) -} - -// ======================================== -// Grade Handlers -// ======================================== - -// CreateGrade creates a new grade -// POST /api/v1/grades -func (h *SchoolHandlers) CreateGrade(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - var req models.CreateGradeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get teacher ID from user ID - teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID)) - if err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"}) - return - } - - grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, grade) -} - -// GetStudentGrades gets all grades for a student -// GET /api/v1/students/:id/grades?school_year_id=... -func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) { - studentIDStr := c.Param("id") - studentID, err := uuid.Parse(studentIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) - return - } - - schoolYearIDStr := c.Query("school_year_id") - if schoolYearIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) - return - } - - schoolYearID, err := uuid.Parse(schoolYearIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) - return - } - - grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, grades) -} - -// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel) -// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=... -func (h *SchoolHandlers) GetClassGrades(c *gin.Context) { - classIDStr := c.Param("id") - classID, err := uuid.Parse(classIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) - return - } - - subjectIDStr := c.Param("subjectId") - subjectID, err := uuid.Parse(subjectIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"}) - return - } - - schoolYearIDStr := c.Query("school_year_id") - if schoolYearIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) - return - } - - schoolYearID, err := uuid.Parse(schoolYearIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) - return - } - - semesterStr := c.DefaultQuery("semester", "1") - var semester int - if semesterStr == "1" { - semester = 1 - } else { - semester = 2 - } - - overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, overviews) -} - -// GetGradeStatistics gets grade statistics for a class/subject -// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=... -func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) { - classIDStr := c.Param("id") - classID, err := uuid.Parse(classIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) - return - } - - subjectIDStr := c.Param("subjectId") - subjectID, err := uuid.Parse(subjectIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"}) - return - } - - schoolYearIDStr := c.Query("school_year_id") - if schoolYearIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) - return - } - - schoolYearID, err := uuid.Parse(schoolYearIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) - return - } - - semesterStr := c.DefaultQuery("semester", "1") - var semester int - if semesterStr == "1" { - semester = 1 - } else { - semester = 2 - } - - stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, stats) -} - -// ======================================== -// Parent Onboarding Handlers -// ======================================== - -// GenerateOnboardingToken generates a QR code token for parent onboarding -// POST /api/v1/onboarding/tokens -func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - var req struct { - SchoolID string `json:"school_id" binding:"required"` - ClassID string `json:"class_id" binding:"required"` - StudentID string `json:"student_id" binding:"required"` - Role string `json:"role"` // "parent" or "parent_representative" - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - schoolID, err := uuid.Parse(req.SchoolID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) - return - } - - classID, err := uuid.Parse(req.ClassID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) - return - } - - studentID, err := uuid.Parse(req.StudentID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) - return - } - - role := req.Role - if role == "" { - role = "parent" - } - - token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Generate QR code URL - qrURL := "/onboard-parent?token=" + token.Token - - c.JSON(http.StatusCreated, gin.H{ - "token": token.Token, - "qr_url": qrURL, - "expires_at": token.ExpiresAt, - }) -} - -// ValidateOnboardingToken validates an onboarding token -// GET /api/v1/onboarding/validate?token=... -func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) { - token := c.Query("token") - if token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) - return - } - - onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"}) - return - } - - // Get student and school info - student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "valid": true, - "role": onboardingToken.Role, - "student_name": student.FirstName + " " + student.LastName, - "class_name": class.Name, - "school_name": school.Name, - "expires_at": onboardingToken.ExpiresAt, - }) -} - -// RedeemOnboardingToken redeems a token and creates parent account -// POST /api/v1/onboarding/redeem -func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - - var req struct { - Token string `json:"token" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"}) -} - // ======================================== // Register Routes // ======================================== diff --git a/consent-service/internal/models/consent.go b/consent-service/internal/models/consent.go new file mode 100644 index 0000000..bdfe165 --- /dev/null +++ b/consent-service/internal/models/consent.go @@ -0,0 +1,237 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// LegalDocument represents a type of legal document (e.g., Terms, Privacy Policy) +type LegalDocument struct { + ID uuid.UUID `json:"id" db:"id"` + Type string `json:"type" db:"type"` // 'terms', 'privacy', 'cookies', 'community' + Name string `json:"name" db:"name"` + Description *string `json:"description" db:"description"` + IsMandatory bool `json:"is_mandatory" db:"is_mandatory"` + IsActive bool `json:"is_active" db:"is_active"` + SortOrder int `json:"sort_order" db:"sort_order"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// DocumentVersion represents a specific version of a legal document +type DocumentVersion struct { + ID uuid.UUID `json:"id" db:"id"` + DocumentID uuid.UUID `json:"document_id" db:"document_id"` + Version string `json:"version" db:"version"` // Semver: 1.0.0, 1.1.0 + Language string `json:"language" db:"language"` // ISO 639-1: de, en + Title string `json:"title" db:"title"` + Content string `json:"content" db:"content"` // HTML or Markdown + Summary *string `json:"summary" db:"summary"` // Summary of changes + Status string `json:"status" db:"status"` // 'draft', 'review', 'approved', 'scheduled', 'published', 'archived' + PublishedAt *time.Time `json:"published_at" db:"published_at"` + ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"` + CreatedBy *uuid.UUID `json:"created_by" db:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at" db:"approved_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// UserConsent represents a user's consent to a document version +type UserConsent struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"` + Consented bool `json:"consented" db:"consented"` + IPAddress *string `json:"ip_address" db:"ip_address"` + UserAgent *string `json:"user_agent" db:"user_agent"` + ConsentedAt time.Time `json:"consented_at" db:"consented_at"` + WithdrawnAt *time.Time `json:"withdrawn_at" db:"withdrawn_at"` +} + +// AuditLog represents an audit trail entry for GDPR compliance +type AuditLog struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id" db:"user_id"` + Action string `json:"action" db:"action"` // 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete' + EntityType *string `json:"entity_type" db:"entity_type"` // 'document', 'cookie_category' + EntityID *uuid.UUID `json:"entity_id" db:"entity_id"` + Details *string `json:"details" db:"details"` // JSON string + IPAddress *string `json:"ip_address" db:"ip_address"` + UserAgent *string `json:"user_agent" db:"user_agent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// DataExportRequest represents a user's request to export their data +type DataExportRequest struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed' + DownloadURL *string `json:"download_url" db:"download_url"` + ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CompletedAt *time.Time `json:"completed_at" db:"completed_at"` +} + +// DataDeletionRequest represents a user's request to delete their data +type DataDeletionRequest struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed' + Reason *string `json:"reason" db:"reason"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + ProcessedAt *time.Time `json:"processed_at" db:"processed_at"` + ProcessedBy *uuid.UUID `json:"processed_by" db:"processed_by"` +} + +// VersionApproval tracks the approval workflow +type VersionApproval struct { + ID uuid.UUID `json:"id" db:"id"` + VersionID uuid.UUID `json:"version_id" db:"version_id"` + ApproverID uuid.UUID `json:"approver_id" db:"approver_id"` + Action string `json:"action" db:"action"` // 'submitted_for_review', 'approved', 'rejected', 'published' + Comment *string `json:"comment,omitempty" db:"comment"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ConsentDeadline tracks consent deadlines per user +type ConsentDeadline struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"` + DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"` + ReminderCount int `json:"reminder_count" db:"reminder_count"` + LastReminderAt *time.Time `json:"last_reminder_at,omitempty" db:"last_reminder_at"` + ConsentGivenAt *time.Time `json:"consent_given_at,omitempty" db:"consent_given_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// AccountSuspension tracks account suspensions +type AccountSuspension struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Reason string `json:"reason" db:"reason"` // 'consent_deadline_exceeded' + Details *string `json:"details,omitempty" db:"details"` // JSON + SuspendedAt time.Time `json:"suspended_at" db:"suspended_at"` + LiftedAt *time.Time `json:"lifted_at,omitempty" db:"lifted_at"` + LiftedReason *string `json:"lifted_reason,omitempty" db:"lifted_reason"` +} + +// ======================================== +// Consent DTOs +// ======================================== + +// CreateConsentRequest is the request body for creating a consent +type CreateConsentRequest struct { + DocumentType string `json:"document_type" binding:"required"` + VersionID string `json:"version_id" binding:"required"` + Consented bool `json:"consented"` +} + +// ConsentCheckResponse is the response for checking consent status +type ConsentCheckResponse struct { + HasConsent bool `json:"has_consent"` + CurrentVersionID *string `json:"current_version_id,omitempty"` + ConsentedVersion *string `json:"consented_version,omitempty"` + NeedsUpdate bool `json:"needs_update"` + ConsentedAt *time.Time `json:"consented_at,omitempty"` +} + +// DocumentWithVersion combines document info with its latest published version +type DocumentWithVersion struct { + Document LegalDocument `json:"document"` + LatestVersion *DocumentVersion `json:"latest_version,omitempty"` +} + +// ConsentHistory represents a user's consent history for a document +type ConsentHistory struct { + Document LegalDocument `json:"document"` + Version DocumentVersion `json:"version"` + Consent UserConsent `json:"consent"` +} + +// ConsentStats represents statistics about consents +type ConsentStats struct { + TotalUsers int `json:"total_users"` + ConsentedUsers int `json:"consented_users"` + ConsentRate float64 `json:"consent_rate"` + RecentConsents int `json:"recent_consents"` // Last 7 days + RecentWithdrawals int `json:"recent_withdrawals"` +} + +// MyDataResponse represents all data we have about a user +type MyDataResponse struct { + User User `json:"user"` + Consents []ConsentHistory `json:"consents"` + CookieConsents []CookieConsent `json:"cookie_consents"` + AuditLog []AuditLog `json:"audit_log"` + ExportedAt time.Time `json:"exported_at"` +} + +// CreateDocumentRequest is the request body for creating a document +type CreateDocumentRequest struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + Description *string `json:"description"` + IsMandatory bool `json:"is_mandatory"` +} + +// CreateVersionRequest is the request body for creating a document version +type CreateVersionRequest struct { + DocumentID string `json:"document_id" binding:"required"` + Version string `json:"version" binding:"required"` + Language string `json:"language" binding:"required"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Summary *string `json:"summary"` +} + +// UpdateVersionRequest is the request body for updating a version +type UpdateVersionRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` + Summary *string `json:"summary"` + Status *string `json:"status"` +} + +// SubmitForReviewRequest for submitting a version for review +type SubmitForReviewRequest struct { + Comment *string `json:"comment"` +} + +// ApproveVersionRequest for approving a version (DSB) +type ApproveVersionRequest struct { + Comment *string `json:"comment"` + ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601 datetime for scheduled publishing +} + +// RejectVersionRequest for rejecting a version +type RejectVersionRequest struct { + Comment string `json:"comment" binding:"required"` +} + +// VersionCompareResponse for comparing versions +type VersionCompareResponse struct { + Published *DocumentVersion `json:"published,omitempty"` + Draft *DocumentVersion `json:"draft"` + Diff *string `json:"diff,omitempty"` + Approvals []VersionApproval `json:"approvals"` +} + +// PendingConsentResponse for pending consents with deadline info +type PendingConsentResponse struct { + Document LegalDocument `json:"document"` + Version DocumentVersion `json:"version"` + DeadlineAt time.Time `json:"deadline_at"` + DaysLeft int `json:"days_left"` + IsOverdue bool `json:"is_overdue"` +} + +// AccountStatusResponse for account status check +type AccountStatusResponse struct { + Status string `json:"status"` // 'active', 'suspended' + PendingConsents []PendingConsentResponse `json:"pending_consents,omitempty"` + SuspensionReason *string `json:"suspension_reason,omitempty"` + CanAccess bool `json:"can_access"` +} diff --git a/consent-service/internal/models/cookies_notifications.go b/consent-service/internal/models/cookies_notifications.go new file mode 100644 index 0000000..c45e71b --- /dev/null +++ b/consent-service/internal/models/cookies_notifications.go @@ -0,0 +1,118 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CookieCategory represents a category of cookies +type CookieCategory struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` // 'necessary', 'functional', 'analytics', 'marketing' + DisplayNameDE string `json:"display_name_de" db:"display_name_de"` + DisplayNameEN *string `json:"display_name_en" db:"display_name_en"` + DescriptionDE *string `json:"description_de" db:"description_de"` + DescriptionEN *string `json:"description_en" db:"description_en"` + IsMandatory bool `json:"is_mandatory" db:"is_mandatory"` + SortOrder int `json:"sort_order" db:"sort_order"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CookieConsent represents a user's cookie preferences +type CookieConsent struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + CategoryID uuid.UUID `json:"category_id" db:"category_id"` + Consented bool `json:"consented" db:"consented"` + ConsentedAt time.Time `json:"consented_at" db:"consented_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CookieConsentRequest is the request body for setting cookie preferences +type CookieConsentRequest struct { + Categories []CookieCategoryConsent `json:"categories" binding:"required"` +} + +// CookieCategoryConsent represents consent for a single cookie category +type CookieCategoryConsent struct { + CategoryID string `json:"category_id" binding:"required"` + Consented bool `json:"consented"` +} + +// CookieStats represents statistics about cookie consents +type CookieStats struct { + Category string `json:"category"` + TotalUsers int `json:"total_users"` + ConsentedUsers int `json:"consented_users"` + ConsentRate float64 `json:"consent_rate"` +} + +// CreateCookieCategoryRequest is the request body for creating a cookie category +type CreateCookieCategoryRequest struct { + Name string `json:"name" binding:"required"` + DisplayNameDE string `json:"display_name_de" binding:"required"` + DisplayNameEN *string `json:"display_name_en"` + DescriptionDE *string `json:"description_de"` + DescriptionEN *string `json:"description_en"` + IsMandatory bool `json:"is_mandatory"` + SortOrder int `json:"sort_order"` +} + +// ======================================== +// Notification Models +// ======================================== + +// Notification represents a user notification +type Notification struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Type string `json:"type" db:"type"` // 'new_version', 'consent_reminder', 'account_warning' + Channel string `json:"channel" db:"channel"` // 'email', 'in_app', 'push' + Title string `json:"title" db:"title"` + Body string `json:"body" db:"body"` + Data *string `json:"data,omitempty" db:"data"` // JSON string + ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// PushSubscription for Web Push notifications +type PushSubscription struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Endpoint string `json:"endpoint" db:"endpoint"` + P256dh string `json:"p256dh" db:"p256dh"` + Auth string `json:"auth" db:"auth"` + UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// NotificationPreferences for user notification settings +type NotificationPreferences struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + EmailEnabled bool `json:"email_enabled" db:"email_enabled"` + PushEnabled bool `json:"push_enabled" db:"push_enabled"` + InAppEnabled bool `json:"in_app_enabled" db:"in_app_enabled"` + ReminderFrequency string `json:"reminder_frequency" db:"reminder_frequency"` // 'daily', 'weekly', 'never' + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SubscribePushRequest for subscribing to push notifications +type SubscribePushRequest struct { + Endpoint string `json:"endpoint" binding:"required"` + P256dh string `json:"p256dh" binding:"required"` + Auth string `json:"auth" binding:"required"` +} + +// UpdateNotificationPreferencesRequest for updating preferences +type UpdateNotificationPreferencesRequest struct { + EmailEnabled *bool `json:"email_enabled"` + PushEnabled *bool `json:"push_enabled"` + InAppEnabled *bool `json:"in_app_enabled"` + ReminderFrequency *string `json:"reminder_frequency"` +} diff --git a/consent-service/internal/models/dsr.go b/consent-service/internal/models/dsr.go new file mode 100644 index 0000000..4d3ecc9 --- /dev/null +++ b/consent-service/internal/models/dsr.go @@ -0,0 +1,403 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// ======================================== +// DSGVO Betroffenenanfragen (DSR) +// Data Subject Request Management +// Art. 15, 16, 17, 18, 20 DSGVO +// ======================================== + +// DSRRequestType defines the GDPR article for the request +type DSRRequestType string + +const ( + DSRTypeAccess DSRRequestType = "access" // Art. 15 - Auskunftsrecht + DSRTypeRectification DSRRequestType = "rectification" // Art. 16 - Berichtigungsrecht + DSRTypeErasure DSRRequestType = "erasure" // Art. 17 - Löschungsrecht + DSRTypeRestriction DSRRequestType = "restriction" // Art. 18 - Einschränkungsrecht + DSRTypePortability DSRRequestType = "portability" // Art. 20 - Datenübertragbarkeit +) + +// DSRStatus defines the workflow state of a DSR +type DSRStatus string + +const ( + DSRStatusIntake DSRStatus = "intake" // Eingegangen + DSRStatusIdentityVerification DSRStatus = "identity_verification" // Identitätsprüfung + DSRStatusProcessing DSRStatus = "processing" // In Bearbeitung + DSRStatusCompleted DSRStatus = "completed" // Abgeschlossen + DSRStatusRejected DSRStatus = "rejected" // Abgelehnt + DSRStatusCancelled DSRStatus = "cancelled" // Storniert +) + +// DSRPriority defines the priority level of a DSR +type DSRPriority string + +const ( + DSRPriorityNormal DSRPriority = "normal" + DSRPriorityExpedited DSRPriority = "expedited" // Art. 16, 17, 18 - beschleunigt + DSRPriorityUrgent DSRPriority = "urgent" +) + +// DSRSource defines where the request came from +type DSRSource string + +const ( + DSRSourceAPI DSRSource = "api" // Über API/Self-Service + DSRSourceAdminPanel DSRSource = "admin_panel" // Manuell im Admin + DSRSourceEmail DSRSource = "email" // Per E-Mail + DSRSourcePostal DSRSource = "postal" // Per Post +) + +// Art. 17(3) Exception Types +const ( + DSRExceptionFreedomExpression = "freedom_expression" // Art. 17(3)(a) + DSRExceptionLegalObligation = "legal_obligation" // Art. 17(3)(b) + DSRExceptionPublicInterest = "public_interest" // Art. 17(3)(c) + DSRExceptionPublicHealth = "public_health" // Art. 17(3)(c) + DSRExceptionArchiving = "archiving" // Art. 17(3)(d) + DSRExceptionLegalClaims = "legal_claims" // Art. 17(3)(e) +) + +// DataSubjectRequest represents a GDPR data subject request +type DataSubjectRequest struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` + RequestNumber string `json:"request_number" db:"request_number"` + RequestType DSRRequestType `json:"request_type" db:"request_type"` + Status DSRStatus `json:"status" db:"status"` + Priority DSRPriority `json:"priority" db:"priority"` + Source DSRSource `json:"source" db:"source"` + RequesterEmail string `json:"requester_email" db:"requester_email"` + RequesterName *string `json:"requester_name,omitempty" db:"requester_name"` + RequesterPhone *string `json:"requester_phone,omitempty" db:"requester_phone"` + IdentityVerified bool `json:"identity_verified" db:"identity_verified"` + IdentityVerifiedAt *time.Time `json:"identity_verified_at,omitempty" db:"identity_verified_at"` + IdentityVerifiedBy *uuid.UUID `json:"identity_verified_by,omitempty" db:"identity_verified_by"` + IdentityVerificationMethod *string `json:"identity_verification_method,omitempty" db:"identity_verification_method"` + RequestDetails map[string]interface{} `json:"request_details" db:"request_details"` + DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"` + LegalDeadlineDays int `json:"legal_deadline_days" db:"legal_deadline_days"` + ExtendedDeadlineAt *time.Time `json:"extended_deadline_at,omitempty" db:"extended_deadline_at"` + ExtensionReason *string `json:"extension_reason,omitempty" db:"extension_reason"` + AssignedTo *uuid.UUID `json:"assigned_to,omitempty" db:"assigned_to"` + ProcessingNotes *string `json:"processing_notes,omitempty" db:"processing_notes"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + CompletedBy *uuid.UUID `json:"completed_by,omitempty" db:"completed_by"` + ResultSummary *string `json:"result_summary,omitempty" db:"result_summary"` + ResultData map[string]interface{} `json:"result_data,omitempty" db:"result_data"` + RejectedAt *time.Time `json:"rejected_at,omitempty" db:"rejected_at"` + RejectedBy *uuid.UUID `json:"rejected_by,omitempty" db:"rejected_by"` + RejectionReason *string `json:"rejection_reason,omitempty" db:"rejection_reason"` + RejectionLegalBasis *string `json:"rejection_legal_basis,omitempty" db:"rejection_legal_basis"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` +} + +// DSRStatusHistory tracks status changes for audit trail +type DSRStatusHistory struct { + ID uuid.UUID `json:"id" db:"id"` + RequestID uuid.UUID `json:"request_id" db:"request_id"` + FromStatus *DSRStatus `json:"from_status,omitempty" db:"from_status"` + ToStatus DSRStatus `json:"to_status" db:"to_status"` + ChangedBy *uuid.UUID `json:"changed_by,omitempty" db:"changed_by"` + Comment *string `json:"comment,omitempty" db:"comment"` + Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// DSRCommunication tracks all communications related to a DSR +type DSRCommunication struct { + ID uuid.UUID `json:"id" db:"id"` + RequestID uuid.UUID `json:"request_id" db:"request_id"` + Direction string `json:"direction" db:"direction"` + Channel string `json:"channel" db:"channel"` + CommunicationType string `json:"communication_type" db:"communication_type"` + TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty" db:"template_version_id"` + Subject *string `json:"subject,omitempty" db:"subject"` + BodyHTML *string `json:"body_html,omitempty" db:"body_html"` + BodyText *string `json:"body_text,omitempty" db:"body_text"` + RecipientEmail *string `json:"recipient_email,omitempty" db:"recipient_email"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + ErrorMessage *string `json:"error_message,omitempty" db:"error_message"` + Attachments []map[string]interface{} `json:"attachments,omitempty" db:"attachments"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` +} + +// DSRTemplate represents a template type for DSR communications +type DSRTemplate struct { + ID uuid.UUID `json:"id" db:"id"` + TemplateType string `json:"template_type" db:"template_type"` + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + RequestTypes []string `json:"request_types" db:"request_types"` + IsActive bool `json:"is_active" db:"is_active"` + SortOrder int `json:"sort_order" db:"sort_order"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// DSRTemplateVersion represents a versioned template for DSR communications +type DSRTemplateVersion struct { + ID uuid.UUID `json:"id" db:"id"` + TemplateID uuid.UUID `json:"template_id" db:"template_id"` + Version string `json:"version" db:"version"` + Language string `json:"language" db:"language"` + Subject string `json:"subject" db:"subject"` + BodyHTML string `json:"body_html" db:"body_html"` + BodyText string `json:"body_text" db:"body_text"` + Status string `json:"status" db:"status"` + PublishedAt *time.Time `json:"published_at,omitempty" db:"published_at"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by,omitempty" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// DSRExceptionCheck tracks Art. 17(3) exception evaluations for erasure requests +type DSRExceptionCheck struct { + ID uuid.UUID `json:"id" db:"id"` + RequestID uuid.UUID `json:"request_id" db:"request_id"` + ExceptionType string `json:"exception_type" db:"exception_type"` + Description string `json:"description" db:"description"` + Applies *bool `json:"applies,omitempty" db:"applies"` + CheckedBy *uuid.UUID `json:"checked_by,omitempty" db:"checked_by"` + CheckedAt *time.Time `json:"checked_at,omitempty" db:"checked_at"` + Notes *string `json:"notes,omitempty" db:"notes"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ======================================== +// DSR DTOs +// ======================================== + +// CreateDSRRequest for creating a new data subject request +type CreateDSRRequest struct { + RequestType string `json:"request_type" binding:"required"` + RequesterEmail string `json:"requester_email" binding:"required,email"` + RequesterName *string `json:"requester_name"` + RequesterPhone *string `json:"requester_phone"` + Source string `json:"source"` + RequestDetails map[string]interface{} `json:"request_details"` + Priority string `json:"priority"` +} + +// UpdateDSRRequest for updating a DSR +type UpdateDSRRequest struct { + Status *string `json:"status"` + AssignedTo *string `json:"assigned_to"` + ProcessingNotes *string `json:"processing_notes"` + ExtendDeadline *bool `json:"extend_deadline"` + ExtensionReason *string `json:"extension_reason"` + RequestDetails map[string]interface{} `json:"request_details"` + Priority *string `json:"priority"` +} + +// VerifyDSRIdentityRequest for verifying identity of requester +type VerifyDSRIdentityRequest struct { + Method string `json:"method" binding:"required"` + Comment *string `json:"comment"` +} + +// CompleteDSRRequest for completing a DSR +type CompleteDSRRequest struct { + ResultSummary string `json:"result_summary" binding:"required"` + ResultData map[string]interface{} `json:"result_data"` +} + +// RejectDSRRequest for rejecting a DSR +type RejectDSRRequest struct { + Reason string `json:"reason" binding:"required"` + LegalBasis string `json:"legal_basis" binding:"required"` +} + +// ExtendDSRDeadlineRequest for extending a DSR deadline +type ExtendDSRDeadlineRequest struct { + Reason string `json:"reason" binding:"required"` + Days int `json:"days"` +} + +// AssignDSRRequest for assigning a DSR to a handler +type AssignDSRRequest struct { + AssigneeID string `json:"assignee_id" binding:"required"` + Comment *string `json:"comment"` +} + +// SendDSRCommunicationRequest for sending a communication +type SendDSRCommunicationRequest struct { + CommunicationType string `json:"communication_type" binding:"required"` + TemplateVersionID *string `json:"template_version_id"` + CustomSubject *string `json:"custom_subject"` + CustomBody *string `json:"custom_body"` + Variables map[string]string `json:"variables"` +} + +// UpdateDSRExceptionCheckRequest for updating an exception check +type UpdateDSRExceptionCheckRequest struct { + Applies bool `json:"applies"` + Notes *string `json:"notes"` +} + +// DSRListFilters for filtering DSR list +type DSRListFilters struct { + Status *string `form:"status"` + RequestType *string `form:"request_type"` + AssignedTo *string `form:"assigned_to"` + Priority *string `form:"priority"` + OverdueOnly bool `form:"overdue_only"` + FromDate *time.Time `form:"from_date"` + ToDate *time.Time `form:"to_date"` + Search *string `form:"search"` +} + +// DSRDashboardStats for the admin dashboard +type DSRDashboardStats struct { + TotalRequests int `json:"total_requests"` + PendingRequests int `json:"pending_requests"` + OverdueRequests int `json:"overdue_requests"` + CompletedThisMonth int `json:"completed_this_month"` + AverageProcessingDays float64 `json:"average_processing_days"` + ByType map[string]int `json:"by_type"` + ByStatus map[string]int `json:"by_status"` + UpcomingDeadlines []DataSubjectRequest `json:"upcoming_deadlines"` +} + +// DSRWithDetails combines DSR with related data +type DSRWithDetails struct { + Request DataSubjectRequest `json:"request"` + StatusHistory []DSRStatusHistory `json:"status_history"` + Communications []DSRCommunication `json:"communications"` + ExceptionChecks []DSRExceptionCheck `json:"exception_checks,omitempty"` + AssigneeName *string `json:"assignee_name,omitempty"` + CreatorName *string `json:"creator_name,omitempty"` +} + +// DSRTemplateWithVersions combines template with versions +type DSRTemplateWithVersions struct { + Template DSRTemplate `json:"template"` + LatestVersion *DSRTemplateVersion `json:"latest_version,omitempty"` + Versions []DSRTemplateVersion `json:"versions,omitempty"` +} + +// CreateDSRTemplateVersionRequest for creating a template version +type CreateDSRTemplateVersionRequest struct { + TemplateID string `json:"template_id" binding:"required"` + Version string `json:"version" binding:"required"` + Language string `json:"language" binding:"required"` + Subject string `json:"subject" binding:"required"` + BodyHTML string `json:"body_html" binding:"required"` + BodyText string `json:"body_text" binding:"required"` +} + +// UpdateDSRTemplateVersionRequest for updating a template version +type UpdateDSRTemplateVersionRequest struct { + Subject *string `json:"subject"` + BodyHTML *string `json:"body_html"` + BodyText *string `json:"body_text"` + Status *string `json:"status"` +} + +// PreviewDSRTemplateRequest for previewing a template with variables +type PreviewDSRTemplateRequest struct { + Variables map[string]string `json:"variables"` +} + +// DSRTemplatePreviewResponse for template preview +type DSRTemplatePreviewResponse struct { + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` +} + +// ======================================== +// DSR Helper Methods +// ======================================== + +// Label returns German label for request type +func (rt DSRRequestType) Label() string { + switch rt { + case DSRTypeAccess: + return "Auskunftsanfrage (Art. 15)" + case DSRTypeRectification: + return "Berichtigungsanfrage (Art. 16)" + case DSRTypeErasure: + return "Löschanfrage (Art. 17)" + case DSRTypeRestriction: + return "Einschränkungsanfrage (Art. 18)" + case DSRTypePortability: + return "Datenübertragung (Art. 20)" + default: + return string(rt) + } +} + +// DeadlineDays returns the legal deadline in days for request type +func (rt DSRRequestType) DeadlineDays() int { + switch rt { + case DSRTypeAccess, DSRTypePortability: + return 30 // 1 month + case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction: + return 14 // 2 weeks (expedited per BDSG) + default: + return 30 + } +} + +// IsExpedited returns whether this request type should be processed expeditiously +func (rt DSRRequestType) IsExpedited() bool { + switch rt { + case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction: + return true + default: + return false + } +} + +// Label returns German label for status +func (s DSRStatus) Label() string { + switch s { + case DSRStatusIntake: + return "Eingang" + case DSRStatusIdentityVerification: + return "Identitätsprüfung" + case DSRStatusProcessing: + return "In Bearbeitung" + case DSRStatusCompleted: + return "Abgeschlossen" + case DSRStatusRejected: + return "Abgelehnt" + case DSRStatusCancelled: + return "Storniert" + default: + return string(s) + } +} + +// IsValidDSRRequestType checks if a string is a valid DSR request type +func IsValidDSRRequestType(reqType string) bool { + switch DSRRequestType(reqType) { + case DSRTypeAccess, DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction, DSRTypePortability: + return true + default: + return false + } +} + +// IsValidDSRStatus checks if a string is a valid DSR status +func IsValidDSRStatus(status string) bool { + switch DSRStatus(status) { + case DSRStatusIntake, DSRStatusIdentityVerification, DSRStatusProcessing, + DSRStatusCompleted, DSRStatusRejected, DSRStatusCancelled: + return true + default: + return false + } +} diff --git a/consent-service/internal/models/email_templates.go b/consent-service/internal/models/email_templates.go new file mode 100644 index 0000000..4cfad96 --- /dev/null +++ b/consent-service/internal/models/email_templates.go @@ -0,0 +1,191 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// EmailTemplateType defines the types of transactional emails +const ( + // Auth & Security + EmailTypeWelcome = "welcome" + EmailTypeEmailVerification = "email_verification" + EmailTypePasswordReset = "password_reset" + EmailTypePasswordChanged = "password_changed" + EmailType2FAEnabled = "2fa_enabled" + EmailType2FADisabled = "2fa_disabled" + EmailTypeNewDeviceLogin = "new_device_login" + EmailTypeSuspiciousActivity = "suspicious_activity" + EmailTypeAccountLocked = "account_locked" + EmailTypeAccountUnlocked = "account_unlocked" + + // Account Lifecycle + EmailTypeDeletionRequested = "deletion_requested" + EmailTypeDeletionConfirmed = "deletion_confirmed" + EmailTypeDataExportReady = "data_export_ready" + EmailTypeEmailChanged = "email_changed" + EmailTypeEmailChangeVerify = "email_change_verify" + + // Consent-related + EmailTypeNewVersionPublished = "new_version_published" + EmailTypeConsentReminder = "consent_reminder" + EmailTypeConsentDeadlineWarning = "consent_deadline_warning" + EmailTypeAccountSuspended = "account_suspended" +) + +// EmailTemplate represents a template for transactional emails (like LegalDocument) +type EmailTemplate struct { + ID uuid.UUID `json:"id" db:"id"` + Type string `json:"type" db:"type"` // One of EmailType constants + Name string `json:"name" db:"name"` // Human-readable name + Description *string `json:"description" db:"description"` + IsActive bool `json:"is_active" db:"is_active"` + SortOrder int `json:"sort_order" db:"sort_order"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EmailTemplateVersion represents a specific version of an email template (like DocumentVersion) +type EmailTemplateVersion struct { + ID uuid.UUID `json:"id" db:"id"` + TemplateID uuid.UUID `json:"template_id" db:"template_id"` + Version string `json:"version" db:"version"` // Semver: 1.0.0 + Language string `json:"language" db:"language"` // ISO 639-1: de, en + Subject string `json:"subject" db:"subject"` // Email subject line + BodyHTML string `json:"body_html" db:"body_html"` // HTML version + BodyText string `json:"body_text" db:"body_text"` // Plain text version + Summary *string `json:"summary" db:"summary"` // Change summary + Status string `json:"status" db:"status"` // draft, review, approved, published, archived + PublishedAt *time.Time `json:"published_at" db:"published_at"` + ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"` + CreatedBy *uuid.UUID `json:"created_by" db:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at" db:"approved_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EmailTemplateApproval tracks approval workflow for email templates +type EmailTemplateApproval struct { + ID uuid.UUID `json:"id" db:"id"` + VersionID uuid.UUID `json:"version_id" db:"version_id"` + ApproverID uuid.UUID `json:"approver_id" db:"approver_id"` + Action string `json:"action" db:"action"` // submitted_for_review, approved, rejected, published + Comment *string `json:"comment,omitempty" db:"comment"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// EmailSendLog tracks sent emails for audit purposes +type EmailSendLog struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` + VersionID uuid.UUID `json:"version_id" db:"version_id"` + Recipient string `json:"recipient" db:"recipient"` // Email address + Subject string `json:"subject" db:"subject"` + Status string `json:"status" db:"status"` // queued, sent, delivered, bounced, failed + ErrorMsg *string `json:"error_msg,omitempty" db:"error_msg"` + Variables *string `json:"variables,omitempty" db:"variables"` // JSON of template variables used + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + DeliveredAt *time.Time `json:"delivered_at,omitempty" db:"delivered_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// EmailTemplateSettings stores global email settings (logo, signature, etc.) +type EmailTemplateSettings struct { + ID uuid.UUID `json:"id" db:"id"` + LogoURL *string `json:"logo_url" db:"logo_url"` + LogoBase64 *string `json:"logo_base64" db:"logo_base64"` // For embedding in emails + CompanyName string `json:"company_name" db:"company_name"` + SenderName string `json:"sender_name" db:"sender_name"` + SenderEmail string `json:"sender_email" db:"sender_email"` + ReplyToEmail *string `json:"reply_to_email" db:"reply_to_email"` + FooterHTML *string `json:"footer_html" db:"footer_html"` + FooterText *string `json:"footer_text" db:"footer_text"` + PrimaryColor string `json:"primary_color" db:"primary_color"` // Hex color + SecondaryColor string `json:"secondary_color" db:"secondary_color"` // Hex color + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + UpdatedBy *uuid.UUID `json:"updated_by" db:"updated_by"` +} + +// ======================================== +// E-Mail Template DTOs +// ======================================== + +// CreateEmailTemplateRequest for creating a new email template type +type CreateEmailTemplateRequest struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + Description *string `json:"description"` +} + +// CreateEmailTemplateVersionRequest for creating a new version of an email template +type CreateEmailTemplateVersionRequest struct { + TemplateID string `json:"template_id" binding:"required"` + Version string `json:"version" binding:"required"` + Language string `json:"language" binding:"required"` + Subject string `json:"subject" binding:"required"` + BodyHTML string `json:"body_html" binding:"required"` + BodyText string `json:"body_text" binding:"required"` + Summary *string `json:"summary"` +} + +// UpdateEmailTemplateVersionRequest for updating a version +type UpdateEmailTemplateVersionRequest struct { + Subject *string `json:"subject"` + BodyHTML *string `json:"body_html"` + BodyText *string `json:"body_text"` + Summary *string `json:"summary"` + Status *string `json:"status"` +} + +// UpdateEmailTemplateSettingsRequest for updating global settings +type UpdateEmailTemplateSettingsRequest struct { + LogoURL *string `json:"logo_url"` + LogoBase64 *string `json:"logo_base64"` + CompanyName *string `json:"company_name"` + SenderName *string `json:"sender_name"` + SenderEmail *string `json:"sender_email"` + ReplyToEmail *string `json:"reply_to_email"` + FooterHTML *string `json:"footer_html"` + FooterText *string `json:"footer_text"` + PrimaryColor *string `json:"primary_color"` + SecondaryColor *string `json:"secondary_color"` +} + +// EmailTemplateWithVersion combines template info with its latest published version +type EmailTemplateWithVersion struct { + Template EmailTemplate `json:"template"` + LatestVersion *EmailTemplateVersion `json:"latest_version,omitempty"` +} + +// SendTestEmailRequest for sending a test email +type SendTestEmailRequest struct { + VersionID string `json:"version_id" binding:"required"` + Recipient string `json:"recipient" binding:"required,email"` + Variables map[string]string `json:"variables"` // Template variable overrides +} + +// EmailPreviewResponse for previewing an email +type EmailPreviewResponse struct { + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` +} + +// EmailTemplateVariables defines available variables for each template type +type EmailTemplateVariables struct { + TemplateType string `json:"template_type"` + Variables []string `json:"variables"` + Descriptions map[string]string `json:"descriptions"` +} + +// EmailStats represents statistics about email sends +type EmailStats struct { + TotalSent int `json:"total_sent"` + Delivered int `json:"delivered"` + Bounced int `json:"bounced"` + Failed int `json:"failed"` + DeliveryRate float64 `json:"delivery_rate"` + RecentSent int `json:"recent_sent"` // Last 7 days +} diff --git a/consent-service/internal/models/models.go b/consent-service/internal/models/models.go deleted file mode 100644 index 6dfbf8b..0000000 --- a/consent-service/internal/models/models.go +++ /dev/null @@ -1,1797 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// User represents a user with full authentication support -type User struct { - ID uuid.UUID `json:"id" db:"id"` - ExternalID *string `json:"external_id,omitempty" db:"external_id"` - Email string `json:"email" db:"email"` - PasswordHash *string `json:"-" db:"password_hash"` // Never exposed in JSON - Name *string `json:"name,omitempty" db:"name"` - Role string `json:"role" db:"role"` // 'user', 'admin', 'super_admin', 'data_protection_officer' - EmailVerified bool `json:"email_verified" db:"email_verified"` - EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty" db:"email_verified_at"` - AccountStatus string `json:"account_status" db:"account_status"` // 'active', 'suspended', 'locked' - LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` - FailedLoginAttempts int `json:"failed_login_attempts" db:"failed_login_attempts"` - LockedUntil *time.Time `json:"locked_until,omitempty" db:"locked_until"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// LegalDocument represents a type of legal document (e.g., Terms, Privacy Policy) -type LegalDocument struct { - ID uuid.UUID `json:"id" db:"id"` - Type string `json:"type" db:"type"` // 'terms', 'privacy', 'cookies', 'community' - Name string `json:"name" db:"name"` - Description *string `json:"description" db:"description"` - IsMandatory bool `json:"is_mandatory" db:"is_mandatory"` - IsActive bool `json:"is_active" db:"is_active"` - SortOrder int `json:"sort_order" db:"sort_order"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// DocumentVersion represents a specific version of a legal document -type DocumentVersion struct { - ID uuid.UUID `json:"id" db:"id"` - DocumentID uuid.UUID `json:"document_id" db:"document_id"` - Version string `json:"version" db:"version"` // Semver: 1.0.0, 1.1.0 - Language string `json:"language" db:"language"` // ISO 639-1: de, en - Title string `json:"title" db:"title"` - Content string `json:"content" db:"content"` // HTML or Markdown - Summary *string `json:"summary" db:"summary"` // Summary of changes - Status string `json:"status" db:"status"` // 'draft', 'review', 'approved', 'scheduled', 'published', 'archived' - PublishedAt *time.Time `json:"published_at" db:"published_at"` - ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"` - CreatedBy *uuid.UUID `json:"created_by" db:"created_by"` - ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"` - ApprovedAt *time.Time `json:"approved_at" db:"approved_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// UserConsent represents a user's consent to a document version -type UserConsent struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"` - Consented bool `json:"consented" db:"consented"` - IPAddress *string `json:"ip_address" db:"ip_address"` - UserAgent *string `json:"user_agent" db:"user_agent"` - ConsentedAt time.Time `json:"consented_at" db:"consented_at"` - WithdrawnAt *time.Time `json:"withdrawn_at" db:"withdrawn_at"` -} - -// CookieCategory represents a category of cookies -type CookieCategory struct { - ID uuid.UUID `json:"id" db:"id"` - Name string `json:"name" db:"name"` // 'necessary', 'functional', 'analytics', 'marketing' - DisplayNameDE string `json:"display_name_de" db:"display_name_de"` - DisplayNameEN *string `json:"display_name_en" db:"display_name_en"` - DescriptionDE *string `json:"description_de" db:"description_de"` - DescriptionEN *string `json:"description_en" db:"description_en"` - IsMandatory bool `json:"is_mandatory" db:"is_mandatory"` - SortOrder int `json:"sort_order" db:"sort_order"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// CookieConsent represents a user's cookie preferences -type CookieConsent struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - CategoryID uuid.UUID `json:"category_id" db:"category_id"` - Consented bool `json:"consented" db:"consented"` - ConsentedAt time.Time `json:"consented_at" db:"consented_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// AuditLog represents an audit trail entry for GDPR compliance -type AuditLog struct { - ID uuid.UUID `json:"id" db:"id"` - UserID *uuid.UUID `json:"user_id" db:"user_id"` - Action string `json:"action" db:"action"` // 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete' - EntityType *string `json:"entity_type" db:"entity_type"` // 'document', 'cookie_category' - EntityID *uuid.UUID `json:"entity_id" db:"entity_id"` - Details *string `json:"details" db:"details"` // JSON string - IPAddress *string `json:"ip_address" db:"ip_address"` - UserAgent *string `json:"user_agent" db:"user_agent"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// DataExportRequest represents a user's request to export their data -type DataExportRequest struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed' - DownloadURL *string `json:"download_url" db:"download_url"` - ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - CompletedAt *time.Time `json:"completed_at" db:"completed_at"` -} - -// DataDeletionRequest represents a user's request to delete their data -type DataDeletionRequest struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed' - Reason *string `json:"reason" db:"reason"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - ProcessedAt *time.Time `json:"processed_at" db:"processed_at"` - ProcessedBy *uuid.UUID `json:"processed_by" db:"processed_by"` -} - -// ======================================== -// DTOs (Data Transfer Objects) -// ======================================== - -// CreateConsentRequest is the request body for creating a consent -type CreateConsentRequest struct { - DocumentType string `json:"document_type" binding:"required"` - VersionID string `json:"version_id" binding:"required"` - Consented bool `json:"consented"` -} - -// CookieConsentRequest is the request body for setting cookie preferences -type CookieConsentRequest struct { - Categories []CookieCategoryConsent `json:"categories" binding:"required"` -} - -// CookieCategoryConsent represents consent for a single cookie category -type CookieCategoryConsent struct { - CategoryID string `json:"category_id" binding:"required"` - Consented bool `json:"consented"` -} - -// ConsentCheckResponse is the response for checking consent status -type ConsentCheckResponse struct { - HasConsent bool `json:"has_consent"` - CurrentVersionID *string `json:"current_version_id,omitempty"` - ConsentedVersion *string `json:"consented_version,omitempty"` - NeedsUpdate bool `json:"needs_update"` - ConsentedAt *time.Time `json:"consented_at,omitempty"` -} - -// DocumentWithVersion combines document info with its latest published version -type DocumentWithVersion struct { - Document LegalDocument `json:"document"` - LatestVersion *DocumentVersion `json:"latest_version,omitempty"` -} - -// ConsentHistory represents a user's consent history for a document -type ConsentHistory struct { - Document LegalDocument `json:"document"` - Version DocumentVersion `json:"version"` - Consent UserConsent `json:"consent"` -} - -// ConsentStats represents statistics about consents -type ConsentStats struct { - TotalUsers int `json:"total_users"` - ConsentedUsers int `json:"consented_users"` - ConsentRate float64 `json:"consent_rate"` - RecentConsents int `json:"recent_consents"` // Last 7 days - RecentWithdrawals int `json:"recent_withdrawals"` -} - -// CookieStats represents statistics about cookie consents -type CookieStats struct { - Category string `json:"category"` - TotalUsers int `json:"total_users"` - ConsentedUsers int `json:"consented_users"` - ConsentRate float64 `json:"consent_rate"` -} - -// MyDataResponse represents all data we have about a user -type MyDataResponse struct { - User User `json:"user"` - Consents []ConsentHistory `json:"consents"` - CookieConsents []CookieConsent `json:"cookie_consents"` - AuditLog []AuditLog `json:"audit_log"` - ExportedAt time.Time `json:"exported_at"` -} - -// CreateDocumentRequest is the request body for creating a document -type CreateDocumentRequest struct { - Type string `json:"type" binding:"required"` - Name string `json:"name" binding:"required"` - Description *string `json:"description"` - IsMandatory bool `json:"is_mandatory"` -} - -// CreateVersionRequest is the request body for creating a document version -type CreateVersionRequest struct { - DocumentID string `json:"document_id" binding:"required"` - Version string `json:"version" binding:"required"` - Language string `json:"language" binding:"required"` - Title string `json:"title" binding:"required"` - Content string `json:"content" binding:"required"` - Summary *string `json:"summary"` -} - -// UpdateVersionRequest is the request body for updating a version -type UpdateVersionRequest struct { - Title *string `json:"title"` - Content *string `json:"content"` - Summary *string `json:"summary"` - Status *string `json:"status"` -} - -// CreateCookieCategoryRequest is the request body for creating a cookie category -type CreateCookieCategoryRequest struct { - Name string `json:"name" binding:"required"` - DisplayNameDE string `json:"display_name_de" binding:"required"` - DisplayNameEN *string `json:"display_name_en"` - DescriptionDE *string `json:"description_de"` - DescriptionEN *string `json:"description_en"` - IsMandatory bool `json:"is_mandatory"` - SortOrder int `json:"sort_order"` -} - -// ======================================== -// Phase 1: Authentication Models -// ======================================== - -// EmailVerificationToken for email verification -type EmailVerificationToken struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Token string `json:"token" db:"token"` - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// PasswordResetToken for password reset -type PasswordResetToken struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Token string `json:"token" db:"token"` - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` - IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// UserSession for session management -type UserSession struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - TokenHash string `json:"-" db:"token_hash"` - DeviceInfo *string `json:"device_info,omitempty" db:"device_info"` - IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` - UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - LastActivityAt time.Time `json:"last_activity_at" db:"last_activity_at"` -} - -// RegisterRequest for user registration -type RegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - Name *string `json:"name"` -} - -// LoginRequest for user login -type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` -} - -// LoginResponse after successful login -type LoginResponse struct { - User User `json:"user"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` // seconds -} - -// RefreshTokenRequest for token refresh -type RefreshTokenRequest struct { - RefreshToken string `json:"refresh_token" binding:"required"` -} - -// VerifyEmailRequest for email verification -type VerifyEmailRequest struct { - Token string `json:"token" binding:"required"` -} - -// ForgotPasswordRequest for password reset request -type ForgotPasswordRequest struct { - Email string `json:"email" binding:"required,email"` -} - -// ResetPasswordRequest for password reset -type ResetPasswordRequest struct { - Token string `json:"token" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=8"` -} - -// ChangePasswordRequest for changing password -type ChangePasswordRequest struct { - CurrentPassword string `json:"current_password" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=8"` -} - -// UpdateProfileRequest for profile updates -type UpdateProfileRequest struct { - Name *string `json:"name"` -} - -// ======================================== -// Phase 3: Version Approval Models -// ======================================== - -// VersionApproval tracks the approval workflow -type VersionApproval struct { - ID uuid.UUID `json:"id" db:"id"` - VersionID uuid.UUID `json:"version_id" db:"version_id"` - ApproverID uuid.UUID `json:"approver_id" db:"approver_id"` - Action string `json:"action" db:"action"` // 'submitted_for_review', 'approved', 'rejected', 'published' - Comment *string `json:"comment,omitempty" db:"comment"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// SubmitForReviewRequest for submitting a version for review -type SubmitForReviewRequest struct { - Comment *string `json:"comment"` -} - -// ApproveVersionRequest for approving a version (DSB) -type ApproveVersionRequest struct { - Comment *string `json:"comment"` - ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601 datetime for scheduled publishing -} - -// RejectVersionRequest for rejecting a version -type RejectVersionRequest struct { - Comment string `json:"comment" binding:"required"` -} - -// VersionCompareResponse for comparing versions -type VersionCompareResponse struct { - Published *DocumentVersion `json:"published,omitempty"` - Draft *DocumentVersion `json:"draft"` - Diff *string `json:"diff,omitempty"` - Approvals []VersionApproval `json:"approvals"` -} - -// ======================================== -// Phase 4: Notification Models -// ======================================== - -// Notification represents a user notification -type Notification struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Type string `json:"type" db:"type"` // 'new_version', 'consent_reminder', 'account_warning' - Channel string `json:"channel" db:"channel"` // 'email', 'in_app', 'push' - Title string `json:"title" db:"title"` - Body string `json:"body" db:"body"` - Data *string `json:"data,omitempty" db:"data"` // JSON string - ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"` - SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// PushSubscription for Web Push notifications -type PushSubscription struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Endpoint string `json:"endpoint" db:"endpoint"` - P256dh string `json:"p256dh" db:"p256dh"` - Auth string `json:"auth" db:"auth"` - UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// NotificationPreferences for user notification settings -type NotificationPreferences struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - EmailEnabled bool `json:"email_enabled" db:"email_enabled"` - PushEnabled bool `json:"push_enabled" db:"push_enabled"` - InAppEnabled bool `json:"in_app_enabled" db:"in_app_enabled"` - ReminderFrequency string `json:"reminder_frequency" db:"reminder_frequency"` // 'daily', 'weekly', 'never' - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// SubscribePushRequest for subscribing to push notifications -type SubscribePushRequest struct { - Endpoint string `json:"endpoint" binding:"required"` - P256dh string `json:"p256dh" binding:"required"` - Auth string `json:"auth" binding:"required"` -} - -// UpdateNotificationPreferencesRequest for updating preferences -type UpdateNotificationPreferencesRequest struct { - EmailEnabled *bool `json:"email_enabled"` - PushEnabled *bool `json:"push_enabled"` - InAppEnabled *bool `json:"in_app_enabled"` - ReminderFrequency *string `json:"reminder_frequency"` -} - -// ======================================== -// Phase 6: OAuth 2.0 Authorization Code Flow -// ======================================== - -// OAuthClient represents a registered OAuth 2.0 client application -type OAuthClient struct { - ID uuid.UUID `json:"id" db:"id"` - ClientID string `json:"client_id" db:"client_id"` - ClientSecret string `json:"-" db:"client_secret"` // Never expose in JSON - Name string `json:"name" db:"name"` - Description *string `json:"description,omitempty" db:"description"` - RedirectURIs []string `json:"redirect_uris" db:"redirect_uris"` // JSON array - Scopes []string `json:"scopes" db:"scopes"` // Allowed scopes - GrantTypes []string `json:"grant_types" db:"grant_types"` // authorization_code, refresh_token - IsPublic bool `json:"is_public" db:"is_public"` // Public clients (SPAs) don't have secret - IsActive bool `json:"is_active" db:"is_active"` - CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// OAuthAuthorizationCode represents an authorization code for the OAuth flow -type OAuthAuthorizationCode struct { - ID uuid.UUID `json:"id" db:"id"` - Code string `json:"-" db:"code"` // Hashed - ClientID string `json:"client_id" db:"client_id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - RedirectURI string `json:"redirect_uri" db:"redirect_uri"` - Scopes []string `json:"scopes" db:"scopes"` - CodeChallenge *string `json:"-" db:"code_challenge"` // For PKCE - CodeChallengeMethod *string `json:"-" db:"code_challenge_method"` // S256 or plain - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// OAuthAccessToken represents an OAuth access token -type OAuthAccessToken struct { - ID uuid.UUID `json:"id" db:"id"` - TokenHash string `json:"-" db:"token_hash"` - ClientID string `json:"client_id" db:"client_id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Scopes []string `json:"scopes" db:"scopes"` - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// OAuthRefreshToken represents an OAuth refresh token -type OAuthRefreshToken struct { - ID uuid.UUID `json:"id" db:"id"` - TokenHash string `json:"-" db:"token_hash"` - AccessTokenID uuid.UUID `json:"access_token_id" db:"access_token_id"` - ClientID string `json:"client_id" db:"client_id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Scopes []string `json:"scopes" db:"scopes"` - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// OAuthAuthorizeRequest for the authorization endpoint -type OAuthAuthorizeRequest struct { - ResponseType string `form:"response_type" binding:"required"` // Must be "code" - ClientID string `form:"client_id" binding:"required"` - RedirectURI string `form:"redirect_uri" binding:"required"` - Scope string `form:"scope"` // Space-separated scopes - State string `form:"state" binding:"required"` // CSRF protection - CodeChallenge string `form:"code_challenge"` // PKCE - CodeChallengeMethod string `form:"code_challenge_method"` // S256 (recommended) or plain -} - -// OAuthTokenRequest for the token endpoint -type OAuthTokenRequest struct { - GrantType string `form:"grant_type" binding:"required"` // authorization_code or refresh_token - Code string `form:"code"` // For authorization_code grant - RedirectURI string `form:"redirect_uri"` // For authorization_code grant - ClientID string `form:"client_id" binding:"required"` - ClientSecret string `form:"client_secret"` // For confidential clients - CodeVerifier string `form:"code_verifier"` // For PKCE - RefreshToken string `form:"refresh_token"` // For refresh_token grant - Scope string `form:"scope"` // For refresh_token grant (optional) -} - -// OAuthTokenResponse for successful token requests -type OAuthTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` // Always "Bearer" - ExpiresIn int `json:"expires_in"` // Seconds until expiration - RefreshToken string `json:"refresh_token,omitempty"` - Scope string `json:"scope,omitempty"` -} - -// OAuthErrorResponse for OAuth errors (RFC 6749) -type OAuthErrorResponse struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description,omitempty"` - ErrorURI string `json:"error_uri,omitempty"` -} - -// ======================================== -// Phase 7: Two-Factor Authentication (2FA/TOTP) -// ======================================== - -// UserTOTP stores 2FA TOTP configuration for a user -type UserTOTP struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Secret string `json:"-" db:"secret"` // Encrypted TOTP secret - Verified bool `json:"verified" db:"verified"` // Has 2FA been verified/activated - RecoveryCodes []string `json:"-" db:"recovery_codes"` // Encrypted backup codes - EnabledAt *time.Time `json:"enabled_at,omitempty" db:"enabled_at"` - LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// TwoFactorChallenge represents a pending 2FA challenge during login -type TwoFactorChallenge struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - ChallengeID string `json:"challenge_id" db:"challenge_id"` // Temporary token - IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` - UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// Setup2FAResponse when initiating 2FA setup -type Setup2FAResponse struct { - Secret string `json:"secret"` // Base32 encoded secret for manual entry - QRCodeDataURL string `json:"qr_code"` // Data URL for QR code image - RecoveryCodes []string `json:"recovery_codes"` // One-time backup codes -} - -// Verify2FARequest for verifying 2FA setup or login -type Verify2FARequest struct { - Code string `json:"code" binding:"required"` // 6-digit TOTP code - ChallengeID string `json:"challenge_id,omitempty"` // For login flow -} - -// TwoFactorLoginResponse when 2FA is required during login -type TwoFactorLoginResponse struct { - RequiresTwoFactor bool `json:"requires_two_factor"` - ChallengeID string `json:"challenge_id"` // Use this to complete 2FA - Message string `json:"message"` -} - -// Complete2FALoginRequest to complete login with 2FA -type Complete2FALoginRequest struct { - ChallengeID string `json:"challenge_id" binding:"required"` - Code string `json:"code" binding:"required"` // 6-digit TOTP or recovery code -} - -// Disable2FARequest for disabling 2FA -type Disable2FARequest struct { - Password string `json:"password" binding:"required"` // Require password confirmation - Code string `json:"code" binding:"required"` // Current TOTP code -} - -// RecoveryCodeUseRequest for using a recovery code -type RecoveryCodeUseRequest struct { - ChallengeID string `json:"challenge_id" binding:"required"` - RecoveryCode string `json:"recovery_code" binding:"required"` -} - -// TwoFactorStatusResponse for checking 2FA status -type TwoFactorStatusResponse struct { - Enabled bool `json:"enabled"` - Verified bool `json:"verified"` - EnabledAt *time.Time `json:"enabled_at,omitempty"` - RecoveryCodesCount int `json:"recovery_codes_count"` -} - -// Verify2FAChallengeRequest for verifying a 2FA challenge during login -type Verify2FAChallengeRequest struct { - ChallengeID string `json:"challenge_id" binding:"required"` - Code string `json:"code,omitempty"` // 6-digit TOTP code - RecoveryCode string `json:"recovery_code,omitempty"` // Alternative: recovery code -} - -// ======================================== -// Phase 5: Consent Deadline Models -// ======================================== - -// ConsentDeadline tracks consent deadlines per user -type ConsentDeadline struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"` - DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"` - ReminderCount int `json:"reminder_count" db:"reminder_count"` - LastReminderAt *time.Time `json:"last_reminder_at,omitempty" db:"last_reminder_at"` - ConsentGivenAt *time.Time `json:"consent_given_at,omitempty" db:"consent_given_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// AccountSuspension tracks account suspensions -type AccountSuspension struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` - Reason string `json:"reason" db:"reason"` // 'consent_deadline_exceeded' - Details *string `json:"details,omitempty" db:"details"` // JSON - SuspendedAt time.Time `json:"suspended_at" db:"suspended_at"` - LiftedAt *time.Time `json:"lifted_at,omitempty" db:"lifted_at"` - LiftedReason *string `json:"lifted_reason,omitempty" db:"lifted_reason"` -} - -// PendingConsentResponse for pending consents with deadline info -type PendingConsentResponse struct { - Document LegalDocument `json:"document"` - Version DocumentVersion `json:"version"` - DeadlineAt time.Time `json:"deadline_at"` - DaysLeft int `json:"days_left"` - IsOverdue bool `json:"is_overdue"` -} - -// AccountStatusResponse for account status check -type AccountStatusResponse struct { - Status string `json:"status"` // 'active', 'suspended' - PendingConsents []PendingConsentResponse `json:"pending_consents,omitempty"` - SuspensionReason *string `json:"suspension_reason,omitempty"` - CanAccess bool `json:"can_access"` -} - -// ======================================== -// Phase 8: E-Mail Templates (Transactional) -// ======================================== - -// EmailTemplateType defines the types of transactional emails -// These are like document types but for emails -const ( - // Auth & Security - EmailTypeWelcome = "welcome" - EmailTypeEmailVerification = "email_verification" - EmailTypePasswordReset = "password_reset" - EmailTypePasswordChanged = "password_changed" - EmailType2FAEnabled = "2fa_enabled" - EmailType2FADisabled = "2fa_disabled" - EmailTypeNewDeviceLogin = "new_device_login" - EmailTypeSuspiciousActivity = "suspicious_activity" - EmailTypeAccountLocked = "account_locked" - EmailTypeAccountUnlocked = "account_unlocked" - - // Account Lifecycle - EmailTypeDeletionRequested = "deletion_requested" - EmailTypeDeletionConfirmed = "deletion_confirmed" - EmailTypeDataExportReady = "data_export_ready" - EmailTypeEmailChanged = "email_changed" - EmailTypeEmailChangeVerify = "email_change_verify" - - // Consent-related - EmailTypeNewVersionPublished = "new_version_published" - EmailTypeConsentReminder = "consent_reminder" - EmailTypeConsentDeadlineWarning = "consent_deadline_warning" - EmailTypeAccountSuspended = "account_suspended" -) - -// EmailTemplate represents a template for transactional emails (like LegalDocument) -type EmailTemplate struct { - ID uuid.UUID `json:"id" db:"id"` - Type string `json:"type" db:"type"` // One of EmailType constants - Name string `json:"name" db:"name"` // Human-readable name - Description *string `json:"description" db:"description"` - IsActive bool `json:"is_active" db:"is_active"` - SortOrder int `json:"sort_order" db:"sort_order"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// EmailTemplateVersion represents a specific version of an email template (like DocumentVersion) -type EmailTemplateVersion struct { - ID uuid.UUID `json:"id" db:"id"` - TemplateID uuid.UUID `json:"template_id" db:"template_id"` - Version string `json:"version" db:"version"` // Semver: 1.0.0 - Language string `json:"language" db:"language"` // ISO 639-1: de, en - Subject string `json:"subject" db:"subject"` // Email subject line - BodyHTML string `json:"body_html" db:"body_html"` // HTML version - BodyText string `json:"body_text" db:"body_text"` // Plain text version - Summary *string `json:"summary" db:"summary"` // Change summary - Status string `json:"status" db:"status"` // draft, review, approved, published, archived - PublishedAt *time.Time `json:"published_at" db:"published_at"` - ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"` - CreatedBy *uuid.UUID `json:"created_by" db:"created_by"` - ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"` - ApprovedAt *time.Time `json:"approved_at" db:"approved_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// EmailTemplateApproval tracks approval workflow for email templates -type EmailTemplateApproval struct { - ID uuid.UUID `json:"id" db:"id"` - VersionID uuid.UUID `json:"version_id" db:"version_id"` - ApproverID uuid.UUID `json:"approver_id" db:"approver_id"` - Action string `json:"action" db:"action"` // submitted_for_review, approved, rejected, published - Comment *string `json:"comment,omitempty" db:"comment"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// EmailSendLog tracks sent emails for audit purposes -type EmailSendLog struct { - ID uuid.UUID `json:"id" db:"id"` - UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` - VersionID uuid.UUID `json:"version_id" db:"version_id"` - Recipient string `json:"recipient" db:"recipient"` // Email address - Subject string `json:"subject" db:"subject"` - Status string `json:"status" db:"status"` // queued, sent, delivered, bounced, failed - ErrorMsg *string `json:"error_msg,omitempty" db:"error_msg"` - Variables *string `json:"variables,omitempty" db:"variables"` // JSON of template variables used - SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` - DeliveredAt *time.Time `json:"delivered_at,omitempty" db:"delivered_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// EmailTemplateSettings stores global email settings (logo, signature, etc.) -type EmailTemplateSettings struct { - ID uuid.UUID `json:"id" db:"id"` - LogoURL *string `json:"logo_url" db:"logo_url"` - LogoBase64 *string `json:"logo_base64" db:"logo_base64"` // For embedding in emails - CompanyName string `json:"company_name" db:"company_name"` - SenderName string `json:"sender_name" db:"sender_name"` - SenderEmail string `json:"sender_email" db:"sender_email"` - ReplyToEmail *string `json:"reply_to_email" db:"reply_to_email"` - FooterHTML *string `json:"footer_html" db:"footer_html"` - FooterText *string `json:"footer_text" db:"footer_text"` - PrimaryColor string `json:"primary_color" db:"primary_color"` // Hex color - SecondaryColor string `json:"secondary_color" db:"secondary_color"` // Hex color - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - UpdatedBy *uuid.UUID `json:"updated_by" db:"updated_by"` -} - -// ======================================== -// E-Mail Template DTOs -// ======================================== - -// CreateEmailTemplateRequest for creating a new email template type -type CreateEmailTemplateRequest struct { - Type string `json:"type" binding:"required"` - Name string `json:"name" binding:"required"` - Description *string `json:"description"` -} - -// CreateEmailTemplateVersionRequest for creating a new version of an email template -type CreateEmailTemplateVersionRequest struct { - TemplateID string `json:"template_id" binding:"required"` - Version string `json:"version" binding:"required"` - Language string `json:"language" binding:"required"` - Subject string `json:"subject" binding:"required"` - BodyHTML string `json:"body_html" binding:"required"` - BodyText string `json:"body_text" binding:"required"` - Summary *string `json:"summary"` -} - -// UpdateEmailTemplateVersionRequest for updating a version -type UpdateEmailTemplateVersionRequest struct { - Subject *string `json:"subject"` - BodyHTML *string `json:"body_html"` - BodyText *string `json:"body_text"` - Summary *string `json:"summary"` - Status *string `json:"status"` -} - -// UpdateEmailTemplateSettingsRequest for updating global settings -type UpdateEmailTemplateSettingsRequest struct { - LogoURL *string `json:"logo_url"` - LogoBase64 *string `json:"logo_base64"` - CompanyName *string `json:"company_name"` - SenderName *string `json:"sender_name"` - SenderEmail *string `json:"sender_email"` - ReplyToEmail *string `json:"reply_to_email"` - FooterHTML *string `json:"footer_html"` - FooterText *string `json:"footer_text"` - PrimaryColor *string `json:"primary_color"` - SecondaryColor *string `json:"secondary_color"` -} - -// EmailTemplateWithVersion combines template info with its latest published version -type EmailTemplateWithVersion struct { - Template EmailTemplate `json:"template"` - LatestVersion *EmailTemplateVersion `json:"latest_version,omitempty"` -} - -// SendTestEmailRequest for sending a test email -type SendTestEmailRequest struct { - VersionID string `json:"version_id" binding:"required"` - Recipient string `json:"recipient" binding:"required,email"` - Variables map[string]string `json:"variables"` // Template variable overrides -} - -// EmailPreviewResponse for previewing an email -type EmailPreviewResponse struct { - Subject string `json:"subject"` - BodyHTML string `json:"body_html"` - BodyText string `json:"body_text"` -} - -// EmailTemplateVariables defines available variables for each template type -type EmailTemplateVariables struct { - TemplateType string `json:"template_type"` - Variables []string `json:"variables"` - Descriptions map[string]string `json:"descriptions"` -} - -// EmailStats represents statistics about email sends -type EmailStats struct { - TotalSent int `json:"total_sent"` - Delivered int `json:"delivered"` - Bounced int `json:"bounced"` - Failed int `json:"failed"` - DeliveryRate float64 `json:"delivery_rate"` - RecentSent int `json:"recent_sent"` // Last 7 days -} - -// ======================================== -// Phase 9: Schulverwaltung / School Management -// Matrix-basierte Kommunikation für Schulen -// ======================================== - -// SchoolRole defines roles within the school system -const ( - SchoolRoleTeacher = "teacher" - SchoolRoleClassTeacher = "class_teacher" - SchoolRoleParent = "parent" - SchoolRoleParentRep = "parent_representative" - SchoolRoleStudent = "student" - SchoolRoleAdmin = "school_admin" - SchoolRolePrincipal = "principal" - SchoolRoleSecretary = "secretary" -) - -// AttendanceStatus defines the status of student attendance -const ( - AttendancePresent = "present" - AttendanceAbsent = "absent" - AttendanceAbsentExcused = "excused" - AttendanceAbsentUnexcused = "unexcused" - AttendanceLate = "late" - AttendanceLateExcused = "late_excused" - AttendancePending = "pending_confirmation" -) - -// School represents a school/educational institution -type School struct { - ID uuid.UUID `json:"id" db:"id"` - Name string `json:"name" db:"name"` - ShortName *string `json:"short_name,omitempty" db:"short_name"` - Type string `json:"type" db:"type"` // 'grundschule', 'hauptschule', 'realschule', 'gymnasium', 'gesamtschule', 'berufsschule' - Address *string `json:"address,omitempty" db:"address"` - City *string `json:"city,omitempty" db:"city"` - PostalCode *string `json:"postal_code,omitempty" db:"postal_code"` - State *string `json:"state,omitempty" db:"state"` // Bundesland - Country string `json:"country" db:"country"` // Default: DE - Phone *string `json:"phone,omitempty" db:"phone"` - Email *string `json:"email,omitempty" db:"email"` - Website *string `json:"website,omitempty" db:"website"` - MatrixServerName *string `json:"matrix_server_name,omitempty" db:"matrix_server_name"` // Optional: eigener Matrix-Server - LogoURL *string `json:"logo_url,omitempty" db:"logo_url"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// SchoolYear represents an academic year -type SchoolYear struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - Name string `json:"name" db:"name"` // e.g., "2024/2025" - StartDate time.Time `json:"start_date" db:"start_date"` - EndDate time.Time `json:"end_date" db:"end_date"` - IsCurrent bool `json:"is_current" db:"is_current"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// Class represents a school class -type Class struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` - Name string `json:"name" db:"name"` // e.g., "5a", "10b" - Grade int `json:"grade" db:"grade"` // Klassenstufe: 1-13 - Section *string `json:"section,omitempty" db:"section"` // e.g., "a", "b", "c" - Room *string `json:"room,omitempty" db:"room"` // Klassenzimmer - MatrixInfoRoom *string `json:"matrix_info_room,omitempty" db:"matrix_info_room"` // Broadcast-Raum - MatrixRepRoom *string `json:"matrix_rep_room,omitempty" db:"matrix_rep_room"` // Elternvertreter-Raum - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// Subject represents a school subject -type Subject struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - Name string `json:"name" db:"name"` // e.g., "Mathematik", "Deutsch" - ShortName string `json:"short_name" db:"short_name"` // e.g., "Ma", "De" - Color *string `json:"color,omitempty" db:"color"` // Hex color for display - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// Student represents a student -type Student struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - ClassID uuid.UUID `json:"class_id" db:"class_id"` - UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` // Optional: linked account - StudentNumber *string `json:"student_number,omitempty" db:"student_number"` // Internal ID - FirstName string `json:"first_name" db:"first_name"` - LastName string `json:"last_name" db:"last_name"` - DateOfBirth *time.Time `json:"date_of_birth,omitempty" db:"date_of_birth"` - Gender *string `json:"gender,omitempty" db:"gender"` // 'm', 'f', 'd' - MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` - MatrixDMRoom *string `json:"matrix_dm_room,omitempty" db:"matrix_dm_room"` // Kind-Dialograum - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// Teacher represents a teacher -type Teacher struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` // Linked user account - TeacherCode *string `json:"teacher_code,omitempty" db:"teacher_code"` // e.g., "MÜL" for Müller - Title *string `json:"title,omitempty" db:"title"` // e.g., "Dr.", "StR" - FirstName string `json:"first_name" db:"first_name"` - LastName string `json:"last_name" db:"last_name"` - MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ClassTeacher assigns teachers to classes (Klassenlehrer) -type ClassTeacher struct { - ID uuid.UUID `json:"id" db:"id"` - ClassID uuid.UUID `json:"class_id" db:"class_id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - IsPrimary bool `json:"is_primary" db:"is_primary"` // Hauptklassenlehrer vs. Stellvertreter - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// TeacherSubject assigns subjects to teachers -type TeacherSubject struct { - ID uuid.UUID `json:"id" db:"id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// Parent represents a parent/guardian -type Parent struct { - ID uuid.UUID `json:"id" db:"id"` - UserID uuid.UUID `json:"user_id" db:"user_id"` // Linked user account - MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` - FirstName string `json:"first_name" db:"first_name"` - LastName string `json:"last_name" db:"last_name"` - Phone *string `json:"phone,omitempty" db:"phone"` - EmergencyContact bool `json:"emergency_contact" db:"emergency_contact"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// StudentParent links students to their parents -type StudentParent struct { - ID uuid.UUID `json:"id" db:"id"` - StudentID uuid.UUID `json:"student_id" db:"student_id"` - ParentID uuid.UUID `json:"parent_id" db:"parent_id"` - Relationship string `json:"relationship" db:"relationship"` // 'mother', 'father', 'guardian', 'other' - IsPrimary bool `json:"is_primary" db:"is_primary"` // Hauptansprechpartner - HasCustody bool `json:"has_custody" db:"has_custody"` // Sorgeberechtigt - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ParentRepresentative assigns parent representatives to classes -type ParentRepresentative struct { - ID uuid.UUID `json:"id" db:"id"` - ClassID uuid.UUID `json:"class_id" db:"class_id"` - ParentID uuid.UUID `json:"parent_id" db:"parent_id"` - Role string `json:"role" db:"role"` // 'first_rep', 'second_rep', 'substitute' - ElectedAt time.Time `json:"elected_at" db:"elected_at"` - ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ======================================== -// Stundenplan / Timetable -// ======================================== - -// TimetableSlot represents a time slot in the timetable -type TimetableSlot struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - SlotNumber int `json:"slot_number" db:"slot_number"` // 1, 2, 3... (Stunde) - StartTime string `json:"start_time" db:"start_time"` // "08:00" - EndTime string `json:"end_time" db:"end_time"` // "08:45" - IsBreak bool `json:"is_break" db:"is_break"` // Pause - Name *string `json:"name,omitempty" db:"name"` // e.g., "1. Stunde", "Große Pause" -} - -// TimetableEntry represents a single lesson in the timetable -type TimetableEntry struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` - ClassID uuid.UUID `json:"class_id" db:"class_id"` - SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - SlotID uuid.UUID `json:"slot_id" db:"slot_id"` - DayOfWeek int `json:"day_of_week" db:"day_of_week"` // 1=Monday, 5=Friday - Room *string `json:"room,omitempty" db:"room"` - ValidFrom time.Time `json:"valid_from" db:"valid_from"` - ValidUntil *time.Time `json:"valid_until,omitempty" db:"valid_until"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// TimetableSubstitution represents a substitution/replacement lesson -type TimetableSubstitution struct { - ID uuid.UUID `json:"id" db:"id"` - OriginalEntryID uuid.UUID `json:"original_entry_id" db:"original_entry_id"` - Date time.Time `json:"date" db:"date"` - SubstituteTeacherID *uuid.UUID `json:"substitute_teacher_id,omitempty" db:"substitute_teacher_id"` - SubstituteSubjectID *uuid.UUID `json:"substitute_subject_id,omitempty" db:"substitute_subject_id"` - Room *string `json:"room,omitempty" db:"room"` - Type string `json:"type" db:"type"` // 'substitution', 'cancelled', 'room_change', 'supervision' - Note *string `json:"note,omitempty" db:"note"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - CreatedBy uuid.UUID `json:"created_by" db:"created_by"` -} - -// ======================================== -// Abwesenheit / Attendance -// ======================================== - -// AttendanceRecord represents a student's attendance for a specific lesson -type AttendanceRecord struct { - ID uuid.UUID `json:"id" db:"id"` - StudentID uuid.UUID `json:"student_id" db:"student_id"` - TimetableEntryID *uuid.UUID `json:"timetable_entry_id,omitempty" db:"timetable_entry_id"` - Date time.Time `json:"date" db:"date"` - SlotID uuid.UUID `json:"slot_id" db:"slot_id"` - Status string `json:"status" db:"status"` // AttendanceStatus constants - RecordedBy uuid.UUID `json:"recorded_by" db:"recorded_by"` // Teacher who recorded - Note *string `json:"note,omitempty" db:"note"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// AbsenceReport represents a full absence report (one or more days) -type AbsenceReport struct { - ID uuid.UUID `json:"id" db:"id"` - StudentID uuid.UUID `json:"student_id" db:"student_id"` - StartDate time.Time `json:"start_date" db:"start_date"` - EndDate time.Time `json:"end_date" db:"end_date"` - Reason *string `json:"reason,omitempty" db:"reason"` - ReasonCategory string `json:"reason_category" db:"reason_category"` // 'illness', 'family', 'appointment', 'other' - Status string `json:"status" db:"status"` // 'reported', 'confirmed', 'excused', 'unexcused' - ReportedBy uuid.UUID `json:"reported_by" db:"reported_by"` // Parent or student - ReportedAt time.Time `json:"reported_at" db:"reported_at"` - ConfirmedBy *uuid.UUID `json:"confirmed_by,omitempty" db:"confirmed_by"` // Teacher - ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"` - MedicalCertificate bool `json:"medical_certificate" db:"medical_certificate"` // Attestpflicht - CertificateUploaded bool `json:"certificate_uploaded" db:"certificate_uploaded"` - MatrixNotificationSent bool `json:"matrix_notification_sent" db:"matrix_notification_sent"` - EmailNotificationSent bool `json:"email_notification_sent" db:"email_notification_sent"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// AbsenceNotification tracks notifications sent to parents about absences -type AbsenceNotification struct { - ID uuid.UUID `json:"id" db:"id"` - AttendanceRecordID uuid.UUID `json:"attendance_record_id" db:"attendance_record_id"` - ParentID uuid.UUID `json:"parent_id" db:"parent_id"` - Channel string `json:"channel" db:"channel"` // 'matrix', 'email', 'push' - MessageContent string `json:"message_content" db:"message_content"` - SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` - ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"` - ResponseReceived bool `json:"response_received" db:"response_received"` - ResponseContent *string `json:"response_content,omitempty" db:"response_content"` - ResponseAt *time.Time `json:"response_at,omitempty" db:"response_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ======================================== -// Notenspiegel / Grades -// ======================================== - -// GradeType defines the type of grade -const ( - GradeTypeExam = "exam" // Klassenarbeit/Klausur - GradeTypeTest = "test" // Test/Kurzarbeit - GradeTypeOral = "oral" // Mündlich - GradeTypeHomework = "homework" // Hausaufgabe - GradeTypeProject = "project" // Projekt - GradeTypeParticipation = "participation" // Mitarbeit - GradeTypeSemester = "semester" // Halbjahres-/Semesternote - GradeTypeFinal = "final" // Endnote/Zeugnisnote -) - -// GradeScale represents the grading scale used -type GradeScale struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - Name string `json:"name" db:"name"` // e.g., "1-6", "Punkte 0-15" - MinValue float64 `json:"min_value" db:"min_value"` // e.g., 1 or 0 - MaxValue float64 `json:"max_value" db:"max_value"` // e.g., 6 or 15 - PassingValue float64 `json:"passing_value" db:"passing_value"` // e.g., 4 or 5 - IsAscending bool `json:"is_ascending" db:"is_ascending"` // true: higher=better (Punkte), false: lower=better (Noten) - IsDefault bool `json:"is_default" db:"is_default"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// Grade represents a single grade for a student -type Grade struct { - ID uuid.UUID `json:"id" db:"id"` - StudentID uuid.UUID `json:"student_id" db:"student_id"` - SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` - GradeScaleID uuid.UUID `json:"grade_scale_id" db:"grade_scale_id"` - Type string `json:"type" db:"type"` // GradeType constants - Value float64 `json:"value" db:"value"` - Weight float64 `json:"weight" db:"weight"` // Gewichtung: 1.0, 2.0, 0.5 - Date time.Time `json:"date" db:"date"` - Title *string `json:"title,omitempty" db:"title"` // e.g., "1. Klassenarbeit" - Description *string `json:"description,omitempty" db:"description"` - IsVisible bool `json:"is_visible" db:"is_visible"` // Für Eltern/Schüler sichtbar - Semester int `json:"semester" db:"semester"` // 1 or 2 - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// GradeComment represents a teacher comment on a student's grade -type GradeComment struct { - ID uuid.UUID `json:"id" db:"id"` - GradeID uuid.UUID `json:"grade_id" db:"grade_id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - Comment string `json:"comment" db:"comment"` - IsPrivate bool `json:"is_private" db:"is_private"` // Only visible to teachers - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ======================================== -// Klassenbuch / Class Diary -// ======================================== - -// ClassDiaryEntry represents an entry in the digital class diary -type ClassDiaryEntry struct { - ID uuid.UUID `json:"id" db:"id"` - ClassID uuid.UUID `json:"class_id" db:"class_id"` - Date time.Time `json:"date" db:"date"` - SlotID uuid.UUID `json:"slot_id" db:"slot_id"` - SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - Topic *string `json:"topic,omitempty" db:"topic"` // Unterrichtsthema - Homework *string `json:"homework,omitempty" db:"homework"` // Hausaufgabe - HomeworkDueDate *time.Time `json:"homework_due_date,omitempty" db:"homework_due_date"` - Materials *string `json:"materials,omitempty" db:"materials"` // Benötigte Materialien - Notes *string `json:"notes,omitempty" db:"notes"` // Besondere Vorkommnisse - IsCancelled bool `json:"is_cancelled" db:"is_cancelled"` - CancellationReason *string `json:"cancellation_reason,omitempty" db:"cancellation_reason"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ======================================== -// Elterngespräche / Parent Meetings -// ======================================== - -// ParentMeetingSlot represents available time slots for parent meetings -type ParentMeetingSlot struct { - ID uuid.UUID `json:"id" db:"id"` - TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` - Date time.Time `json:"date" db:"date"` - StartTime string `json:"start_time" db:"start_time"` // "14:00" - EndTime string `json:"end_time" db:"end_time"` // "14:15" - Location *string `json:"location,omitempty" db:"location"` // Room or "Online" - IsOnline bool `json:"is_online" db:"is_online"` - MeetingLink *string `json:"meeting_link,omitempty" db:"meeting_link"` - IsBooked bool `json:"is_booked" db:"is_booked"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ParentMeeting represents a booked parent-teacher meeting -type ParentMeeting struct { - ID uuid.UUID `json:"id" db:"id"` - SlotID uuid.UUID `json:"slot_id" db:"slot_id"` - ParentID uuid.UUID `json:"parent_id" db:"parent_id"` - StudentID uuid.UUID `json:"student_id" db:"student_id"` - Topic *string `json:"topic,omitempty" db:"topic"` - Notes *string `json:"notes,omitempty" db:"notes"` // Teacher notes (private) - Status string `json:"status" db:"status"` // 'scheduled', 'completed', 'cancelled', 'no_show' - CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at"` - CancelledBy *uuid.UUID `json:"cancelled_by,omitempty" db:"cancelled_by"` - CancelReason *string `json:"cancel_reason,omitempty" db:"cancel_reason"` - CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ======================================== -// Matrix / Communication Integration -// ======================================== - -// MatrixRoom tracks Matrix rooms created for school communication -type MatrixRoom struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - MatrixRoomID string `json:"matrix_room_id" db:"matrix_room_id"` // e.g., "!abc123:breakpilot.local" - Type string `json:"type" db:"type"` // 'class_info', 'class_rep', 'student_dm', 'teacher_dm', 'announcement' - ClassID *uuid.UUID `json:"class_id,omitempty" db:"class_id"` - StudentID *uuid.UUID `json:"student_id,omitempty" db:"student_id"` - Name string `json:"name" db:"name"` - IsEncrypted bool `json:"is_encrypted" db:"is_encrypted"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// MatrixRoomMember tracks membership in Matrix rooms -type MatrixRoomMember struct { - ID uuid.UUID `json:"id" db:"id"` - MatrixRoomID uuid.UUID `json:"matrix_room_id" db:"matrix_room_id"` // FK to MatrixRoom - MatrixUserID string `json:"matrix_user_id" db:"matrix_user_id"` // e.g., "@user:breakpilot.local" - UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` // FK to User (if known) - PowerLevel int `json:"power_level" db:"power_level"` // Matrix power level (0, 50, 100) - CanWrite bool `json:"can_write" db:"can_write"` - JoinedAt time.Time `json:"joined_at" db:"joined_at"` - LeftAt *time.Time `json:"left_at,omitempty" db:"left_at"` -} - -// ParentOnboardingToken for QR-code based parent onboarding -type ParentOnboardingToken struct { - ID uuid.UUID `json:"id" db:"id"` - SchoolID uuid.UUID `json:"school_id" db:"school_id"` - ClassID uuid.UUID `json:"class_id" db:"class_id"` - StudentID uuid.UUID `json:"student_id" db:"student_id"` - Token string `json:"token" db:"token"` // Unique token for QR code - Role string `json:"role" db:"role"` // 'parent' or 'parent_representative' - ExpiresAt time.Time `json:"expires_at" db:"expires_at"` - UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` - UsedByUserID *uuid.UUID `json:"used_by_user_id,omitempty" db:"used_by_user_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - CreatedBy uuid.UUID `json:"created_by" db:"created_by"` // Teacher who created -} - -// ======================================== -// Schulverwaltung DTOs -// ======================================== - -// CreateSchoolRequest for creating a new school -type CreateSchoolRequest struct { - Name string `json:"name" binding:"required"` - ShortName *string `json:"short_name"` - Type string `json:"type" binding:"required"` - Address *string `json:"address"` - City *string `json:"city"` - PostalCode *string `json:"postal_code"` - State *string `json:"state"` - Phone *string `json:"phone"` - Email *string `json:"email"` - Website *string `json:"website"` -} - -// CreateClassRequest for creating a new class -type CreateClassRequest struct { - SchoolYearID string `json:"school_year_id" binding:"required"` - Name string `json:"name" binding:"required"` - Grade int `json:"grade" binding:"required"` - Section *string `json:"section"` - Room *string `json:"room"` -} - -// CreateStudentRequest for creating a new student -type CreateStudentRequest struct { - ClassID string `json:"class_id" binding:"required"` - StudentNumber *string `json:"student_number"` - FirstName string `json:"first_name" binding:"required"` - LastName string `json:"last_name" binding:"required"` - DateOfBirth *string `json:"date_of_birth"` // ISO 8601 - Gender *string `json:"gender"` -} - -// RecordAttendanceRequest for recording attendance -type RecordAttendanceRequest struct { - StudentID string `json:"student_id" binding:"required"` - Date string `json:"date" binding:"required"` // ISO 8601 - SlotID string `json:"slot_id" binding:"required"` - Status string `json:"status" binding:"required"` // AttendanceStatus - Note *string `json:"note"` -} - -// ReportAbsenceRequest for parents reporting absence -type ReportAbsenceRequest struct { - StudentID string `json:"student_id" binding:"required"` - StartDate string `json:"start_date" binding:"required"` // ISO 8601 - EndDate string `json:"end_date" binding:"required"` // ISO 8601 - Reason *string `json:"reason"` - ReasonCategory string `json:"reason_category" binding:"required"` -} - -// CreateGradeRequest for creating a grade -type CreateGradeRequest struct { - StudentID string `json:"student_id" binding:"required"` - SubjectID string `json:"subject_id" binding:"required"` - SchoolYearID string `json:"school_year_id" binding:"required"` - Type string `json:"type" binding:"required"` // GradeType - Value float64 `json:"value" binding:"required"` - Weight float64 `json:"weight"` - Date string `json:"date" binding:"required"` // ISO 8601 - Title *string `json:"title"` - Description *string `json:"description"` - Semester int `json:"semester" binding:"required"` -} - -// StudentGradeOverview provides a summary of all grades for a student in a subject -type StudentGradeOverview struct { - Student Student `json:"student"` - Subject Subject `json:"subject"` - Grades []Grade `json:"grades"` - Average float64 `json:"average"` - OralAverage float64 `json:"oral_average"` - ExamAverage float64 `json:"exam_average"` - Semester int `json:"semester"` -} - -// ClassAttendanceOverview provides attendance summary for a class -type ClassAttendanceOverview struct { - Class Class `json:"class"` - Date time.Time `json:"date"` - TotalStudents int `json:"total_students"` - PresentCount int `json:"present_count"` - AbsentCount int `json:"absent_count"` - LateCount int `json:"late_count"` - Records []AttendanceRecord `json:"records"` -} - -// ParentDashboard provides a parent's view of their children's data -type ParentDashboard struct { - Children []StudentOverview `json:"children"` - UnreadMessages int `json:"unread_messages"` - UpcomingMeetings []ParentMeeting `json:"upcoming_meetings"` - RecentGrades []Grade `json:"recent_grades"` - PendingActions []string `json:"pending_actions"` // e.g., "Entschuldigung ausstehend" -} - -// StudentOverview provides summary info about a student -type StudentOverview struct { - Student Student `json:"student"` - Class Class `json:"class"` - ClassTeacher *Teacher `json:"class_teacher,omitempty"` - AttendanceRate float64 `json:"attendance_rate"` // Percentage - UnexcusedAbsences int `json:"unexcused_absences"` - GradeAverage float64 `json:"grade_average"` -} - -// TimetableView provides a formatted timetable for display -type TimetableView struct { - Class Class `json:"class"` - Week string `json:"week"` // ISO week: "2025-W01" - Days []TimetableDayView `json:"days"` -} - -// TimetableDayView represents a single day in the timetable -type TimetableDayView struct { - Date time.Time `json:"date"` - DayName string `json:"day_name"` // "Montag" - Lessons []TimetableLessonView `json:"lessons"` -} - -// TimetableLessonView represents a single lesson in the timetable view -type TimetableLessonView struct { - Slot TimetableSlot `json:"slot"` - Subject *Subject `json:"subject,omitempty"` - Teacher *Teacher `json:"teacher,omitempty"` - Room *string `json:"room,omitempty"` - IsSubstitution bool `json:"is_substitution"` - IsCancelled bool `json:"is_cancelled"` - Note *string `json:"note,omitempty"` -} - -// ======================================== -// Phase 10: DSGVO Betroffenenanfragen (DSR) -// Data Subject Request Management -// Art. 15, 16, 17, 18, 20 DSGVO -// ======================================== - -// DSRRequestType defines the GDPR article for the request -type DSRRequestType string - -const ( - DSRTypeAccess DSRRequestType = "access" // Art. 15 - Auskunftsrecht - DSRTypeRectification DSRRequestType = "rectification" // Art. 16 - Berichtigungsrecht - DSRTypeErasure DSRRequestType = "erasure" // Art. 17 - Löschungsrecht - DSRTypeRestriction DSRRequestType = "restriction" // Art. 18 - Einschränkungsrecht - DSRTypePortability DSRRequestType = "portability" // Art. 20 - Datenübertragbarkeit -) - -// DSRStatus defines the workflow state of a DSR -type DSRStatus string - -const ( - DSRStatusIntake DSRStatus = "intake" // Eingegangen - DSRStatusIdentityVerification DSRStatus = "identity_verification" // Identitätsprüfung - DSRStatusProcessing DSRStatus = "processing" // In Bearbeitung - DSRStatusCompleted DSRStatus = "completed" // Abgeschlossen - DSRStatusRejected DSRStatus = "rejected" // Abgelehnt - DSRStatusCancelled DSRStatus = "cancelled" // Storniert -) - -// DSRPriority defines the priority level of a DSR -type DSRPriority string - -const ( - DSRPriorityNormal DSRPriority = "normal" - DSRPriorityExpedited DSRPriority = "expedited" // Art. 16, 17, 18 - beschleunigt - DSRPriorityUrgent DSRPriority = "urgent" -) - -// DSRSource defines where the request came from -type DSRSource string - -const ( - DSRSourceAPI DSRSource = "api" // Über API/Self-Service - DSRSourceAdminPanel DSRSource = "admin_panel" // Manuell im Admin - DSRSourceEmail DSRSource = "email" // Per E-Mail - DSRSourcePostal DSRSource = "postal" // Per Post -) - -// DataSubjectRequest represents a GDPR data subject request -type DataSubjectRequest struct { - ID uuid.UUID `json:"id" db:"id"` - UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` - RequestNumber string `json:"request_number" db:"request_number"` - RequestType DSRRequestType `json:"request_type" db:"request_type"` - Status DSRStatus `json:"status" db:"status"` - Priority DSRPriority `json:"priority" db:"priority"` - Source DSRSource `json:"source" db:"source"` - RequesterEmail string `json:"requester_email" db:"requester_email"` - RequesterName *string `json:"requester_name,omitempty" db:"requester_name"` - RequesterPhone *string `json:"requester_phone,omitempty" db:"requester_phone"` - IdentityVerified bool `json:"identity_verified" db:"identity_verified"` - IdentityVerifiedAt *time.Time `json:"identity_verified_at,omitempty" db:"identity_verified_at"` - IdentityVerifiedBy *uuid.UUID `json:"identity_verified_by,omitempty" db:"identity_verified_by"` - IdentityVerificationMethod *string `json:"identity_verification_method,omitempty" db:"identity_verification_method"` - RequestDetails map[string]interface{} `json:"request_details" db:"request_details"` - DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"` - LegalDeadlineDays int `json:"legal_deadline_days" db:"legal_deadline_days"` - ExtendedDeadlineAt *time.Time `json:"extended_deadline_at,omitempty" db:"extended_deadline_at"` - ExtensionReason *string `json:"extension_reason,omitempty" db:"extension_reason"` - AssignedTo *uuid.UUID `json:"assigned_to,omitempty" db:"assigned_to"` - ProcessingNotes *string `json:"processing_notes,omitempty" db:"processing_notes"` - CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` - CompletedBy *uuid.UUID `json:"completed_by,omitempty" db:"completed_by"` - ResultSummary *string `json:"result_summary,omitempty" db:"result_summary"` - ResultData map[string]interface{} `json:"result_data,omitempty" db:"result_data"` - RejectedAt *time.Time `json:"rejected_at,omitempty" db:"rejected_at"` - RejectedBy *uuid.UUID `json:"rejected_by,omitempty" db:"rejected_by"` - RejectionReason *string `json:"rejection_reason,omitempty" db:"rejection_reason"` - RejectionLegalBasis *string `json:"rejection_legal_basis,omitempty" db:"rejection_legal_basis"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` -} - -// DSRStatusHistory tracks status changes for audit trail -type DSRStatusHistory struct { - ID uuid.UUID `json:"id" db:"id"` - RequestID uuid.UUID `json:"request_id" db:"request_id"` - FromStatus *DSRStatus `json:"from_status,omitempty" db:"from_status"` - ToStatus DSRStatus `json:"to_status" db:"to_status"` - ChangedBy *uuid.UUID `json:"changed_by,omitempty" db:"changed_by"` - Comment *string `json:"comment,omitempty" db:"comment"` - Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// DSRCommunication tracks all communications related to a DSR -type DSRCommunication struct { - ID uuid.UUID `json:"id" db:"id"` - RequestID uuid.UUID `json:"request_id" db:"request_id"` - Direction string `json:"direction" db:"direction"` // 'outbound', 'inbound' - Channel string `json:"channel" db:"channel"` // 'email', 'in_app', 'postal' - CommunicationType string `json:"communication_type" db:"communication_type"` // Template type used - TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty" db:"template_version_id"` - Subject *string `json:"subject,omitempty" db:"subject"` - BodyHTML *string `json:"body_html,omitempty" db:"body_html"` - BodyText *string `json:"body_text,omitempty" db:"body_text"` - RecipientEmail *string `json:"recipient_email,omitempty" db:"recipient_email"` - SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` - ErrorMessage *string `json:"error_message,omitempty" db:"error_message"` - Attachments []map[string]interface{} `json:"attachments,omitempty" db:"attachments"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` -} - -// DSRTemplate represents a template type for DSR communications -type DSRTemplate struct { - ID uuid.UUID `json:"id" db:"id"` - TemplateType string `json:"template_type" db:"template_type"` - Name string `json:"name" db:"name"` - Description *string `json:"description,omitempty" db:"description"` - RequestTypes []string `json:"request_types" db:"request_types"` // Which DSR types use this template - IsActive bool `json:"is_active" db:"is_active"` - SortOrder int `json:"sort_order" db:"sort_order"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// DSRTemplateVersion represents a versioned template for DSR communications -type DSRTemplateVersion struct { - ID uuid.UUID `json:"id" db:"id"` - TemplateID uuid.UUID `json:"template_id" db:"template_id"` - Version string `json:"version" db:"version"` - Language string `json:"language" db:"language"` - Subject string `json:"subject" db:"subject"` - BodyHTML string `json:"body_html" db:"body_html"` - BodyText string `json:"body_text" db:"body_text"` - Status string `json:"status" db:"status"` // draft, review, approved, published, archived - PublishedAt *time.Time `json:"published_at,omitempty" db:"published_at"` - CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` - ApprovedBy *uuid.UUID `json:"approved_by,omitempty" db:"approved_by"` - ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// DSRExceptionCheck tracks Art. 17(3) exception evaluations for erasure requests -type DSRExceptionCheck struct { - ID uuid.UUID `json:"id" db:"id"` - RequestID uuid.UUID `json:"request_id" db:"request_id"` - ExceptionType string `json:"exception_type" db:"exception_type"` // Type of exception (Art. 17(3) a-e) - Description string `json:"description" db:"description"` - Applies *bool `json:"applies,omitempty" db:"applies"` // nil = not checked, true/false = result - CheckedBy *uuid.UUID `json:"checked_by,omitempty" db:"checked_by"` - CheckedAt *time.Time `json:"checked_at,omitempty" db:"checked_at"` - Notes *string `json:"notes,omitempty" db:"notes"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// Art. 17(3) Exception Types -const ( - DSRExceptionFreedomExpression = "freedom_expression" // Art. 17(3)(a) - DSRExceptionLegalObligation = "legal_obligation" // Art. 17(3)(b) - DSRExceptionPublicInterest = "public_interest" // Art. 17(3)(c) - DSRExceptionPublicHealth = "public_health" // Art. 17(3)(c) - DSRExceptionArchiving = "archiving" // Art. 17(3)(d) - DSRExceptionLegalClaims = "legal_claims" // Art. 17(3)(e) -) - -// ======================================== -// DSR DTOs (Data Transfer Objects) -// ======================================== - -// CreateDSRRequest for creating a new data subject request -type CreateDSRRequest struct { - RequestType string `json:"request_type" binding:"required"` - RequesterEmail string `json:"requester_email" binding:"required,email"` - RequesterName *string `json:"requester_name"` - RequesterPhone *string `json:"requester_phone"` - Source string `json:"source"` - RequestDetails map[string]interface{} `json:"request_details"` - Priority string `json:"priority"` -} - -// UpdateDSRRequest for updating a DSR -type UpdateDSRRequest struct { - Status *string `json:"status"` - AssignedTo *string `json:"assigned_to"` // UUID string - ProcessingNotes *string `json:"processing_notes"` - ExtendDeadline *bool `json:"extend_deadline"` - ExtensionReason *string `json:"extension_reason"` - RequestDetails map[string]interface{} `json:"request_details"` - Priority *string `json:"priority"` -} - -// VerifyDSRIdentityRequest for verifying identity of requester -type VerifyDSRIdentityRequest struct { - Method string `json:"method" binding:"required"` // email_confirmation, id_document, in_person - Comment *string `json:"comment"` -} - -// CompleteDSRRequest for completing a DSR -type CompleteDSRRequest struct { - ResultSummary string `json:"result_summary" binding:"required"` - ResultData map[string]interface{} `json:"result_data"` -} - -// RejectDSRRequest for rejecting a DSR -type RejectDSRRequest struct { - Reason string `json:"reason" binding:"required"` - LegalBasis string `json:"legal_basis" binding:"required"` // Art. 12(5), Art. 17(3)(a-e), etc. -} - -// ExtendDSRDeadlineRequest for extending a DSR deadline -type ExtendDSRDeadlineRequest struct { - Reason string `json:"reason" binding:"required"` - Days int `json:"days"` // Optional: custom extension days -} - -// AssignDSRRequest for assigning a DSR to a handler -type AssignDSRRequest struct { - AssigneeID string `json:"assignee_id" binding:"required"` - Comment *string `json:"comment"` -} - -// SendDSRCommunicationRequest for sending a communication -type SendDSRCommunicationRequest struct { - CommunicationType string `json:"communication_type" binding:"required"` - TemplateVersionID *string `json:"template_version_id"` - CustomSubject *string `json:"custom_subject"` - CustomBody *string `json:"custom_body"` - Variables map[string]string `json:"variables"` -} - -// UpdateDSRExceptionCheckRequest for updating an exception check -type UpdateDSRExceptionCheckRequest struct { - Applies bool `json:"applies"` - Notes *string `json:"notes"` -} - -// DSRListFilters for filtering DSR list -type DSRListFilters struct { - Status *string `form:"status"` - RequestType *string `form:"request_type"` - AssignedTo *string `form:"assigned_to"` - Priority *string `form:"priority"` - OverdueOnly bool `form:"overdue_only"` - FromDate *time.Time `form:"from_date"` - ToDate *time.Time `form:"to_date"` - Search *string `form:"search"` // Search in request number, email, name -} - -// DSRDashboardStats for the admin dashboard -type DSRDashboardStats struct { - TotalRequests int `json:"total_requests"` - PendingRequests int `json:"pending_requests"` - OverdueRequests int `json:"overdue_requests"` - CompletedThisMonth int `json:"completed_this_month"` - AverageProcessingDays float64 `json:"average_processing_days"` - ByType map[string]int `json:"by_type"` - ByStatus map[string]int `json:"by_status"` - UpcomingDeadlines []DataSubjectRequest `json:"upcoming_deadlines"` -} - -// DSRWithDetails combines DSR with related data -type DSRWithDetails struct { - Request DataSubjectRequest `json:"request"` - StatusHistory []DSRStatusHistory `json:"status_history"` - Communications []DSRCommunication `json:"communications"` - ExceptionChecks []DSRExceptionCheck `json:"exception_checks,omitempty"` - AssigneeName *string `json:"assignee_name,omitempty"` - CreatorName *string `json:"creator_name,omitempty"` -} - -// DSRTemplateWithVersions combines template with versions -type DSRTemplateWithVersions struct { - Template DSRTemplate `json:"template"` - LatestVersion *DSRTemplateVersion `json:"latest_version,omitempty"` - Versions []DSRTemplateVersion `json:"versions,omitempty"` -} - -// CreateDSRTemplateVersionRequest for creating a template version -type CreateDSRTemplateVersionRequest struct { - TemplateID string `json:"template_id" binding:"required"` - Version string `json:"version" binding:"required"` - Language string `json:"language" binding:"required"` - Subject string `json:"subject" binding:"required"` - BodyHTML string `json:"body_html" binding:"required"` - BodyText string `json:"body_text" binding:"required"` -} - -// UpdateDSRTemplateVersionRequest for updating a template version -type UpdateDSRTemplateVersionRequest struct { - Subject *string `json:"subject"` - BodyHTML *string `json:"body_html"` - BodyText *string `json:"body_text"` - Status *string `json:"status"` -} - -// PreviewDSRTemplateRequest for previewing a template with variables -type PreviewDSRTemplateRequest struct { - Variables map[string]string `json:"variables"` -} - -// DSRTemplatePreviewResponse for template preview -type DSRTemplatePreviewResponse struct { - Subject string `json:"subject"` - BodyHTML string `json:"body_html"` - BodyText string `json:"body_text"` -} - -// GetRequestTypeLabel returns German label for request type -func (rt DSRRequestType) Label() string { - switch rt { - case DSRTypeAccess: - return "Auskunftsanfrage (Art. 15)" - case DSRTypeRectification: - return "Berichtigungsanfrage (Art. 16)" - case DSRTypeErasure: - return "Löschanfrage (Art. 17)" - case DSRTypeRestriction: - return "Einschränkungsanfrage (Art. 18)" - case DSRTypePortability: - return "Datenübertragung (Art. 20)" - default: - return string(rt) - } -} - -// GetDeadlineDays returns the legal deadline in days for request type -func (rt DSRRequestType) DeadlineDays() int { - switch rt { - case DSRTypeAccess, DSRTypePortability: - return 30 // 1 month - case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction: - return 14 // 2 weeks (expedited per BDSG) - default: - return 30 - } -} - -// IsExpedited returns whether this request type should be processed expeditiously -func (rt DSRRequestType) IsExpedited() bool { - switch rt { - case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction: - return true - default: - return false - } -} - -// GetStatusLabel returns German label for status -func (s DSRStatus) Label() string { - switch s { - case DSRStatusIntake: - return "Eingang" - case DSRStatusIdentityVerification: - return "Identitätsprüfung" - case DSRStatusProcessing: - return "In Bearbeitung" - case DSRStatusCompleted: - return "Abgeschlossen" - case DSRStatusRejected: - return "Abgelehnt" - case DSRStatusCancelled: - return "Storniert" - default: - return string(s) - } -} - -// IsValidDSRRequestType checks if a string is a valid DSR request type -func IsValidDSRRequestType(reqType string) bool { - switch DSRRequestType(reqType) { - case DSRTypeAccess, DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction, DSRTypePortability: - return true - default: - return false - } -} - -// IsValidDSRStatus checks if a string is a valid DSR status -func IsValidDSRStatus(status string) bool { - switch DSRStatus(status) { - case DSRStatusIntake, DSRStatusIdentityVerification, DSRStatusProcessing, - DSRStatusCompleted, DSRStatusRejected, DSRStatusCancelled: - return true - default: - return false - } -} diff --git a/consent-service/internal/models/oauth.go b/consent-service/internal/models/oauth.go new file mode 100644 index 0000000..a5f8f4c --- /dev/null +++ b/consent-service/internal/models/oauth.go @@ -0,0 +1,103 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// OAuthClient represents a registered OAuth 2.0 client application +type OAuthClient struct { + ID uuid.UUID `json:"id" db:"id"` + ClientID string `json:"client_id" db:"client_id"` + ClientSecret string `json:"-" db:"client_secret"` // Never expose in JSON + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + RedirectURIs []string `json:"redirect_uris" db:"redirect_uris"` // JSON array + Scopes []string `json:"scopes" db:"scopes"` // Allowed scopes + GrantTypes []string `json:"grant_types" db:"grant_types"` // authorization_code, refresh_token + IsPublic bool `json:"is_public" db:"is_public"` // Public clients (SPAs) don't have secret + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// OAuthAuthorizationCode represents an authorization code for the OAuth flow +type OAuthAuthorizationCode struct { + ID uuid.UUID `json:"id" db:"id"` + Code string `json:"-" db:"code"` // Hashed + ClientID string `json:"client_id" db:"client_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + RedirectURI string `json:"redirect_uri" db:"redirect_uri"` + Scopes []string `json:"scopes" db:"scopes"` + CodeChallenge *string `json:"-" db:"code_challenge"` // For PKCE + CodeChallengeMethod *string `json:"-" db:"code_challenge_method"` // S256 or plain + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// OAuthAccessToken represents an OAuth access token +type OAuthAccessToken struct { + ID uuid.UUID `json:"id" db:"id"` + TokenHash string `json:"-" db:"token_hash"` + ClientID string `json:"client_id" db:"client_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Scopes []string `json:"scopes" db:"scopes"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// OAuthRefreshToken represents an OAuth refresh token +type OAuthRefreshToken struct { + ID uuid.UUID `json:"id" db:"id"` + TokenHash string `json:"-" db:"token_hash"` + AccessTokenID uuid.UUID `json:"access_token_id" db:"access_token_id"` + ClientID string `json:"client_id" db:"client_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Scopes []string `json:"scopes" db:"scopes"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// OAuthAuthorizeRequest for the authorization endpoint +type OAuthAuthorizeRequest struct { + ResponseType string `form:"response_type" binding:"required"` // Must be "code" + ClientID string `form:"client_id" binding:"required"` + RedirectURI string `form:"redirect_uri" binding:"required"` + Scope string `form:"scope"` // Space-separated scopes + State string `form:"state" binding:"required"` // CSRF protection + CodeChallenge string `form:"code_challenge"` // PKCE + CodeChallengeMethod string `form:"code_challenge_method"` // S256 (recommended) or plain +} + +// OAuthTokenRequest for the token endpoint +type OAuthTokenRequest struct { + GrantType string `form:"grant_type" binding:"required"` // authorization_code or refresh_token + Code string `form:"code"` // For authorization_code grant + RedirectURI string `form:"redirect_uri"` // For authorization_code grant + ClientID string `form:"client_id" binding:"required"` + ClientSecret string `form:"client_secret"` // For confidential clients + CodeVerifier string `form:"code_verifier"` // For PKCE + RefreshToken string `form:"refresh_token"` // For refresh_token grant + Scope string `form:"scope"` // For refresh_token grant (optional) +} + +// OAuthTokenResponse for successful token requests +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` // Always "Bearer" + ExpiresIn int `json:"expires_in"` // Seconds until expiration + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// OAuthErrorResponse for OAuth errors (RFC 6749) +type OAuthErrorResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` +} diff --git a/consent-service/internal/models/school.go b/consent-service/internal/models/school.go new file mode 100644 index 0000000..903b105 --- /dev/null +++ b/consent-service/internal/models/school.go @@ -0,0 +1,187 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// SchoolRole defines roles within the school system +const ( + SchoolRoleTeacher = "teacher" + SchoolRoleClassTeacher = "class_teacher" + SchoolRoleParent = "parent" + SchoolRoleParentRep = "parent_representative" + SchoolRoleStudent = "student" + SchoolRoleAdmin = "school_admin" + SchoolRolePrincipal = "principal" + SchoolRoleSecretary = "secretary" +) + +// AttendanceStatus defines the status of student attendance +const ( + AttendancePresent = "present" + AttendanceAbsent = "absent" + AttendanceAbsentExcused = "excused" + AttendanceAbsentUnexcused = "unexcused" + AttendanceLate = "late" + AttendanceLateExcused = "late_excused" + AttendancePending = "pending_confirmation" +) + +// GradeType defines the type of grade +const ( + GradeTypeExam = "exam" // Klassenarbeit/Klausur + GradeTypeTest = "test" // Test/Kurzarbeit + GradeTypeOral = "oral" // Mündlich + GradeTypeHomework = "homework" // Hausaufgabe + GradeTypeProject = "project" // Projekt + GradeTypeParticipation = "participation" // Mitarbeit + GradeTypeSemester = "semester" // Halbjahres-/Semesternote + GradeTypeFinal = "final" // Endnote/Zeugnisnote +) + +// School represents a school/educational institution +type School struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + ShortName *string `json:"short_name,omitempty" db:"short_name"` + Type string `json:"type" db:"type"` // 'grundschule', 'hauptschule', 'realschule', 'gymnasium', 'gesamtschule', 'berufsschule' + Address *string `json:"address,omitempty" db:"address"` + City *string `json:"city,omitempty" db:"city"` + PostalCode *string `json:"postal_code,omitempty" db:"postal_code"` + State *string `json:"state,omitempty" db:"state"` // Bundesland + Country string `json:"country" db:"country"` // Default: DE + Phone *string `json:"phone,omitempty" db:"phone"` + Email *string `json:"email,omitempty" db:"email"` + Website *string `json:"website,omitempty" db:"website"` + MatrixServerName *string `json:"matrix_server_name,omitempty" db:"matrix_server_name"` + LogoURL *string `json:"logo_url,omitempty" db:"logo_url"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SchoolYear represents an academic year +type SchoolYear struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + Name string `json:"name" db:"name"` // e.g., "2024/2025" + StartDate time.Time `json:"start_date" db:"start_date"` + EndDate time.Time `json:"end_date" db:"end_date"` + IsCurrent bool `json:"is_current" db:"is_current"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Class represents a school class +type Class struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` + Name string `json:"name" db:"name"` // e.g., "5a", "10b" + Grade int `json:"grade" db:"grade"` // Klassenstufe: 1-13 + Section *string `json:"section,omitempty" db:"section"` + Room *string `json:"room,omitempty" db:"room"` + MatrixInfoRoom *string `json:"matrix_info_room,omitempty" db:"matrix_info_room"` + MatrixRepRoom *string `json:"matrix_rep_room,omitempty" db:"matrix_rep_room"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Subject represents a school subject +type Subject struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + Name string `json:"name" db:"name"` // e.g., "Mathematik", "Deutsch" + ShortName string `json:"short_name" db:"short_name"` // e.g., "Ma", "De" + Color *string `json:"color,omitempty" db:"color"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Student represents a student +type Student struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` + StudentNumber *string `json:"student_number,omitempty" db:"student_number"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + DateOfBirth *time.Time `json:"date_of_birth,omitempty" db:"date_of_birth"` + Gender *string `json:"gender,omitempty" db:"gender"` + MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` + MatrixDMRoom *string `json:"matrix_dm_room,omitempty" db:"matrix_dm_room"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Teacher represents a teacher +type Teacher struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + TeacherCode *string `json:"teacher_code,omitempty" db:"teacher_code"` + Title *string `json:"title,omitempty" db:"title"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ClassTeacher assigns teachers to classes (Klassenlehrer) +type ClassTeacher struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + IsPrimary bool `json:"is_primary" db:"is_primary"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherSubject assigns subjects to teachers +type TeacherSubject struct { + ID uuid.UUID `json:"id" db:"id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Parent represents a parent/guardian +type Parent struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + Phone *string `json:"phone,omitempty" db:"phone"` + EmergencyContact bool `json:"emergency_contact" db:"emergency_contact"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// StudentParent links students to their parents +type StudentParent struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + Relationship string `json:"relationship" db:"relationship"` + IsPrimary bool `json:"is_primary" db:"is_primary"` + HasCustody bool `json:"has_custody" db:"has_custody"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ParentRepresentative assigns parent representatives to classes +type ParentRepresentative struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + Role string `json:"role" db:"role"` // 'first_rep', 'second_rep', 'substitute' + ElectedAt time.Time `json:"elected_at" db:"elected_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/consent-service/internal/models/school_operations.go b/consent-service/internal/models/school_operations.go new file mode 100644 index 0000000..a3bce43 --- /dev/null +++ b/consent-service/internal/models/school_operations.go @@ -0,0 +1,382 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// ======================================== +// Stundenplan / Timetable +// ======================================== + +// TimetableSlot represents a time slot in the timetable +type TimetableSlot struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + SlotNumber int `json:"slot_number" db:"slot_number"` // 1, 2, 3... (Stunde) + StartTime string `json:"start_time" db:"start_time"` // "08:00" + EndTime string `json:"end_time" db:"end_time"` // "08:45" + IsBreak bool `json:"is_break" db:"is_break"` // Pause + Name *string `json:"name,omitempty" db:"name"` // e.g., "1. Stunde", "Große Pause" +} + +// TimetableEntry represents a single lesson in the timetable +type TimetableEntry struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` // 1=Monday, 5=Friday + Room *string `json:"room,omitempty" db:"room"` + ValidFrom time.Time `json:"valid_from" db:"valid_from"` + ValidUntil *time.Time `json:"valid_until,omitempty" db:"valid_until"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TimetableSubstitution represents a substitution/replacement lesson +type TimetableSubstitution struct { + ID uuid.UUID `json:"id" db:"id"` + OriginalEntryID uuid.UUID `json:"original_entry_id" db:"original_entry_id"` + Date time.Time `json:"date" db:"date"` + SubstituteTeacherID *uuid.UUID `json:"substitute_teacher_id,omitempty" db:"substitute_teacher_id"` + SubstituteSubjectID *uuid.UUID `json:"substitute_subject_id,omitempty" db:"substitute_subject_id"` + Room *string `json:"room,omitempty" db:"room"` + Type string `json:"type" db:"type"` // 'substitution', 'cancelled', 'room_change', 'supervision' + Note *string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy uuid.UUID `json:"created_by" db:"created_by"` +} + +// ======================================== +// Abwesenheit / Attendance +// ======================================== + +// AttendanceRecord represents a student's attendance for a specific lesson +type AttendanceRecord struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + TimetableEntryID *uuid.UUID `json:"timetable_entry_id,omitempty" db:"timetable_entry_id"` + Date time.Time `json:"date" db:"date"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + Status string `json:"status" db:"status"` // AttendanceStatus constants + RecordedBy uuid.UUID `json:"recorded_by" db:"recorded_by"` + Note *string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AbsenceReport represents a full absence report (one or more days) +type AbsenceReport struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + StartDate time.Time `json:"start_date" db:"start_date"` + EndDate time.Time `json:"end_date" db:"end_date"` + Reason *string `json:"reason,omitempty" db:"reason"` + ReasonCategory string `json:"reason_category" db:"reason_category"` + Status string `json:"status" db:"status"` + ReportedBy uuid.UUID `json:"reported_by" db:"reported_by"` + ReportedAt time.Time `json:"reported_at" db:"reported_at"` + ConfirmedBy *uuid.UUID `json:"confirmed_by,omitempty" db:"confirmed_by"` + ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"` + MedicalCertificate bool `json:"medical_certificate" db:"medical_certificate"` + CertificateUploaded bool `json:"certificate_uploaded" db:"certificate_uploaded"` + MatrixNotificationSent bool `json:"matrix_notification_sent" db:"matrix_notification_sent"` + EmailNotificationSent bool `json:"email_notification_sent" db:"email_notification_sent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AbsenceNotification tracks notifications sent to parents about absences +type AbsenceNotification struct { + ID uuid.UUID `json:"id" db:"id"` + AttendanceRecordID uuid.UUID `json:"attendance_record_id" db:"attendance_record_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + Channel string `json:"channel" db:"channel"` + MessageContent string `json:"message_content" db:"message_content"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"` + ResponseReceived bool `json:"response_received" db:"response_received"` + ResponseContent *string `json:"response_content,omitempty" db:"response_content"` + ResponseAt *time.Time `json:"response_at,omitempty" db:"response_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ======================================== +// Notenspiegel / Grades +// ======================================== + +// GradeScale represents the grading scale used +type GradeScale struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + Name string `json:"name" db:"name"` + MinValue float64 `json:"min_value" db:"min_value"` + MaxValue float64 `json:"max_value" db:"max_value"` + PassingValue float64 `json:"passing_value" db:"passing_value"` + IsAscending bool `json:"is_ascending" db:"is_ascending"` + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Grade represents a single grade for a student +type Grade struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` + GradeScaleID uuid.UUID `json:"grade_scale_id" db:"grade_scale_id"` + Type string `json:"type" db:"type"` + Value float64 `json:"value" db:"value"` + Weight float64 `json:"weight" db:"weight"` + Date time.Time `json:"date" db:"date"` + Title *string `json:"title,omitempty" db:"title"` + Description *string `json:"description,omitempty" db:"description"` + IsVisible bool `json:"is_visible" db:"is_visible"` + Semester int `json:"semester" db:"semester"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// GradeComment represents a teacher comment on a student's grade +type GradeComment struct { + ID uuid.UUID `json:"id" db:"id"` + GradeID uuid.UUID `json:"grade_id" db:"grade_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + Comment string `json:"comment" db:"comment"` + IsPrivate bool `json:"is_private" db:"is_private"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ======================================== +// Klassenbuch, Meetings, Communication +// ======================================== + +// ClassDiaryEntry represents an entry in the digital class diary +type ClassDiaryEntry struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + Date time.Time `json:"date" db:"date"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + Topic *string `json:"topic,omitempty" db:"topic"` + Homework *string `json:"homework,omitempty" db:"homework"` + HomeworkDueDate *time.Time `json:"homework_due_date,omitempty" db:"homework_due_date"` + Materials *string `json:"materials,omitempty" db:"materials"` + Notes *string `json:"notes,omitempty" db:"notes"` + IsCancelled bool `json:"is_cancelled" db:"is_cancelled"` + CancellationReason *string `json:"cancellation_reason,omitempty" db:"cancellation_reason"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ParentMeetingSlot represents available time slots for parent meetings +type ParentMeetingSlot struct { + ID uuid.UUID `json:"id" db:"id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + Date time.Time `json:"date" db:"date"` + StartTime string `json:"start_time" db:"start_time"` + EndTime string `json:"end_time" db:"end_time"` + Location *string `json:"location,omitempty" db:"location"` + IsOnline bool `json:"is_online" db:"is_online"` + MeetingLink *string `json:"meeting_link,omitempty" db:"meeting_link"` + IsBooked bool `json:"is_booked" db:"is_booked"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ParentMeeting represents a booked parent-teacher meeting +type ParentMeeting struct { + ID uuid.UUID `json:"id" db:"id"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + Topic *string `json:"topic,omitempty" db:"topic"` + Notes *string `json:"notes,omitempty" db:"notes"` + Status string `json:"status" db:"status"` + CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at"` + CancelledBy *uuid.UUID `json:"cancelled_by,omitempty" db:"cancelled_by"` + CancelReason *string `json:"cancel_reason,omitempty" db:"cancel_reason"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// MatrixRoom tracks Matrix rooms created for school communication +type MatrixRoom struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + MatrixRoomID string `json:"matrix_room_id" db:"matrix_room_id"` + Type string `json:"type" db:"type"` + ClassID *uuid.UUID `json:"class_id,omitempty" db:"class_id"` + StudentID *uuid.UUID `json:"student_id,omitempty" db:"student_id"` + Name string `json:"name" db:"name"` + IsEncrypted bool `json:"is_encrypted" db:"is_encrypted"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// MatrixRoomMember tracks membership in Matrix rooms +type MatrixRoomMember struct { + ID uuid.UUID `json:"id" db:"id"` + MatrixRoomID uuid.UUID `json:"matrix_room_id" db:"matrix_room_id"` + MatrixUserID string `json:"matrix_user_id" db:"matrix_user_id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` + PowerLevel int `json:"power_level" db:"power_level"` + CanWrite bool `json:"can_write" db:"can_write"` + JoinedAt time.Time `json:"joined_at" db:"joined_at"` + LeftAt *time.Time `json:"left_at,omitempty" db:"left_at"` +} + +// ParentOnboardingToken for QR-code based parent onboarding +type ParentOnboardingToken struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + Token string `json:"token" db:"token"` + Role string `json:"role" db:"role"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + UsedByUserID *uuid.UUID `json:"used_by_user_id,omitempty" db:"used_by_user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy uuid.UUID `json:"created_by" db:"created_by"` +} + +// ======================================== +// Schulverwaltung DTOs +// ======================================== + +// CreateSchoolRequest for creating a new school +type CreateSchoolRequest struct { + Name string `json:"name" binding:"required"` + ShortName *string `json:"short_name"` + Type string `json:"type" binding:"required"` + Address *string `json:"address"` + City *string `json:"city"` + PostalCode *string `json:"postal_code"` + State *string `json:"state"` + Phone *string `json:"phone"` + Email *string `json:"email"` + Website *string `json:"website"` +} + +// CreateClassRequest for creating a new class +type CreateClassRequest struct { + SchoolYearID string `json:"school_year_id" binding:"required"` + Name string `json:"name" binding:"required"` + Grade int `json:"grade" binding:"required"` + Section *string `json:"section"` + Room *string `json:"room"` +} + +// CreateStudentRequest for creating a new student +type CreateStudentRequest struct { + ClassID string `json:"class_id" binding:"required"` + StudentNumber *string `json:"student_number"` + FirstName string `json:"first_name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + DateOfBirth *string `json:"date_of_birth"` // ISO 8601 + Gender *string `json:"gender"` +} + +// RecordAttendanceRequest for recording attendance +type RecordAttendanceRequest struct { + StudentID string `json:"student_id" binding:"required"` + Date string `json:"date" binding:"required"` // ISO 8601 + SlotID string `json:"slot_id" binding:"required"` + Status string `json:"status" binding:"required"` // AttendanceStatus + Note *string `json:"note"` +} + +// ReportAbsenceRequest for parents reporting absence +type ReportAbsenceRequest struct { + StudentID string `json:"student_id" binding:"required"` + StartDate string `json:"start_date" binding:"required"` + EndDate string `json:"end_date" binding:"required"` + Reason *string `json:"reason"` + ReasonCategory string `json:"reason_category" binding:"required"` +} + +// CreateGradeRequest for creating a grade +type CreateGradeRequest struct { + StudentID string `json:"student_id" binding:"required"` + SubjectID string `json:"subject_id" binding:"required"` + SchoolYearID string `json:"school_year_id" binding:"required"` + Type string `json:"type" binding:"required"` + Value float64 `json:"value" binding:"required"` + Weight float64 `json:"weight"` + Date string `json:"date" binding:"required"` + Title *string `json:"title"` + Description *string `json:"description"` + Semester int `json:"semester" binding:"required"` +} + +// StudentGradeOverview provides a summary of all grades for a student in a subject +type StudentGradeOverview struct { + Student Student `json:"student"` + Subject Subject `json:"subject"` + Grades []Grade `json:"grades"` + Average float64 `json:"average"` + OralAverage float64 `json:"oral_average"` + ExamAverage float64 `json:"exam_average"` + Semester int `json:"semester"` +} + +// ClassAttendanceOverview provides attendance summary for a class +type ClassAttendanceOverview struct { + Class Class `json:"class"` + Date time.Time `json:"date"` + TotalStudents int `json:"total_students"` + PresentCount int `json:"present_count"` + AbsentCount int `json:"absent_count"` + LateCount int `json:"late_count"` + Records []AttendanceRecord `json:"records"` +} + +// ParentDashboard provides a parent's view of their children's data +type ParentDashboard struct { + Children []StudentOverview `json:"children"` + UnreadMessages int `json:"unread_messages"` + UpcomingMeetings []ParentMeeting `json:"upcoming_meetings"` + RecentGrades []Grade `json:"recent_grades"` + PendingActions []string `json:"pending_actions"` +} + +// StudentOverview provides summary info about a student +type StudentOverview struct { + Student Student `json:"student"` + Class Class `json:"class"` + ClassTeacher *Teacher `json:"class_teacher,omitempty"` + AttendanceRate float64 `json:"attendance_rate"` + UnexcusedAbsences int `json:"unexcused_absences"` + GradeAverage float64 `json:"grade_average"` +} + +// TimetableView provides a formatted timetable for display +type TimetableView struct { + Class Class `json:"class"` + Week string `json:"week"` // ISO week: "2025-W01" + Days []TimetableDayView `json:"days"` +} + +// TimetableDayView represents a single day in the timetable +type TimetableDayView struct { + Date time.Time `json:"date"` + DayName string `json:"day_name"` + Lessons []TimetableLessonView `json:"lessons"` +} + +// TimetableLessonView represents a single lesson in the timetable view +type TimetableLessonView struct { + Slot TimetableSlot `json:"slot"` + Subject *Subject `json:"subject,omitempty"` + Teacher *Teacher `json:"teacher,omitempty"` + Room *string `json:"room,omitempty"` + IsSubstitution bool `json:"is_substitution"` + IsCancelled bool `json:"is_cancelled"` + Note *string `json:"note,omitempty"` +} diff --git a/consent-service/internal/models/user.go b/consent-service/internal/models/user.go new file mode 100644 index 0000000..88aa7cf --- /dev/null +++ b/consent-service/internal/models/user.go @@ -0,0 +1,195 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// User represents a user with full authentication support +type User struct { + ID uuid.UUID `json:"id" db:"id"` + ExternalID *string `json:"external_id,omitempty" db:"external_id"` + Email string `json:"email" db:"email"` + PasswordHash *string `json:"-" db:"password_hash"` // Never exposed in JSON + Name *string `json:"name,omitempty" db:"name"` + Role string `json:"role" db:"role"` // 'user', 'admin', 'super_admin', 'data_protection_officer' + EmailVerified bool `json:"email_verified" db:"email_verified"` + EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty" db:"email_verified_at"` + AccountStatus string `json:"account_status" db:"account_status"` // 'active', 'suspended', 'locked' + LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` + FailedLoginAttempts int `json:"failed_login_attempts" db:"failed_login_attempts"` + LockedUntil *time.Time `json:"locked_until,omitempty" db:"locked_until"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EmailVerificationToken for email verification +type EmailVerificationToken struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Token string `json:"token" db:"token"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// PasswordResetToken for password reset +type PasswordResetToken struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Token string `json:"token" db:"token"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// UserSession for session management +type UserSession struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + TokenHash string `json:"-" db:"token_hash"` + DeviceInfo *string `json:"device_info,omitempty" db:"device_info"` + IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` + UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + LastActivityAt time.Time `json:"last_activity_at" db:"last_activity_at"` +} + +// RegisterRequest for user registration +type RegisterRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + Name *string `json:"name"` +} + +// LoginRequest for user login +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +// LoginResponse after successful login +type LoginResponse struct { + User User `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` // seconds +} + +// RefreshTokenRequest for token refresh +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +// VerifyEmailRequest for email verification +type VerifyEmailRequest struct { + Token string `json:"token" binding:"required"` +} + +// ForgotPasswordRequest for password reset request +type ForgotPasswordRequest struct { + Email string `json:"email" binding:"required,email"` +} + +// ResetPasswordRequest for password reset +type ResetPasswordRequest struct { + Token string `json:"token" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +// ChangePasswordRequest for changing password +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +// UpdateProfileRequest for profile updates +type UpdateProfileRequest struct { + Name *string `json:"name"` +} + +// ======================================== +// Two-Factor Authentication (2FA/TOTP) +// ======================================== + +// UserTOTP stores 2FA TOTP configuration for a user +type UserTOTP struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Secret string `json:"-" db:"secret"` // Encrypted TOTP secret + Verified bool `json:"verified" db:"verified"` // Has 2FA been verified/activated + RecoveryCodes []string `json:"-" db:"recovery_codes"` // Encrypted backup codes + EnabledAt *time.Time `json:"enabled_at,omitempty" db:"enabled_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TwoFactorChallenge represents a pending 2FA challenge during login +type TwoFactorChallenge struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + ChallengeID string `json:"challenge_id" db:"challenge_id"` // Temporary token + IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` + UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Setup2FAResponse when initiating 2FA setup +type Setup2FAResponse struct { + Secret string `json:"secret"` // Base32 encoded secret for manual entry + QRCodeDataURL string `json:"qr_code"` // Data URL for QR code image + RecoveryCodes []string `json:"recovery_codes"` // One-time backup codes +} + +// Verify2FARequest for verifying 2FA setup or login +type Verify2FARequest struct { + Code string `json:"code" binding:"required"` // 6-digit TOTP code + ChallengeID string `json:"challenge_id,omitempty"` // For login flow +} + +// TwoFactorLoginResponse when 2FA is required during login +type TwoFactorLoginResponse struct { + RequiresTwoFactor bool `json:"requires_two_factor"` + ChallengeID string `json:"challenge_id"` // Use this to complete 2FA + Message string `json:"message"` +} + +// Complete2FALoginRequest to complete login with 2FA +type Complete2FALoginRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + Code string `json:"code" binding:"required"` // 6-digit TOTP or recovery code +} + +// Disable2FARequest for disabling 2FA +type Disable2FARequest struct { + Password string `json:"password" binding:"required"` // Require password confirmation + Code string `json:"code" binding:"required"` // Current TOTP code +} + +// RecoveryCodeUseRequest for using a recovery code +type RecoveryCodeUseRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + RecoveryCode string `json:"recovery_code" binding:"required"` +} + +// TwoFactorStatusResponse for checking 2FA status +type TwoFactorStatusResponse struct { + Enabled bool `json:"enabled"` + Verified bool `json:"verified"` + EnabledAt *time.Time `json:"enabled_at,omitempty"` + RecoveryCodesCount int `json:"recovery_codes_count"` +} + +// Verify2FAChallengeRequest for verifying a 2FA challenge during login +type Verify2FAChallengeRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + Code string `json:"code,omitempty"` // 6-digit TOTP code + RecoveryCode string `json:"recovery_code,omitempty"` // Alternative: recovery code +} diff --git a/consent-service/internal/services/attendance_service.go b/consent-service/internal/services/attendance_service.go index 046e6f8..28c7ea4 100644 --- a/consent-service/internal/services/attendance_service.go +++ b/consent-service/internal/services/attendance_service.go @@ -235,271 +235,3 @@ func (s *AttendanceService) GetStudentAttendance(ctx context.Context, studentID return records, nil } -// ======================================== -// Absence Reports (Parent-initiated) -// ======================================== - -// ReportAbsence allows parents to report a student's absence -func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) { - studentID, err := uuid.Parse(req.StudentID) - if err != nil { - return nil, fmt.Errorf("invalid student ID: %w", err) - } - - startDate, err := time.Parse("2006-01-02", req.StartDate) - if err != nil { - return nil, fmt.Errorf("invalid start date format: %w", err) - } - - endDate, err := time.Parse("2006-01-02", req.EndDate) - if err != nil { - return nil, fmt.Errorf("invalid end date format: %w", err) - } - - report := &models.AbsenceReport{ - ID: uuid.New(), - StudentID: studentID, - StartDate: startDate, - EndDate: endDate, - Reason: req.Reason, - ReasonCategory: req.ReasonCategory, - Status: "reported", - ReportedBy: reportedByUserID, - ReportedAt: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - query := ` - INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id` - - err = s.db.Pool.QueryRow(ctx, query, - report.ID, report.StudentID, report.StartDate, report.EndDate, - report.Reason, report.ReasonCategory, report.Status, - report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt, - ).Scan(&report.ID) - - if err != nil { - return nil, fmt.Errorf("failed to create absence report: %w", err) - } - - return report, nil -} - -// ConfirmAbsence allows teachers to confirm/excuse an absence -func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error { - query := ` - UPDATE absence_reports - SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW() - WHERE id = $3` - - result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID) - if err != nil { - return fmt.Errorf("failed to confirm absence: %w", err) - } - - if result.RowsAffected() == 0 { - return fmt.Errorf("absence report not found") - } - - return nil -} - -// GetAbsenceReports gets absence reports for a student -func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) { - query := ` - SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at - FROM absence_reports - WHERE student_id = $1 - ORDER BY start_date DESC` - - rows, err := s.db.Pool.Query(ctx, query, studentID) - if err != nil { - return nil, fmt.Errorf("failed to get absence reports: %w", err) - } - defer rows.Close() - - var reports []models.AbsenceReport - for rows.Next() { - var report models.AbsenceReport - err := rows.Scan( - &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, - &report.Reason, &report.ReasonCategory, &report.Status, - &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, - &report.MedicalCertificate, &report.CertificateUploaded, - &report.MatrixNotificationSent, &report.EmailNotificationSent, - &report.CreatedAt, &report.UpdatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan absence report: %w", err) - } - reports = append(reports, report) - } - - return reports, nil -} - -// GetPendingAbsenceReports gets all unconfirmed absence reports for a class -func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) { - query := ` - SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at - FROM absence_reports ar - JOIN students s ON ar.student_id = s.id - WHERE s.class_id = $1 AND ar.status = 'reported' - ORDER BY ar.start_date DESC` - - rows, err := s.db.Pool.Query(ctx, query, classID) - if err != nil { - return nil, fmt.Errorf("failed to get pending absence reports: %w", err) - } - defer rows.Close() - - var reports []models.AbsenceReport - for rows.Next() { - var report models.AbsenceReport - err := rows.Scan( - &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, - &report.Reason, &report.ReasonCategory, &report.Status, - &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, - &report.MedicalCertificate, &report.CertificateUploaded, - &report.MatrixNotificationSent, &report.EmailNotificationSent, - &report.CreatedAt, &report.UpdatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan absence report: %w", err) - } - reports = append(reports, report) - } - - return reports, nil -} - -// ======================================== -// Attendance Statistics -// ======================================== - -// GetStudentAttendanceStats gets attendance statistics for a student -func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) { - query := ` - SELECT - COUNT(*) as total_records, - COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count, - COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count, - COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count, - COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count - FROM attendance_records ar - JOIN timetable_slots ts ON ar.slot_id = ts.id - JOIN schools sch ON ts.school_id = sch.id - JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2 - WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date` - - var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int - err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan( - &totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount, - ) - if err != nil { - return nil, fmt.Errorf("failed to get attendance stats: %w", err) - } - - var attendanceRate float64 - if totalRecords > 0 { - attendanceRate = float64(presentCount) / float64(totalRecords) * 100 - } - - return map[string]interface{}{ - "total_records": totalRecords, - "present_count": presentCount, - "absent_count": absentCount, - "unexcused_count": unexcusedCount, - "late_count": lateCount, - "attendance_rate": attendanceRate, - }, nil -} - -// ======================================== -// Parent Notifications -// ======================================== - -func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) { - if s.matrix == nil { - return - } - - // Get student info - var studentFirstName, studentLastName, matrixDMRoom string - err := s.db.Pool.QueryRow(ctx, ` - SELECT first_name, last_name, matrix_dm_room - FROM students - WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) - if err != nil || matrixDMRoom == "" { - return - } - - // Get slot info - var slotNumber int - err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber) - if err != nil { - return - } - - studentName := studentFirstName + " " + studentLastName - dateStr := record.Date.Format("02.01.2006") - - // Send Matrix notification - err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber) - if err != nil { - fmt.Printf("Failed to send absence notification: %v\n", err) - return - } - - // Update notification status - s.db.Pool.Exec(ctx, ` - UPDATE attendance_records - SET updated_at = NOW() - WHERE id = $1`, record.ID) - - // Log the notification - s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber) -} - -func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) { - record := &models.AttendanceRecord{ - StudentID: studentID, - Date: date, - SlotID: slotID, - } - s.notifyParentsOfAbsence(ctx, record) -} - -func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) { - // Get parent IDs for this student - query := ` - SELECT p.id - FROM parents p - JOIN student_parents sp ON p.id = sp.parent_id - JOIN attendance_records ar ON sp.student_id = ar.student_id - WHERE ar.id = $1` - - rows, err := s.db.Pool.Query(ctx, query, recordID) - if err != nil { - return - } - defer rows.Close() - - message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber) - - for rows.Next() { - var parentID uuid.UUID - if err := rows.Scan(&parentID); err != nil { - continue - } - - // Insert notification log - s.db.Pool.Exec(ctx, ` - INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at) - VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`, - uuid.New(), recordID, parentID, message) - } -} diff --git a/consent-service/internal/services/attendance_service_ops.go b/consent-service/internal/services/attendance_service_ops.go new file mode 100644 index 0000000..74dfbfc --- /dev/null +++ b/consent-service/internal/services/attendance_service_ops.go @@ -0,0 +1,280 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/google/uuid" +) + +// ======================================== +// Absence Reports (Parent-initiated) +// ======================================== + +// ReportAbsence allows parents to report a student's absence +func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) { + studentID, err := uuid.Parse(req.StudentID) + if err != nil { + return nil, fmt.Errorf("invalid student ID: %w", err) + } + + startDate, err := time.Parse("2006-01-02", req.StartDate) + if err != nil { + return nil, fmt.Errorf("invalid start date format: %w", err) + } + + endDate, err := time.Parse("2006-01-02", req.EndDate) + if err != nil { + return nil, fmt.Errorf("invalid end date format: %w", err) + } + + report := &models.AbsenceReport{ + ID: uuid.New(), + StudentID: studentID, + StartDate: startDate, + EndDate: endDate, + Reason: req.Reason, + ReasonCategory: req.ReasonCategory, + Status: "reported", + ReportedBy: reportedByUserID, + ReportedAt: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + report.ID, report.StudentID, report.StartDate, report.EndDate, + report.Reason, report.ReasonCategory, report.Status, + report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt, + ).Scan(&report.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create absence report: %w", err) + } + + return report, nil +} + +// ConfirmAbsence allows teachers to confirm/excuse an absence +func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error { + query := ` + UPDATE absence_reports + SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW() + WHERE id = $3` + + result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID) + if err != nil { + return fmt.Errorf("failed to confirm absence: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("absence report not found") + } + + return nil +} + +// GetAbsenceReports gets absence reports for a student +func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) { + query := ` + SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at + FROM absence_reports + WHERE student_id = $1 + ORDER BY start_date DESC` + + rows, err := s.db.Pool.Query(ctx, query, studentID) + if err != nil { + return nil, fmt.Errorf("failed to get absence reports: %w", err) + } + defer rows.Close() + + var reports []models.AbsenceReport + for rows.Next() { + var report models.AbsenceReport + err := rows.Scan( + &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, + &report.Reason, &report.ReasonCategory, &report.Status, + &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, + &report.MedicalCertificate, &report.CertificateUploaded, + &report.MatrixNotificationSent, &report.EmailNotificationSent, + &report.CreatedAt, &report.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan absence report: %w", err) + } + reports = append(reports, report) + } + + return reports, nil +} + +// GetPendingAbsenceReports gets all unconfirmed absence reports for a class +func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) { + query := ` + SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at + FROM absence_reports ar + JOIN students s ON ar.student_id = s.id + WHERE s.class_id = $1 AND ar.status = 'reported' + ORDER BY ar.start_date DESC` + + rows, err := s.db.Pool.Query(ctx, query, classID) + if err != nil { + return nil, fmt.Errorf("failed to get pending absence reports: %w", err) + } + defer rows.Close() + + var reports []models.AbsenceReport + for rows.Next() { + var report models.AbsenceReport + err := rows.Scan( + &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, + &report.Reason, &report.ReasonCategory, &report.Status, + &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, + &report.MedicalCertificate, &report.CertificateUploaded, + &report.MatrixNotificationSent, &report.EmailNotificationSent, + &report.CreatedAt, &report.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan absence report: %w", err) + } + reports = append(reports, report) + } + + return reports, nil +} + +// ======================================== +// Attendance Statistics +// ======================================== + +// GetStudentAttendanceStats gets attendance statistics for a student +func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) { + query := ` + SELECT + COUNT(*) as total_records, + COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count, + COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count, + COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count, + COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count + FROM attendance_records ar + JOIN timetable_slots ts ON ar.slot_id = ts.id + JOIN schools sch ON ts.school_id = sch.id + JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2 + WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date` + + var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int + err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan( + &totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount, + ) + if err != nil { + return nil, fmt.Errorf("failed to get attendance stats: %w", err) + } + + var attendanceRate float64 + if totalRecords > 0 { + attendanceRate = float64(presentCount) / float64(totalRecords) * 100 + } + + return map[string]interface{}{ + "total_records": totalRecords, + "present_count": presentCount, + "absent_count": absentCount, + "unexcused_count": unexcusedCount, + "late_count": lateCount, + "attendance_rate": attendanceRate, + }, nil +} + +// ======================================== +// Parent Notifications +// ======================================== + +func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) { + if s.matrix == nil { + return + } + + // Get student info + var studentFirstName, studentLastName, matrixDMRoom string + err := s.db.Pool.QueryRow(ctx, ` + SELECT first_name, last_name, matrix_dm_room + FROM students + WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) + if err != nil || matrixDMRoom == "" { + return + } + + // Get slot info + var slotNumber int + err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber) + if err != nil { + return + } + + studentName := studentFirstName + " " + studentLastName + dateStr := record.Date.Format("02.01.2006") + + // Send Matrix notification + err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber) + if err != nil { + fmt.Printf("Failed to send absence notification: %v\n", err) + return + } + + // Update notification status + s.db.Pool.Exec(ctx, ` + UPDATE attendance_records + SET updated_at = NOW() + WHERE id = $1`, record.ID) + + // Log the notification + s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber) +} + +func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) { + record := &models.AttendanceRecord{ + StudentID: studentID, + Date: date, + SlotID: slotID, + } + s.notifyParentsOfAbsence(ctx, record) +} + +func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) { + // Get parent IDs for this student + query := ` + SELECT p.id + FROM parents p + JOIN student_parents sp ON p.id = sp.parent_id + JOIN attendance_records ar ON sp.student_id = ar.student_id + WHERE ar.id = $1` + + rows, err := s.db.Pool.Query(ctx, query, recordID) + if err != nil { + return + } + defer rows.Close() + + message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber) + + for rows.Next() { + var parentID uuid.UUID + if err := rows.Scan(&parentID); err != nil { + continue + } + + // Insert notification log + s.db.Pool.Exec(ctx, ` + INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at) + VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`, + uuid.New(), recordID, parentID, message) + } +} diff --git a/consent-service/internal/services/auth_service.go b/consent-service/internal/services/auth_service.go index da02c9c..98fc732 100644 --- a/consent-service/internal/services/auth_service.go +++ b/consent-service/internal/services/auth_service.go @@ -383,186 +383,3 @@ func (s *AuthService) VerifyEmail(ctx context.Context, token string) error { return nil } -// CreatePasswordResetToken creates a password reset token -func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) { - var userID uuid.UUID - err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID) - if err != nil { - // Don't reveal if user exists - return "", nil, nil - } - - token, err := s.GenerateSecureToken(32) - if err != nil { - return "", nil, err - } - - _, err = s.db.Exec(ctx, ` - INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at) - VALUES ($1, $2, $3, $4, NOW()) - `, userID, token, time.Now().Add(time.Hour), ipAddress) - - if err != nil { - return "", nil, fmt.Errorf("failed to create reset token: %w", err) - } - - return token, &userID, nil -} - -// ResetPassword resets a user's password using a reset token -func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error { - var tokenID uuid.UUID - var userID uuid.UUID - var expiresAt time.Time - var usedAt *time.Time - - err := s.db.QueryRow(ctx, ` - SELECT id, user_id, expires_at, used_at FROM password_reset_tokens - WHERE token = $1 - `, token).Scan(&tokenID, &userID, &expiresAt, &usedAt) - - if err != nil { - return ErrInvalidToken - } - - if usedAt != nil || expiresAt.Before(time.Now()) { - return ErrInvalidToken - } - - // Hash new password - passwordHash, err := s.HashPassword(newPassword) - if err != nil { - return err - } - - // Mark token as used - _, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID) - if err != nil { - return fmt.Errorf("failed to update token: %w", err) - } - - // Update password - _, err = s.db.Exec(ctx, ` - UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2 - `, passwordHash, userID) - - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - // Revoke all sessions for security - _, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID) - if err != nil { - fmt.Printf("Warning: failed to revoke sessions: %v\n", err) - } - - return nil -} - -// ChangePassword changes a user's password (requires current password) -func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { - var passwordHash *string - err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash) - if err != nil { - return ErrUserNotFound - } - - if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) { - return ErrInvalidCredentials - } - - newPasswordHash, err := s.HashPassword(newPassword) - if err != nil { - return err - } - - _, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID) - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - return nil -} - -// GetUserByID retrieves a user by ID -func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) { - var user models.User - err := s.db.QueryRow(ctx, ` - SELECT id, email, name, role, email_verified, email_verified_at, account_status, - last_login_at, created_at, updated_at - FROM users WHERE id = $1 - `, userID).Scan( - &user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt, - &user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, - ) - - if err != nil { - return nil, ErrUserNotFound - } - - return &user, nil -} - -// UpdateProfile updates a user's profile -func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) { - _, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID) - if err != nil { - return nil, fmt.Errorf("failed to update profile: %w", err) - } - - return s.GetUserByID(ctx, userID) -} - -// GetActiveSessions retrieves all active sessions for a user -func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) { - rows, err := s.db.Query(ctx, ` - SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at - FROM user_sessions - WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() - ORDER BY last_activity_at DESC - `, userID) - - if err != nil { - return nil, fmt.Errorf("failed to get sessions: %w", err) - } - defer rows.Close() - - var sessions []models.UserSession - for rows.Next() { - var session models.UserSession - err := rows.Scan( - &session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress, - &session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan session: %w", err) - } - sessions = append(sessions, session) - } - - return sessions, nil -} - -// RevokeSession revokes a specific session -func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error { - result, err := s.db.Exec(ctx, ` - UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL - `, sessionID, userID) - - if err != nil { - return fmt.Errorf("failed to revoke session: %w", err) - } - - if result.RowsAffected() == 0 { - return errors.New("session not found") - } - - return nil -} - -// Logout revokes a session by refresh token -func (s *AuthService) Logout(ctx context.Context, refreshToken string) error { - tokenHash := s.HashToken(refreshToken) - _, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash) - return err -} diff --git a/consent-service/internal/services/auth_service_sessions.go b/consent-service/internal/services/auth_service_sessions.go new file mode 100644 index 0000000..841f268 --- /dev/null +++ b/consent-service/internal/services/auth_service_sessions.go @@ -0,0 +1,196 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/breakpilot/consent-service/internal/models" +) + +// CreatePasswordResetToken creates a password reset token +func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) { + var userID uuid.UUID + err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID) + if err != nil { + // Don't reveal if user exists + return "", nil, nil + } + + token, err := s.GenerateSecureToken(32) + if err != nil { + return "", nil, err + } + + _, err = s.db.Exec(ctx, ` + INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at) + VALUES ($1, $2, $3, $4, NOW()) + `, userID, token, time.Now().Add(time.Hour), ipAddress) + + if err != nil { + return "", nil, fmt.Errorf("failed to create reset token: %w", err) + } + + return token, &userID, nil +} + +// ResetPassword resets a user's password using a reset token +func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error { + var tokenID uuid.UUID + var userID uuid.UUID + var expiresAt time.Time + var usedAt *time.Time + + err := s.db.QueryRow(ctx, ` + SELECT id, user_id, expires_at, used_at FROM password_reset_tokens + WHERE token = $1 + `, token).Scan(&tokenID, &userID, &expiresAt, &usedAt) + + if err != nil { + return ErrInvalidToken + } + + if usedAt != nil || expiresAt.Before(time.Now()) { + return ErrInvalidToken + } + + // Hash new password + passwordHash, err := s.HashPassword(newPassword) + if err != nil { + return err + } + + // Mark token as used + _, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID) + if err != nil { + return fmt.Errorf("failed to update token: %w", err) + } + + // Update password + _, err = s.db.Exec(ctx, ` + UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2 + `, passwordHash, userID) + + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + // Revoke all sessions for security + _, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID) + if err != nil { + fmt.Printf("Warning: failed to revoke sessions: %v\n", err) + } + + return nil +} + +// ChangePassword changes a user's password (requires current password) +func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { + var passwordHash *string + err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash) + if err != nil { + return ErrUserNotFound + } + + if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) { + return ErrInvalidCredentials + } + + newPasswordHash, err := s.HashPassword(newPassword) + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} + +// GetUserByID retrieves a user by ID +func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) { + var user models.User + err := s.db.QueryRow(ctx, ` + SELECT id, email, name, role, email_verified, email_verified_at, account_status, + last_login_at, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan( + &user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt, + &user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, + ) + + if err != nil { + return nil, ErrUserNotFound + } + + return &user, nil +} + +// UpdateProfile updates a user's profile +func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) { + _, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID) + if err != nil { + return nil, fmt.Errorf("failed to update profile: %w", err) + } + + return s.GetUserByID(ctx, userID) +} + +// GetActiveSessions retrieves all active sessions for a user +func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at + FROM user_sessions + WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY last_activity_at DESC + `, userID) + + if err != nil { + return nil, fmt.Errorf("failed to get sessions: %w", err) + } + defer rows.Close() + + var sessions []models.UserSession + for rows.Next() { + var session models.UserSession + err := rows.Scan( + &session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress, + &session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan session: %w", err) + } + sessions = append(sessions, session) + } + + return sessions, nil +} + +// RevokeSession revokes a specific session +func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error { + result, err := s.db.Exec(ctx, ` + UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL + `, sessionID, userID) + + if err != nil { + return fmt.Errorf("failed to revoke session: %w", err) + } + + if result.RowsAffected() == 0 { + return errors.New("session not found") + } + + return nil +} + +// Logout revokes a session by refresh token +func (s *AuthService) Logout(ctx context.Context, refreshToken string) error { + tokenHash := s.HashToken(refreshToken) + _, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash) + return err +} diff --git a/consent-service/internal/services/dsr_service.go b/consent-service/internal/services/dsr_service.go index 7b44c0d..3ddfce3 100644 --- a/consent-service/internal/services/dsr_service.go +++ b/consent-service/internal/services/dsr_service.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" "time" "github.com/breakpilot/consent-service/internal/models" @@ -367,29 +366,6 @@ func (s *DSRService) AssignRequest(ctx context.Context, id uuid.UUID, assigneeID return nil } -// ExtendDeadline extends the deadline for a DSR -func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error { - // Default extension is 2 months (60 days) per Art. 12(3) - if days <= 0 { - days = 60 - } - - _, err := s.pool.Exec(ctx, ` - UPDATE data_subject_requests - SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL, - extension_reason = $2, - updated_at = NOW() - WHERE id = $3 - `, days, reason, id) - if err != nil { - return fmt.Errorf("failed to extend deadline: %w", err) - } - - s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason)) - - return nil -} - // CompleteRequest marks a DSR as completed func (s *DSRService) CompleteRequest(ctx context.Context, id uuid.UUID, summary string, resultData map[string]interface{}, completedBy uuid.UUID) error { resultJSON, _ := json.Marshal(resultData) @@ -470,352 +446,7 @@ func (s *DSRService) CancelRequest(ctx context.Context, id uuid.UUID, cancelledB return nil } -// GetDashboardStats returns statistics for the admin dashboard -func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) { - stats := &models.DSRDashboardStats{ - ByType: make(map[string]int), - ByStatus: make(map[string]int), - } - - // Total requests - s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests) - - // Pending requests (not completed, rejected, or cancelled) - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM data_subject_requests - WHERE status NOT IN ('completed', 'rejected', 'cancelled') - `).Scan(&stats.PendingRequests) - - // Overdue requests - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM data_subject_requests - WHERE COALESCE(extended_deadline_at, deadline_at) < NOW() - AND status NOT IN ('completed', 'rejected', 'cancelled') - `).Scan(&stats.OverdueRequests) - - // Completed this month - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM data_subject_requests - WHERE status = 'completed' - AND completed_at >= DATE_TRUNC('month', NOW()) - `).Scan(&stats.CompletedThisMonth) - - // Average processing days - s.pool.QueryRow(ctx, ` - SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0) - FROM data_subject_requests WHERE status = 'completed' - `).Scan(&stats.AverageProcessingDays) - - // Count by type - rows, _ := s.pool.Query(ctx, ` - SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type - `) - for rows.Next() { - var t string - var count int - rows.Scan(&t, &count) - stats.ByType[t] = count - } - rows.Close() - - // Count by status - rows, _ = s.pool.Query(ctx, ` - SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status - `) - for rows.Next() { - var s string - var count int - rows.Scan(&s, &count) - stats.ByStatus[s] = count - } - rows.Close() - - // Upcoming deadlines (next 7 days) - rows, _ = s.pool.Query(ctx, ` - SELECT id, request_number, request_type, status, requester_email, deadline_at - FROM data_subject_requests - WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days' - AND status NOT IN ('completed', 'rejected', 'cancelled') - ORDER BY deadline_at ASC LIMIT 10 - `) - for rows.Next() { - var dsr models.DataSubjectRequest - rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt) - stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr) - } - rows.Close() - - return stats, nil -} - -// GetStatusHistory retrieves the status history for a DSR -func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at - FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC - `, requestID) - if err != nil { - return nil, fmt.Errorf("failed to query status history: %w", err) - } - defer rows.Close() - - var history []models.DSRStatusHistory - for rows.Next() { - var h models.DSRStatusHistory - var metadataJSON []byte - err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt) - if err != nil { - continue - } - json.Unmarshal(metadataJSON, &h.Metadata) - history = append(history, h) - } - - return history, nil -} - -// GetCommunications retrieves communications for a DSR -func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, request_id, direction, channel, communication_type, template_version_id, - subject, body_html, body_text, recipient_email, sent_at, error_message, - attachments, created_at, created_by - FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC - `, requestID) - if err != nil { - return nil, fmt.Errorf("failed to query communications: %w", err) - } - defer rows.Close() - - var comms []models.DSRCommunication - for rows.Next() { - var c models.DSRCommunication - var attachmentsJSON []byte - err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType, - &c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail, - &c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy) - if err != nil { - continue - } - json.Unmarshal(attachmentsJSON, &c.Attachments) - comms = append(comms, c) - } - - return comms, nil -} - -// SendCommunication sends a communication for a DSR -func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error { - // Get DSR details - dsr, err := s.GetByID(ctx, requestID) - if err != nil { - return err - } - - // Get template if specified - var subject, bodyHTML, bodyText string - if req.TemplateVersionID != nil { - templateVersionID, _ := uuid.Parse(*req.TemplateVersionID) - err := s.pool.QueryRow(ctx, ` - SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published' - `, templateVersionID).Scan(&subject, &bodyHTML, &bodyText) - if err != nil { - return fmt.Errorf("template version not found or not published: %w", err) - } - } - - // Use custom content if provided - if req.CustomSubject != nil { - subject = *req.CustomSubject - } - if req.CustomBody != nil { - bodyHTML = *req.CustomBody - bodyText = stripHTML(*req.CustomBody) - } - - // Replace variables - variables := map[string]string{ - "requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"), - "request_number": dsr.RequestNumber, - "request_type_de": dsr.RequestType.Label(), - "request_date": dsr.CreatedAt.Format("02.01.2006"), - "deadline_date": dsr.DeadlineAt.Format("02.01.2006"), - } - for k, v := range req.Variables { - variables[k] = v - } - subject = replaceVariables(subject, variables) - bodyHTML = replaceVariables(bodyHTML, variables) - bodyText = replaceVariables(bodyText, variables) - - // Send email - if s.emailService != nil { - err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText) - if err != nil { - // Log error but continue - _, _ = s.pool.Exec(ctx, ` - INSERT INTO dsr_communications (request_id, direction, channel, communication_type, - template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by) - VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9) - `, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText, - dsr.RequesterEmail, err.Error(), sentBy) - return fmt.Errorf("failed to send email: %w", err) - } - } - - // Log communication - now := time.Now() - _, err = s.pool.Exec(ctx, ` - INSERT INTO dsr_communications (request_id, direction, channel, communication_type, - template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by) - VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9) - `, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText, - dsr.RequesterEmail, now, sentBy) - - return err -} - -// InitErasureExceptionChecks initializes exception checks for an erasure request -func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error { - exceptions := []struct { - Type string - Description string - }{ - {models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"}, - {models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"}, - {models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"}, - {models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"}, - {models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"}, - } - - for _, exc := range exceptions { - _, err := s.pool.Exec(ctx, ` - INSERT INTO dsr_exception_checks (request_id, exception_type, description) - VALUES ($1, $2, $3) ON CONFLICT DO NOTHING - `, requestID, exc.Type, exc.Description) - if err != nil { - return fmt.Errorf("failed to create exception check: %w", err) - } - } - - return nil -} - -// GetExceptionChecks retrieves exception checks for a DSR -func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at - FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at - `, requestID) - if err != nil { - return nil, fmt.Errorf("failed to query exception checks: %w", err) - } - defer rows.Close() - - var checks []models.DSRExceptionCheck - for rows.Next() { - var c models.DSRExceptionCheck - err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies, - &c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt) - if err != nil { - continue - } - checks = append(checks, c) - } - - return checks, nil -} - -// UpdateExceptionCheck updates an exception check -func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error { - _, err := s.pool.Exec(ctx, ` - UPDATE dsr_exception_checks - SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW() - WHERE id = $4 - `, applies, notes, checkedBy, checkID) - return err -} - -// ProcessDeadlines checks for approaching and overdue deadlines -func (s *DSRService) ProcessDeadlines(ctx context.Context) error { - now := time.Now() - - // Find requests with deadlines in 3 days - threeDaysAhead := now.AddDate(0, 0, 3) - rows, _ := s.pool.Query(ctx, ` - SELECT id, request_number, request_type, assigned_to, deadline_at - FROM data_subject_requests - WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2 - AND status NOT IN ('completed', 'rejected', 'cancelled') - `, now, threeDaysAhead) - for rows.Next() { - var id uuid.UUID - var requestNumber, requestType string - var assignedTo *uuid.UUID - var deadline time.Time - rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) - - // Notify assigned user or all DPOs - if assignedTo != nil { - s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3) - } else { - s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline) - } - } - rows.Close() - - // Find requests with deadlines in 1 day - oneDayAhead := now.AddDate(0, 0, 1) - rows, _ = s.pool.Query(ctx, ` - SELECT id, request_number, request_type, assigned_to, deadline_at - FROM data_subject_requests - WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2 - AND status NOT IN ('completed', 'rejected', 'cancelled') - `, now, oneDayAhead) - for rows.Next() { - var id uuid.UUID - var requestNumber, requestType string - var assignedTo *uuid.UUID - var deadline time.Time - rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) - - if assignedTo != nil { - s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1) - } else { - s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline) - } - } - rows.Close() - - // Find overdue requests - rows, _ = s.pool.Query(ctx, ` - SELECT id, request_number, request_type, assigned_to, deadline_at - FROM data_subject_requests - WHERE COALESCE(extended_deadline_at, deadline_at) < $1 - AND status NOT IN ('completed', 'rejected', 'cancelled') - `, now) - for rows.Next() { - var id uuid.UUID - var requestNumber, requestType string - var assignedTo *uuid.UUID - var deadline time.Time - rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) - - // Notify all DPOs for overdue - s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline) - - // Log to audit - s.pool.Exec(ctx, ` - INSERT INTO consent_audit_log (action, entity_type, entity_id, details) - VALUES ('dsr_overdue', 'dsr', $1, $2) - `, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339))) - } - rows.Close() - - return nil -} - -// Helper functions +// --- Internal helpers --- func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID, fromStatus *models.DSRStatus, toStatus models.DSRStatus, changedBy *uuid.UUID, comment string) { s.pool.Exec(ctx, ` @@ -824,62 +455,6 @@ func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID `, requestID, fromStatus, toStatus, changedBy, comment) } -func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) { - if s.notificationService == nil { - return - } - // Notify all DPOs - rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'") - defer rows.Close() - for rows.Next() { - var userID uuid.UUID - rows.Scan(&userID) - s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived, - "Neue Betroffenenanfrage", - fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber), - map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber}) - } -} - -func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) { - if s.notificationService == nil { - return - } - dsr, _ := s.GetByID(ctx, dsrID) - if dsr != nil { - s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned, - "Betroffenenanfrage zugewiesen", - fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber), - map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber}) - } -} - -func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) { - if s.notificationService == nil { - return - } - s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline, - fmt.Sprintf("Fristwarnung: %s", requestNumber), - fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")), - map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft}) -} - -func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) { - if s.notificationService == nil { - return - } - rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'") - defer rows.Close() - for rows.Next() { - var userID uuid.UUID - rows.Scan(&userID) - s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline, - fmt.Sprintf("%s: %s", message, requestNumber), - fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")), - map[string]interface{}{"dsr_id": dsrID, "deadline": deadline}) - } -} - func isValidRequestType(rt models.DSRRequestType) bool { switch rt { case models.DSRTypeAccess, models.DSRTypeRectification, models.DSRTypeErasure, @@ -891,12 +466,12 @@ func isValidRequestType(rt models.DSRRequestType) bool { func isValidStatusTransition(from, to models.DSRStatus) bool { validTransitions := map[models.DSRStatus][]models.DSRStatus{ - models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled}, - models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled}, - models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled}, - models.DSRStatusCompleted: {}, - models.DSRStatusRejected: {}, - models.DSRStatusCancelled: {}, + models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled}, + models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled}, + models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled}, + models.DSRStatusCompleted: {}, + models.DSRStatusRejected: {}, + models.DSRStatusCancelled: {}, } allowed, exists := validTransitions[from] @@ -910,38 +485,3 @@ func isValidStatusTransition(from, to models.DSRStatus) bool { } return false } - -func stringOrDefault(s *string, def string) string { - if s != nil { - return *s - } - return def -} - -func replaceVariables(text string, variables map[string]string) string { - for k, v := range variables { - text = strings.ReplaceAll(text, "{{"+k+"}}", v) - } - return text -} - -func stripHTML(html string) string { - // Simple HTML stripping - in production use a proper library - text := strings.ReplaceAll(html, "
", "\n") - text = strings.ReplaceAll(text, "
", "\n") - text = strings.ReplaceAll(text, "
", "\n") - text = strings.ReplaceAll(text, "

", "\n\n") - // Remove all remaining tags - for { - start := strings.Index(text, "<") - if start == -1 { - break - } - end := strings.Index(text[start:], ">") - if end == -1 { - break - } - text = text[:start] + text[start+end+1:] - } - return strings.TrimSpace(text) -} diff --git a/consent-service/internal/services/dsr_service_comms.go b/consent-service/internal/services/dsr_service_comms.go new file mode 100644 index 0000000..92a0398 --- /dev/null +++ b/consent-service/internal/services/dsr_service_comms.go @@ -0,0 +1,208 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" +) + +// GetCommunications retrieves communications for a DSR +func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, direction, channel, communication_type, template_version_id, + subject, body_html, body_text, recipient_email, sent_at, error_message, + attachments, created_at, created_by + FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC + `, requestID) + if err != nil { + return nil, fmt.Errorf("failed to query communications: %w", err) + } + defer rows.Close() + + var comms []models.DSRCommunication + for rows.Next() { + var c models.DSRCommunication + var attachmentsJSON []byte + err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType, + &c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail, + &c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy) + if err != nil { + continue + } + json.Unmarshal(attachmentsJSON, &c.Attachments) + comms = append(comms, c) + } + + return comms, nil +} + +// SendCommunication sends a communication for a DSR +func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error { + // Get DSR details + dsr, err := s.GetByID(ctx, requestID) + if err != nil { + return err + } + + // Get template if specified + var subject, bodyHTML, bodyText string + if req.TemplateVersionID != nil { + templateVersionID, _ := uuid.Parse(*req.TemplateVersionID) + err := s.pool.QueryRow(ctx, ` + SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published' + `, templateVersionID).Scan(&subject, &bodyHTML, &bodyText) + if err != nil { + return fmt.Errorf("template version not found or not published: %w", err) + } + } + + // Use custom content if provided + if req.CustomSubject != nil { + subject = *req.CustomSubject + } + if req.CustomBody != nil { + bodyHTML = *req.CustomBody + bodyText = stripHTML(*req.CustomBody) + } + + // Replace variables + variables := map[string]string{ + "requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"), + "request_number": dsr.RequestNumber, + "request_type_de": dsr.RequestType.Label(), + "request_date": dsr.CreatedAt.Format("02.01.2006"), + "deadline_date": dsr.DeadlineAt.Format("02.01.2006"), + } + for k, v := range req.Variables { + variables[k] = v + } + subject = replaceVariables(subject, variables) + bodyHTML = replaceVariables(bodyHTML, variables) + bodyText = replaceVariables(bodyText, variables) + + // Send email + if s.emailService != nil { + err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText) + if err != nil { + // Log error but continue + _, _ = s.pool.Exec(ctx, ` + INSERT INTO dsr_communications (request_id, direction, channel, communication_type, + template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by) + VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9) + `, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText, + dsr.RequesterEmail, err.Error(), sentBy) + return fmt.Errorf("failed to send email: %w", err) + } + } + + // Log communication + now := time.Now() + _, err = s.pool.Exec(ctx, ` + INSERT INTO dsr_communications (request_id, direction, channel, communication_type, + template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by) + VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9) + `, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText, + dsr.RequesterEmail, now, sentBy) + + return err +} + +// --- Notification helpers --- + +func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) { + if s.notificationService == nil { + return + } + // Notify all DPOs + rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'") + defer rows.Close() + for rows.Next() { + var userID uuid.UUID + rows.Scan(&userID) + s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived, + "Neue Betroffenenanfrage", + fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber), + map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber}) + } +} + +func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) { + if s.notificationService == nil { + return + } + dsr, _ := s.GetByID(ctx, dsrID) + if dsr != nil { + s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned, + "Betroffenenanfrage zugewiesen", + fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber), + map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber}) + } +} + +func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) { + if s.notificationService == nil { + return + } + s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline, + fmt.Sprintf("Fristwarnung: %s", requestNumber), + fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")), + map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft}) +} + +func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) { + if s.notificationService == nil { + return + } + rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'") + defer rows.Close() + for rows.Next() { + var userID uuid.UUID + rows.Scan(&userID) + s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline, + fmt.Sprintf("%s: %s", message, requestNumber), + fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")), + map[string]interface{}{"dsr_id": dsrID, "deadline": deadline}) + } +} + +// --- String utility helpers --- + +func stringOrDefault(s *string, def string) string { + if s != nil { + return *s + } + return def +} + +func replaceVariables(text string, variables map[string]string) string { + for k, v := range variables { + text = strings.ReplaceAll(text, "{{"+k+"}}", v) + } + return text +} + +func stripHTML(html string) string { + // Simple HTML stripping - in production use a proper library + text := strings.ReplaceAll(html, "
", "\n") + text = strings.ReplaceAll(text, "
", "\n") + text = strings.ReplaceAll(text, "
", "\n") + text = strings.ReplaceAll(text, "

", "\n\n") + // Remove all remaining tags + for { + start := strings.Index(text, "<") + if start == -1 { + break + } + end := strings.Index(text[start:], ">") + if end == -1 { + break + } + text = text[:start] + text[start+end+1:] + } + return strings.TrimSpace(text) +} diff --git a/consent-service/internal/services/dsr_service_dashboard.go b/consent-service/internal/services/dsr_service_dashboard.go new file mode 100644 index 0000000..2e0ba46 --- /dev/null +++ b/consent-service/internal/services/dsr_service_dashboard.go @@ -0,0 +1,278 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" +) + +// ExtendDeadline extends the deadline for a DSR +func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error { + // Default extension is 2 months (60 days) per Art. 12(3) + if days <= 0 { + days = 60 + } + + _, err := s.pool.Exec(ctx, ` + UPDATE data_subject_requests + SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL, + extension_reason = $2, + updated_at = NOW() + WHERE id = $3 + `, days, reason, id) + if err != nil { + return fmt.Errorf("failed to extend deadline: %w", err) + } + + s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason)) + + return nil +} + +// GetDashboardStats returns statistics for the admin dashboard +func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) { + stats := &models.DSRDashboardStats{ + ByType: make(map[string]int), + ByStatus: make(map[string]int), + } + + // Total requests + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests) + + // Pending requests (not completed, rejected, or cancelled) + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM data_subject_requests + WHERE status NOT IN ('completed', 'rejected', 'cancelled') + `).Scan(&stats.PendingRequests) + + // Overdue requests + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) < NOW() + AND status NOT IN ('completed', 'rejected', 'cancelled') + `).Scan(&stats.OverdueRequests) + + // Completed this month + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM data_subject_requests + WHERE status = 'completed' + AND completed_at >= DATE_TRUNC('month', NOW()) + `).Scan(&stats.CompletedThisMonth) + + // Average processing days + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0) + FROM data_subject_requests WHERE status = 'completed' + `).Scan(&stats.AverageProcessingDays) + + // Count by type + rows, _ := s.pool.Query(ctx, ` + SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type + `) + for rows.Next() { + var t string + var count int + rows.Scan(&t, &count) + stats.ByType[t] = count + } + rows.Close() + + // Count by status + rows, _ = s.pool.Query(ctx, ` + SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status + `) + for rows.Next() { + var s string + var count int + rows.Scan(&s, &count) + stats.ByStatus[s] = count + } + rows.Close() + + // Upcoming deadlines (next 7 days) + rows, _ = s.pool.Query(ctx, ` + SELECT id, request_number, request_type, status, requester_email, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days' + AND status NOT IN ('completed', 'rejected', 'cancelled') + ORDER BY deadline_at ASC LIMIT 10 + `) + for rows.Next() { + var dsr models.DataSubjectRequest + rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt) + stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr) + } + rows.Close() + + return stats, nil +} + +// GetStatusHistory retrieves the status history for a DSR +func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at + FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC + `, requestID) + if err != nil { + return nil, fmt.Errorf("failed to query status history: %w", err) + } + defer rows.Close() + + var history []models.DSRStatusHistory + for rows.Next() { + var h models.DSRStatusHistory + var metadataJSON []byte + err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt) + if err != nil { + continue + } + json.Unmarshal(metadataJSON, &h.Metadata) + history = append(history, h) + } + + return history, nil +} + +// InitErasureExceptionChecks initializes exception checks for an erasure request +func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error { + exceptions := []struct { + Type string + Description string + }{ + {models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"}, + {models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"}, + {models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"}, + {models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"}, + {models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"}, + } + + for _, exc := range exceptions { + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsr_exception_checks (request_id, exception_type, description) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING + `, requestID, exc.Type, exc.Description) + if err != nil { + return fmt.Errorf("failed to create exception check: %w", err) + } + } + + return nil +} + +// GetExceptionChecks retrieves exception checks for a DSR +func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at + FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at + `, requestID) + if err != nil { + return nil, fmt.Errorf("failed to query exception checks: %w", err) + } + defer rows.Close() + + var checks []models.DSRExceptionCheck + for rows.Next() { + var c models.DSRExceptionCheck + err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies, + &c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt) + if err != nil { + continue + } + checks = append(checks, c) + } + + return checks, nil +} + +// UpdateExceptionCheck updates an exception check +func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE dsr_exception_checks + SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW() + WHERE id = $4 + `, applies, notes, checkedBy, checkID) + return err +} + +// ProcessDeadlines checks for approaching and overdue deadlines +func (s *DSRService) ProcessDeadlines(ctx context.Context) error { + now := time.Now() + + // Find requests with deadlines in 3 days + threeDaysAhead := now.AddDate(0, 0, 3) + rows, _ := s.pool.Query(ctx, ` + SELECT id, request_number, request_type, assigned_to, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2 + AND status NOT IN ('completed', 'rejected', 'cancelled') + `, now, threeDaysAhead) + for rows.Next() { + var id uuid.UUID + var requestNumber, requestType string + var assignedTo *uuid.UUID + var deadline time.Time + rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) + + // Notify assigned user or all DPOs + if assignedTo != nil { + s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3) + } else { + s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline) + } + } + rows.Close() + + // Find requests with deadlines in 1 day + oneDayAhead := now.AddDate(0, 0, 1) + rows, _ = s.pool.Query(ctx, ` + SELECT id, request_number, request_type, assigned_to, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2 + AND status NOT IN ('completed', 'rejected', 'cancelled') + `, now, oneDayAhead) + for rows.Next() { + var id uuid.UUID + var requestNumber, requestType string + var assignedTo *uuid.UUID + var deadline time.Time + rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) + + if assignedTo != nil { + s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1) + } else { + s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline) + } + } + rows.Close() + + // Find overdue requests + rows, _ = s.pool.Query(ctx, ` + SELECT id, request_number, request_type, assigned_to, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) < $1 + AND status NOT IN ('completed', 'rejected', 'cancelled') + `, now) + for rows.Next() { + var id uuid.UUID + var requestNumber, requestType string + var assignedTo *uuid.UUID + var deadline time.Time + rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) + + // Notify all DPOs for overdue + s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline) + + // Log to audit + s.pool.Exec(ctx, ` + INSERT INTO consent_audit_log (action, entity_type, entity_id, details) + VALUES ('dsr_overdue', 'dsr', $1, $2) + `, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339))) + } + rows.Close() + + return nil +} diff --git a/consent-service/internal/services/email_service.go b/consent-service/internal/services/email_service.go index 1d36cd2..228a10d 100644 --- a/consent-service/internal/services/email_service.go +++ b/consent-service/internal/services/email_service.go @@ -3,7 +3,6 @@ package services import ( "bytes" "fmt" - "html/template" "net/smtp" "strings" ) @@ -249,306 +248,3 @@ Ihr BreakPilot Team`, getDisplayName(name), appLink) return s.SendEmail(to, subject, htmlBody, textBody) } -// renderTemplate renders an email HTML template -func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string { - templates := map[string]string{ - "verification": ` - - - - - - - -
-

Willkommen bei BreakPilot!

-
-
-

Hallo {{.Name}},

-

Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.

-

- E-Mail bestätigen -

-

Dieser Link ist 24 Stunden gültig.

-

Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.

-
- - -`, - - "password_reset": ` - - - - - - - -
-

Passwort zurücksetzen

-
-
-

Hallo {{.Name}},

-

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

-

- Passwort zurücksetzen -

-
- Hinweis: Dieser Link ist nur 1 Stunde gültig. -
-

Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.

-
- - -`, - - "new_version": ` - - - - - - - -
-

Neue Version: {{.DocumentName}}

-
-
-

Hallo {{.Name}},

-

Wir haben unsere {{.DocumentName}} aktualisiert.

-
- Wichtig: Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten {{.DeadlineDays}} Tage. -
-

- Dokument ansehen & bestätigen -

-

Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.

-
- - -`, - - "reminder": ` - - - - - - - -
-

{{.Urgency}}: Ausstehende Bestätigungen

-
-
-

Hallo {{.Name}},

-

Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.

-
- Ausstehende Dokumente: -
    - {{range .Documents}}
  • {{.}}
  • {{end}} -
-
-
- Sie haben noch {{.DaysLeft}} Tage Zeit. Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt. -
-

- Jetzt bestätigen -

-
- - -`, - - "suspended": ` - - - - - - - -
-

Account vorübergehend gesperrt

-
-
-

Hallo {{.Name}},

-
- Ihr Account wurde vorübergehend gesperrt, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben. -
-
- Nicht bestätigte Dokumente: -
    - {{range .Documents}}
  • {{.}}
  • {{end}} -
-
-

Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:

-

- Dokumente bestätigen & Account entsperren -

-

Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.

-
- - -`, - - "reactivated": ` - - - - - - - -
-

Account wieder aktiviert!

-
-
-

Hallo {{.Name}},

-
- Vielen Dank! Ihr Account wurde erfolgreich wieder aktiviert. -
-

Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.

-

- Zu BreakPilot -

-
- - -`, - - "generic_notification": ` - - - - - - - -
-

{{.Title}}

-
-
-

{{.Body}}

-

- Zu BreakPilot -

-
- - -`, - } - - tmplStr, ok := templates[templateName] - if !ok { - return "" - } - - tmpl, err := template.New(templateName).Parse(tmplStr) - if err != nil { - return "" - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "" - } - - return buf.String() -} - -// SendConsentReminderEmail sends a simplified consent reminder email -func (s *EmailService) SendConsentReminderEmail(to, title, body string) error { - subject := title - - htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{ - "Title": title, - "Body": body, - "BaseURL": s.config.BaseURL, - }) - - return s.SendEmail(to, subject, htmlBody, body) -} - -// SendGenericNotificationEmail sends a generic notification email -func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error { - subject := title - - htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{ - "Title": title, - "Body": body, - "BaseURL": s.config.BaseURL, - }) - - return s.SendEmail(to, subject, htmlBody, body) -} - -// getDisplayName returns display name or fallback -func getDisplayName(name string) string { - if name != "" { - return name - } - return "Nutzer" -} diff --git a/consent-service/internal/services/email_service_templates.go b/consent-service/internal/services/email_service_templates.go new file mode 100644 index 0000000..acc4c53 --- /dev/null +++ b/consent-service/internal/services/email_service_templates.go @@ -0,0 +1,310 @@ +package services + +import ( + "bytes" + "html/template" +) + +// renderTemplate renders an email HTML template +func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string { + templates := map[string]string{ + "verification": ` + + + + + + + +
+

Willkommen bei BreakPilot!

+
+
+

Hallo {{.Name}},

+

Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.

+

+ E-Mail bestätigen +

+

Dieser Link ist 24 Stunden gültig.

+

Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.

+
+ + +`, + + "password_reset": ` + + + + + + + +
+

Passwort zurücksetzen

+
+
+

Hallo {{.Name}},

+

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

+

+ Passwort zurücksetzen +

+
+ Hinweis: Dieser Link ist nur 1 Stunde gültig. +
+

Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.

+
+ + +`, + + "new_version": ` + + + + + + + +
+

Neue Version: {{.DocumentName}}

+
+
+

Hallo {{.Name}},

+

Wir haben unsere {{.DocumentName}} aktualisiert.

+
+ Wichtig: Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten {{.DeadlineDays}} Tage. +
+

+ Dokument ansehen & bestätigen +

+

Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.

+
+ + +`, + + "reminder": ` + + + + + + + +
+

{{.Urgency}}: Ausstehende Bestätigungen

+
+
+

Hallo {{.Name}},

+

Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.

+
+ Ausstehende Dokumente: +
    + {{range .Documents}}
  • {{.}}
  • {{end}} +
+
+
+ Sie haben noch {{.DaysLeft}} Tage Zeit. Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt. +
+

+ Jetzt bestätigen +

+
+ + +`, + + "suspended": ` + + + + + + + +
+

Account vorübergehend gesperrt

+
+
+

Hallo {{.Name}},

+
+ Ihr Account wurde vorübergehend gesperrt, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben. +
+
+ Nicht bestätigte Dokumente: +
    + {{range .Documents}}
  • {{.}}
  • {{end}} +
+
+

Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:

+

+ Dokumente bestätigen & Account entsperren +

+

Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.

+
+ + +`, + + "reactivated": ` + + + + + + + +
+

Account wieder aktiviert!

+
+
+

Hallo {{.Name}},

+
+ Vielen Dank! Ihr Account wurde erfolgreich wieder aktiviert. +
+

Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.

+

+ Zu BreakPilot +

+
+ + +`, + + "generic_notification": ` + + + + + + + +
+

{{.Title}}

+
+
+

{{.Body}}

+

+ Zu BreakPilot +

+
+ + +`, + } + + tmplStr, ok := templates[templateName] + if !ok { + return "" + } + + tmpl, err := template.New(templateName).Parse(tmplStr) + if err != nil { + return "" + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "" + } + + return buf.String() +} + +// SendConsentReminderEmail sends a simplified consent reminder email +func (s *EmailService) SendConsentReminderEmail(to, title, body string) error { + subject := title + + htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{ + "Title": title, + "Body": body, + "BaseURL": s.config.BaseURL, + }) + + return s.SendEmail(to, subject, htmlBody, body) +} + +// SendGenericNotificationEmail sends a generic notification email +func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error { + subject := title + + htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{ + "Title": title, + "Body": body, + "BaseURL": s.config.BaseURL, + }) + + return s.SendEmail(to, subject, htmlBody, body) +} + +// getDisplayName returns display name or fallback +func getDisplayName(name string) string { + if name != "" { + return name + } + return "Nutzer" +} diff --git a/consent-service/internal/services/email_template_approval.go b/consent-service/internal/services/email_template_approval.go new file mode 100644 index 0000000..48bad1c --- /dev/null +++ b/consent-service/internal/services/email_template_approval.go @@ -0,0 +1,174 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" +) + +// SubmitForReview submits a version for review +func (s *EmailTemplateService) SubmitForReview(ctx context.Context, versionID, submitterID uuid.UUID, comment *string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + // Update status + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions SET status = 'review', updated_at = $1 WHERE id = $2 + `, time.Now(), versionID) + if err != nil { + return fmt.Errorf("failed to update status: %w", err) + } + + // Create approval record + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, uuid.New(), versionID, submitterID, "submitted_for_review", comment, time.Now()) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// ApproveVersion approves a version (DSB) +func (s *EmailTemplateService) ApproveVersion(ctx context.Context, versionID, approverID uuid.UUID, comment *string, scheduledPublishAt *time.Time) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + now := time.Now() + status := "approved" + if scheduledPublishAt != nil { + status = "scheduled" + } + + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions + SET status = $1, approved_by = $2, approved_at = $3, scheduled_publish_at = $4, updated_at = $5 + WHERE id = $6 + `, status, approverID, now, scheduledPublishAt, now, versionID) + if err != nil { + return fmt.Errorf("failed to approve version: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, uuid.New(), versionID, approverID, "approved", comment, now) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// PublishVersion publishes an approved version +func (s *EmailTemplateService) PublishVersion(ctx context.Context, versionID, publisherID uuid.UUID) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + // Get version info to find template and language + var templateID uuid.UUID + var language string + err = tx.QueryRow(ctx, `SELECT template_id, language FROM email_template_versions WHERE id = $1`, versionID).Scan(&templateID, &language) + if err != nil { + return fmt.Errorf("failed to get version info: %w", err) + } + + // Archive old published versions for same template and language + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions + SET status = 'archived', updated_at = $1 + WHERE template_id = $2 AND language = $3 AND status = 'published' + `, time.Now(), templateID, language) + if err != nil { + return fmt.Errorf("failed to archive old versions: %w", err) + } + + // Publish the new version + now := time.Now() + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions + SET status = 'published', published_at = $1, updated_at = $2 + WHERE id = $3 + `, now, now, versionID) + if err != nil { + return fmt.Errorf("failed to publish version: %w", err) + } + + // Create approval record + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, created_at) + VALUES ($1, $2, $3, $4, $5) + `, uuid.New(), versionID, publisherID, "published", now) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// RejectVersion rejects a version +func (s *EmailTemplateService) RejectVersion(ctx context.Context, versionID, rejectorID uuid.UUID, comment string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + now := time.Now() + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions SET status = 'draft', updated_at = $1 WHERE id = $2 + `, now, versionID) + if err != nil { + return fmt.Errorf("failed to reject version: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, uuid.New(), versionID, rejectorID, "rejected", &comment, now) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// GetApprovals returns approval history for a version +func (s *EmailTemplateService) GetApprovals(ctx context.Context, versionID uuid.UUID) ([]models.EmailTemplateApproval, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, version_id, approver_id, action, comment, created_at + FROM email_template_approvals + WHERE version_id = $1 + ORDER BY created_at DESC + `, versionID) + if err != nil { + return nil, fmt.Errorf("failed to get approvals: %w", err) + } + defer rows.Close() + + var approvals []models.EmailTemplateApproval + for rows.Next() { + var a models.EmailTemplateApproval + err := rows.Scan(&a.ID, &a.VersionID, &a.ApproverID, &a.Action, &a.Comment, &a.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan approval: %w", err) + } + approvals = append(approvals, a) + } + + return approvals, nil +} diff --git a/consent-service/internal/services/email_template_defaults_auth.go b/consent-service/internal/services/email_template_defaults_auth.go new file mode 100644 index 0000000..6150dd9 --- /dev/null +++ b/consent-service/internal/services/email_template_defaults_auth.go @@ -0,0 +1,376 @@ +package services + +// Default German email templates for authentication and security events. +// Templates: Welcome, Email Verification, Password Reset, Password Changed, +// 2FA Enabled, 2FA Disabled, New Device Login, Suspicious Activity, +// Account Locked, Account Unlocked. + +func (s *EmailTemplateService) getWelcomeTemplateDE() (string, string, string) { + subject := "Willkommen bei BreakPilot!" + bodyHTML := ` + + + +
+

Willkommen bei BreakPilot!

+

Hallo {{user_name}},

+

vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.

+

Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden:

+

+ Jetzt anmelden +

+

Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Willkommen bei BreakPilot! + +Hallo {{user_name}}, + +vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt. + +Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden: +{{login_url}} + +Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getEmailVerificationTemplateDE() (string, string, string) { + subject := "Bitte bestätigen Sie Ihre E-Mail-Adresse" + bodyHTML := ` + + + +
+

E-Mail-Adresse bestätigen

+

Hallo {{user_name}},

+

bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:

+

+ E-Mail bestätigen +

+

Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}}

+

Hinweis: Dieser Link ist nur {{expires_in}} gültig.

+

Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `E-Mail-Adresse bestätigen + +Hallo {{user_name}}, + +bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken: +{{verification_url}} + +Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}} + +Hinweis: Dieser Link ist nur {{expires_in}} gültig. + +Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getPasswordResetTemplateDE() (string, string, string) { + subject := "Passwort zurücksetzen" + bodyHTML := ` + + + +
+

Passwort zurücksetzen

+

Hallo {{user_name}},

+

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:

+

+ Neues Passwort festlegen +

+

Alternativ können Sie auch diesen Code verwenden: {{reset_code}}

+

Hinweis: Dieser Link ist nur {{expires_in}} gültig.

+

+ Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert. +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Passwort zurücksetzen + +Hallo {{user_name}}, + +Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen: +{{reset_url}} + +Alternativ können Sie auch diesen Code verwenden: {{reset_code}} + +Hinweis: Dieser Link ist nur {{expires_in}} gültig. + +Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getPasswordChangedTemplateDE() (string, string, string) { + subject := "Ihr Passwort wurde geändert" + bodyHTML := ` + + + +
+

Passwort geändert

+

Hallo {{user_name}},

+

Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.

+

Details:

+
    +
  • IP-Adresse: {{ip_address}}
  • +
  • Gerät: {{device_info}}
  • +
+

+ Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Passwort geändert + +Hallo {{user_name}}, + +Ihr Passwort wurde am {{changed_at}} erfolgreich geändert. + +Details: +- IP-Adresse: {{ip_address}} +- Gerät: {{device_info}} + +Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) get2FAEnabledTemplateDE() (string, string, string) { + subject := "Zwei-Faktor-Authentifizierung aktiviert" + bodyHTML := ` + + + +
+

2FA aktiviert

+

Hallo {{user_name}},

+

Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.

+

Gerät: {{device_info}}

+

+ Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren. +

+

Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `2FA aktiviert + +Hallo {{user_name}}, + +Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert. + +Gerät: {{device_info}} + +Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren. + +Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) get2FADisabledTemplateDE() (string, string, string) { + subject := "Zwei-Faktor-Authentifizierung deaktiviert" + bodyHTML := ` + + + +
+

2FA deaktiviert

+

Hallo {{user_name}},

+

Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.

+

IP-Adresse: {{ip_address}}

+

+ Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren. +

+

Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `2FA deaktiviert + +Hallo {{user_name}}, + +Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert. + +IP-Adresse: {{ip_address}} + +Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren. + +Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getNewDeviceLoginTemplateDE() (string, string, string) { + subject := "Neuer Login auf Ihrem Konto" + bodyHTML := ` + + + +
+

Neuer Login erkannt

+

Hallo {{user_name}},

+

Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:

+
    +
  • Zeitpunkt: {{login_time}}
  • +
  • IP-Adresse: {{ip_address}}
  • +
  • Gerät: {{device_info}}
  • +
  • Standort: {{location}}
  • +
+

+ Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}. +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Neuer Login erkannt + +Hallo {{user_name}}, + +Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt: + +- Zeitpunkt: {{login_time}} +- IP-Adresse: {{ip_address}} +- Gerät: {{device_info}} +- Standort: {{location}} + +Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getSuspiciousActivityTemplateDE() (string, string, string) { + subject := "Verdächtige Aktivität auf Ihrem Konto" + bodyHTML := ` + + + +
+

Verdächtige Aktivität erkannt

+

Hallo {{user_name}},

+

Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:

+
    +
  • Art: {{activity_type}}
  • +
  • Zeitpunkt: {{activity_time}}
  • +
  • IP-Adresse: {{ip_address}}
  • +
+

+ Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben. +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Verdächtige Aktivität erkannt + +Hallo {{user_name}}, + +Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt: + +- Art: {{activity_type}} +- Zeitpunkt: {{activity_time}} +- IP-Adresse: {{ip_address}} + +Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getAccountLockedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde gesperrt" + bodyHTML := ` + + + +
+

Konto gesperrt

+

Hallo {{user_name}},

+

Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:

+

+ {{reason}} +

+

Ihr Konto wird automatisch entsperrt am: {{unlock_time}}

+

Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Konto gesperrt + +Hallo {{user_name}}, + +Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt: + +{{reason}} + +Ihr Konto wird automatisch entsperrt am: {{unlock_time}} + +Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getAccountUnlockedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde entsperrt" + bodyHTML := ` + + + +
+

Konto entsperrt

+

Hallo {{user_name}},

+

Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.

+

+ Jetzt anmelden +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Konto entsperrt + +Hallo {{user_name}}, + +Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt. + +Sie können sich jetzt wieder anmelden: {{login_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} diff --git a/consent-service/internal/services/email_template_defaults_lifecycle.go b/consent-service/internal/services/email_template_defaults_lifecycle.go new file mode 100644 index 0000000..e6523f4 --- /dev/null +++ b/consent-service/internal/services/email_template_defaults_lifecycle.go @@ -0,0 +1,301 @@ +package services + +// Default German email templates for account lifecycle and consent events. +// Templates: Deletion Requested, Deletion Confirmed, Data Export Ready, +// Email Changed, New Version Published, Consent Reminder, +// Consent Deadline Warning, Account Suspended. + +func (s *EmailTemplateService) getDeletionRequestedTemplateDE() (string, string, string) { + subject := "Bestätigung: Kontolöschung angefordert" + bodyHTML := ` + + + +
+

Kontolöschung angefordert

+

Hallo {{user_name}},

+

Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.

+

+ Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht. +

+

Folgende Daten werden gelöscht:

+

{{data_info}}

+

Sie können die Löschung bis zum genannten Datum abbrechen:

+

+ Löschung abbrechen +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Kontolöschung angefordert + +Hallo {{user_name}}, + +Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt. + +Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht. + +Folgende Daten werden gelöscht: +{{data_info}} + +Sie können die Löschung bis zum genannten Datum abbrechen: {{cancel_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getDeletionConfirmedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde gelöscht" + bodyHTML := ` + + + +
+

Konto gelöscht

+

Hallo {{user_name}},

+

Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.

+

Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten:

+

+ Feedback geben +

+

Vielen Dank für Ihre Zeit bei BreakPilot.

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Konto gelöscht + +Hallo {{user_name}}, + +Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht. + +Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten: {{feedback_url}} + +Vielen Dank für Ihre Zeit bei BreakPilot. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getDataExportReadyTemplateDE() (string, string, string) { + subject := "Ihr Datenexport ist bereit" + bodyHTML := ` + + + +
+

Datenexport bereit

+

Hallo {{user_name}},

+

Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit.

+

+ Daten herunterladen ({{file_size}}) +

+

+ Hinweis: Der Download-Link ist nur {{expires_in}} gültig. +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Datenexport bereit + +Hallo {{user_name}}, + +Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit: +{{download_url}} + +Dateigröße: {{file_size}} + +Hinweis: Der Download-Link ist nur {{expires_in}} gültig. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getEmailChangedTemplateDE() (string, string, string) { + subject := "Ihre E-Mail-Adresse wurde geändert" + bodyHTML := ` + + + +
+

E-Mail-Adresse geändert

+

Hallo {{user_name}},

+

Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.

+
    +
  • Alte Adresse: {{old_email}}
  • +
  • Neue Adresse: {{new_email}}
  • +
+

+ Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `E-Mail-Adresse geändert + +Hallo {{user_name}}, + +Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert. + +- Alte Adresse: {{old_email}} +- Neue Adresse: {{new_email}} + +Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getNewVersionPublishedTemplateDE() (string, string, string) { + subject := "Neue Version: {{document_name}} - Ihre Zustimmung ist erforderlich" + bodyHTML := ` + + + +
+

Neue Dokumentversion

+

Hallo {{user_name}},

+

Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht.

+

Version: {{version}}

+

+ Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}. +

+

+ Jetzt prüfen und zustimmen +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Neue Dokumentversion + +Hallo {{user_name}}, + +Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht. + +Version: {{version}} + +Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}. + +Jetzt prüfen und zustimmen: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getConsentReminderTemplateDE() (string, string, string) { + subject := "Erinnerung: Zustimmung zu {{document_name}} erforderlich" + bodyHTML := ` + + + +
+

Erinnerung: Zustimmung erforderlich

+

Hallo {{user_name}},

+

Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht.

+

+ Noch {{days_left}} Tage bis zur Frist am {{deadline}}. +

+

+ Jetzt zustimmen +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Erinnerung: Zustimmung erforderlich + +Hallo {{user_name}}, + +Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht. + +Noch {{days_left}} Tage bis zur Frist am {{deadline}}. + +Jetzt zustimmen: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getConsentDeadlineWarningTemplateDE() (string, string, string) { + subject := "DRINGEND: Zustimmung zu {{document_name}} läuft bald ab" + bodyHTML := ` + + + +
+

Dringende Erinnerung

+

Hallo {{user_name}},

+

Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab!

+

+ Wichtig: {{consequences}} +

+

+ Sofort zustimmen +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Dringende Erinnerung + +Hallo {{user_name}}, + +Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab! + +Wichtig: {{consequences}} + +Sofort zustimmen: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getAccountSuspendedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde suspendiert" + bodyHTML := ` + + + +
+

Konto suspendiert

+

Hallo {{user_name}},

+

Ihr Konto wurde am {{suspended_at}} suspendiert.

+

Grund: {{reason}}

+

Fehlende Zustimmungen:

+

{{documents}}

+

Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu:

+

+ Konto reaktivieren +

+

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

+
+ +` + bodyText := `Konto suspendiert + +Hallo {{user_name}}, + +Ihr Konto wurde am {{suspended_at}} suspendiert. + +Grund: {{reason}} + +Fehlende Zustimmungen: +{{documents}} + +Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} diff --git a/consent-service/internal/services/email_template_render.go b/consent-service/internal/services/email_template_render.go new file mode 100644 index 0000000..196693d --- /dev/null +++ b/consent-service/internal/services/email_template_render.go @@ -0,0 +1,273 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" +) + +// RenderTemplate renders a template with variables +func (s *EmailTemplateService) RenderTemplate(version *models.EmailTemplateVersion, variables map[string]string) (*models.EmailPreviewResponse, error) { + subject := version.Subject + bodyHTML := version.BodyHTML + bodyText := version.BodyText + + // Replace variables in format {{variable_name}} + re := regexp.MustCompile(`\{\{(\w+)\}\}`) + + replaceFunc := func(content string) string { + return re.ReplaceAllStringFunc(content, func(match string) string { + varName := strings.Trim(match, "{}") + if val, ok := variables[varName]; ok { + return val + } + return match // Keep placeholder if variable not provided + }) + } + + return &models.EmailPreviewResponse{ + Subject: replaceFunc(subject), + BodyHTML: replaceFunc(bodyHTML), + BodyText: replaceFunc(bodyText), + }, nil +} + +// LogEmailSend logs a sent email +func (s *EmailTemplateService) LogEmailSend(ctx context.Context, log *models.EmailSendLog) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO email_send_logs + (id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, log.ID, log.UserID, log.VersionID, log.Recipient, log.Subject, log.Status, + log.ErrorMsg, log.Variables, log.SentAt, log.CreatedAt) + if err != nil { + return fmt.Errorf("failed to log email send: %w", err) + } + return nil +} + +// GetEmailStats returns email statistics +func (s *EmailTemplateService) GetEmailStats(ctx context.Context) (*models.EmailStats, error) { + var stats models.EmailStats + + err := s.db.QueryRow(ctx, ` + SELECT + COUNT(*) as total_sent, + COUNT(*) FILTER (WHERE status = 'delivered') as delivered, + COUNT(*) FILTER (WHERE status = 'bounced') as bounced, + COUNT(*) FILTER (WHERE status = 'failed') as failed, + COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') as recent_sent + FROM email_send_logs + `).Scan(&stats.TotalSent, &stats.Delivered, &stats.Bounced, &stats.Failed, &stats.RecentSent) + if err != nil { + return nil, fmt.Errorf("failed to get email stats: %w", err) + } + + if stats.TotalSent > 0 { + stats.DeliveryRate = float64(stats.Delivered) / float64(stats.TotalSent) * 100 + } + + return &stats, nil +} + +// GetDefaultTemplateContent returns default content for a template type +func (s *EmailTemplateService) GetDefaultTemplateContent(templateType, language string) (subject, bodyHTML, bodyText string) { + // Default templates in German + if language == "de" { + switch templateType { + case models.EmailTypeWelcome: + return s.getWelcomeTemplateDE() + case models.EmailTypeEmailVerification: + return s.getEmailVerificationTemplateDE() + case models.EmailTypePasswordReset: + return s.getPasswordResetTemplateDE() + case models.EmailTypePasswordChanged: + return s.getPasswordChangedTemplateDE() + case models.EmailType2FAEnabled: + return s.get2FAEnabledTemplateDE() + case models.EmailType2FADisabled: + return s.get2FADisabledTemplateDE() + case models.EmailTypeNewDeviceLogin: + return s.getNewDeviceLoginTemplateDE() + case models.EmailTypeSuspiciousActivity: + return s.getSuspiciousActivityTemplateDE() + case models.EmailTypeAccountLocked: + return s.getAccountLockedTemplateDE() + case models.EmailTypeAccountUnlocked: + return s.getAccountUnlockedTemplateDE() + case models.EmailTypeDeletionRequested: + return s.getDeletionRequestedTemplateDE() + case models.EmailTypeDeletionConfirmed: + return s.getDeletionConfirmedTemplateDE() + case models.EmailTypeDataExportReady: + return s.getDataExportReadyTemplateDE() + case models.EmailTypeEmailChanged: + return s.getEmailChangedTemplateDE() + case models.EmailTypeNewVersionPublished: + return s.getNewVersionPublishedTemplateDE() + case models.EmailTypeConsentReminder: + return s.getConsentReminderTemplateDE() + case models.EmailTypeConsentDeadlineWarning: + return s.getConsentDeadlineWarningTemplateDE() + case models.EmailTypeAccountSuspended: + return s.getAccountSuspendedTemplateDE() + } + } + + // Default English fallback + return "No template", "

No template available

", "No template available" +} + +// InitDefaultTemplates creates default email templates if they don't exist +func (s *EmailTemplateService) InitDefaultTemplates(ctx context.Context) error { + templateTypes := []struct { + Type string + Name string + Description string + SortOrder int + }{ + {models.EmailTypeWelcome, "Willkommens-E-Mail", "Wird nach erfolgreicher Registrierung gesendet", 1}, + {models.EmailTypeEmailVerification, "E-Mail-Verifizierung", "Enthält Link zur E-Mail-Bestätigung", 2}, + {models.EmailTypePasswordReset, "Passwort zurücksetzen", "Enthält Link zum Passwort-Reset", 3}, + {models.EmailTypePasswordChanged, "Passwort geändert", "Bestätigung der Passwortänderung", 4}, + {models.EmailType2FAEnabled, "2FA aktiviert", "Bestätigung der 2FA-Aktivierung", 5}, + {models.EmailType2FADisabled, "2FA deaktiviert", "Warnung über 2FA-Deaktivierung", 6}, + {models.EmailTypeNewDeviceLogin, "Neuer Login", "Benachrichtigung über Login von neuem Gerät", 7}, + {models.EmailTypeSuspiciousActivity, "Verdächtige Aktivität", "Warnung über verdächtige Kontoaktivität", 8}, + {models.EmailTypeAccountLocked, "Konto gesperrt", "Benachrichtigung über Kontosperrung", 9}, + {models.EmailTypeAccountUnlocked, "Konto entsperrt", "Bestätigung der Kontoentsperrung", 10}, + {models.EmailTypeDeletionRequested, "Löschung angefordert", "Bestätigung der Löschanfrage", 11}, + {models.EmailTypeDeletionConfirmed, "Löschung bestätigt", "Bestätigung der Kontolöschung", 12}, + {models.EmailTypeDataExportReady, "Datenexport bereit", "Benachrichtigung über fertigen Datenexport", 13}, + {models.EmailTypeEmailChanged, "E-Mail geändert", "Bestätigung der E-Mail-Änderung", 14}, + {models.EmailTypeNewVersionPublished, "Neue Version veröffentlicht", "Benachrichtigung über neue Dokumentversion", 15}, + {models.EmailTypeConsentReminder, "Zustimmungs-Erinnerung", "Erinnerung an ausstehende Zustimmung", 16}, + {models.EmailTypeConsentDeadlineWarning, "Frist-Warnung", "Dringende Warnung vor ablaufender Frist", 17}, + {models.EmailTypeAccountSuspended, "Konto suspendiert", "Benachrichtigung über Kontosuspendierung", 18}, + } + + for _, tt := range templateTypes { + // Check if template exists + var exists bool + err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM email_templates WHERE type = $1)`, tt.Type).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check template existence: %w", err) + } + + if !exists { + desc := tt.Description + _, err = s.db.Exec(ctx, ` + INSERT INTO email_templates (id, type, name, description, is_active, sort_order, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, uuid.New(), tt.Type, tt.Name, &desc, true, tt.SortOrder, time.Now(), time.Now()) + if err != nil { + return fmt.Errorf("failed to create template %s: %w", tt.Type, err) + } + + // Create default German version + template, err := s.GetTemplateByType(ctx, tt.Type) + if err != nil { + return fmt.Errorf("failed to get template %s: %w", tt.Type, err) + } + + subject, bodyHTML, bodyText := s.GetDefaultTemplateContent(tt.Type, "de") + _, err = s.db.Exec(ctx, ` + INSERT INTO email_template_versions + (id, template_id, version, language, subject, body_html, body_text, status, published_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, uuid.New(), template.ID, "1.0.0", "de", subject, bodyHTML, bodyText, "published", time.Now(), time.Now(), time.Now()) + if err != nil { + return fmt.Errorf("failed to create template version %s: %w", tt.Type, err) + } + } + } + + return nil +} + +// GetSendLogs returns email send logs with optional filtering +func (s *EmailTemplateService) GetSendLogs(ctx context.Context, limit, offset int) ([]models.EmailSendLog, int, error) { + var total int + err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM email_send_logs`).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count send logs: %w", err) + } + + rows, err := s.db.Query(ctx, ` + SELECT id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, delivered_at, created_at + FROM email_send_logs + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get send logs: %w", err) + } + defer rows.Close() + + var logs []models.EmailSendLog + for rows.Next() { + var log models.EmailSendLog + err := rows.Scan(&log.ID, &log.UserID, &log.VersionID, &log.Recipient, &log.Subject, + &log.Status, &log.ErrorMsg, &log.Variables, &log.SentAt, &log.DeliveredAt, &log.CreatedAt) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan send log: %w", err) + } + logs = append(logs, log) + } + + return logs, total, nil +} + +// SendEmail sends an email using the specified template (stub - actual sending would use SMTP) +func (s *EmailTemplateService) SendEmail(ctx context.Context, templateType, language, recipient string, variables map[string]string, userID *uuid.UUID) error { + // Get published version + version, err := s.GetPublishedVersion(ctx, templateType, language) + if err != nil { + return fmt.Errorf("failed to get published version: %w", err) + } + + // Render template + rendered, err := s.RenderTemplate(version, variables) + if err != nil { + return fmt.Errorf("failed to render template: %w", err) + } + + // Log the send attempt + variablesJSON, _ := json.Marshal(variables) + now := time.Now() + sendLog := &models.EmailSendLog{ + ID: uuid.New(), + UserID: userID, + VersionID: version.ID, + Recipient: recipient, + Subject: rendered.Subject, + Status: "queued", + Variables: ptr(string(variablesJSON)), + CreatedAt: now, + } + + if err := s.LogEmailSend(ctx, sendLog); err != nil { + return fmt.Errorf("failed to log email send: %w", err) + } + + // TODO: Actual email sending via SMTP would go here + // For now, we just log it as "sent" + _, err = s.db.Exec(ctx, ` + UPDATE email_send_logs SET status = 'sent', sent_at = $1 WHERE id = $2 + `, now, sendLog.ID) + if err != nil { + return fmt.Errorf("failed to update send log status: %w", err) + } + + return nil +} + +func ptr(s string) *string { + return &s +} diff --git a/consent-service/internal/services/email_template_service.go b/consent-service/internal/services/email_template_service.go index afc50e7..84a3590 100644 --- a/consent-service/internal/services/email_template_service.go +++ b/consent-service/internal/services/email_template_service.go @@ -2,10 +2,7 @@ package services import ( "context" - "encoding/json" "fmt" - "regexp" - "strings" "time" "github.com/breakpilot/consent-service/internal/models" @@ -23,213 +20,6 @@ func NewEmailTemplateService(db *pgxpool.Pool) *EmailTemplateService { return &EmailTemplateService{db: db} } -// GetAllTemplateTypes returns all available email template types with their variables -func (s *EmailTemplateService) GetAllTemplateTypes() []models.EmailTemplateVariables { - return []models.EmailTemplateVariables{ - { - TemplateType: models.EmailTypeWelcome, - Variables: []string{"user_name", "user_email", "login_url", "support_email"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "user_email": "E-Mail-Adresse des Benutzers", - "login_url": "URL zur Login-Seite", - "support_email": "Support E-Mail-Adresse", - }, - }, - { - TemplateType: models.EmailTypeEmailVerification, - Variables: []string{"user_name", "verification_url", "verification_code", "expires_in"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "verification_url": "URL zur E-Mail-Verifizierung", - "verification_code": "Verifizierungscode", - "expires_in": "Gültigkeit des Links (z.B. '24 Stunden')", - }, - }, - { - TemplateType: models.EmailTypePasswordReset, - Variables: []string{"user_name", "reset_url", "reset_code", "expires_in", "ip_address"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "reset_url": "URL zum Passwort-Reset", - "reset_code": "Reset-Code", - "expires_in": "Gültigkeit des Links", - "ip_address": "IP-Adresse der Anfrage", - }, - }, - { - TemplateType: models.EmailTypePasswordChanged, - Variables: []string{"user_name", "changed_at", "ip_address", "device_info", "support_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "changed_at": "Zeitpunkt der Änderung", - "ip_address": "IP-Adresse", - "device_info": "Geräte-Informationen", - "support_url": "URL zum Support", - }, - }, - { - TemplateType: models.EmailType2FAEnabled, - Variables: []string{"user_name", "enabled_at", "device_info", "security_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "enabled_at": "Zeitpunkt der Aktivierung", - "device_info": "Geräte-Informationen", - "security_url": "URL zu Sicherheitseinstellungen", - }, - }, - { - TemplateType: models.EmailType2FADisabled, - Variables: []string{"user_name", "disabled_at", "ip_address", "security_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "disabled_at": "Zeitpunkt der Deaktivierung", - "ip_address": "IP-Adresse", - "security_url": "URL zu Sicherheitseinstellungen", - }, - }, - { - TemplateType: models.EmailTypeNewDeviceLogin, - Variables: []string{"user_name", "login_time", "ip_address", "device_info", "location", "security_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "login_time": "Zeitpunkt des Logins", - "ip_address": "IP-Adresse", - "device_info": "Geräte-Informationen", - "location": "Ungefährer Standort", - "security_url": "URL zu Sicherheitseinstellungen", - }, - }, - { - TemplateType: models.EmailTypeSuspiciousActivity, - Variables: []string{"user_name", "activity_type", "activity_time", "ip_address", "security_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "activity_type": "Art der Aktivität", - "activity_time": "Zeitpunkt", - "ip_address": "IP-Adresse", - "security_url": "URL zu Sicherheitseinstellungen", - }, - }, - { - TemplateType: models.EmailTypeAccountLocked, - Variables: []string{"user_name", "locked_at", "reason", "unlock_time", "support_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "locked_at": "Zeitpunkt der Sperrung", - "reason": "Grund der Sperrung", - "unlock_time": "Zeitpunkt der automatischen Entsperrung", - "support_url": "URL zum Support", - }, - }, - { - TemplateType: models.EmailTypeAccountUnlocked, - Variables: []string{"user_name", "unlocked_at", "login_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "unlocked_at": "Zeitpunkt der Entsperrung", - "login_url": "URL zur Login-Seite", - }, - }, - { - TemplateType: models.EmailTypeDeletionRequested, - Variables: []string{"user_name", "requested_at", "deletion_date", "cancel_url", "data_info"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "requested_at": "Zeitpunkt der Anfrage", - "deletion_date": "Datum der endgültigen Löschung", - "cancel_url": "URL zum Abbrechen", - "data_info": "Info über zu löschende Daten", - }, - }, - { - TemplateType: models.EmailTypeDeletionConfirmed, - Variables: []string{"user_name", "deleted_at", "feedback_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "deleted_at": "Zeitpunkt der Löschung", - "feedback_url": "URL für Feedback", - }, - }, - { - TemplateType: models.EmailTypeDataExportReady, - Variables: []string{"user_name", "download_url", "expires_in", "file_size"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "download_url": "URL zum Download", - "expires_in": "Gültigkeit des Download-Links", - "file_size": "Dateigröße", - }, - }, - { - TemplateType: models.EmailTypeEmailChanged, - Variables: []string{"user_name", "old_email", "new_email", "changed_at", "support_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "old_email": "Alte E-Mail-Adresse", - "new_email": "Neue E-Mail-Adresse", - "changed_at": "Zeitpunkt der Änderung", - "support_url": "URL zum Support", - }, - }, - { - TemplateType: models.EmailTypeEmailChangeVerify, - Variables: []string{"user_name", "new_email", "verification_url", "expires_in"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "new_email": "Neue E-Mail-Adresse", - "verification_url": "URL zur Verifizierung", - "expires_in": "Gültigkeit des Links", - }, - }, - { - TemplateType: models.EmailTypeNewVersionPublished, - Variables: []string{"user_name", "document_name", "document_type", "version", "consent_url", "deadline"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "document_name": "Name des Dokuments", - "document_type": "Typ des Dokuments", - "version": "Versionsnummer", - "consent_url": "URL zur Zustimmung", - "deadline": "Frist für die Zustimmung", - }, - }, - { - TemplateType: models.EmailTypeConsentReminder, - Variables: []string{"user_name", "document_name", "days_left", "consent_url", "deadline"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "document_name": "Name des Dokuments", - "days_left": "Verbleibende Tage", - "consent_url": "URL zur Zustimmung", - "deadline": "Frist für die Zustimmung", - }, - }, - { - TemplateType: models.EmailTypeConsentDeadlineWarning, - Variables: []string{"user_name", "document_name", "hours_left", "consent_url", "consequences"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "document_name": "Name des Dokuments", - "hours_left": "Verbleibende Stunden", - "consent_url": "URL zur Zustimmung", - "consequences": "Konsequenzen bei Nicht-Zustimmung", - }, - }, - { - TemplateType: models.EmailTypeAccountSuspended, - Variables: []string{"user_name", "suspended_at", "reason", "documents", "consent_url"}, - Descriptions: map[string]string{ - "user_name": "Name des Benutzers", - "suspended_at": "Zeitpunkt der Suspendierung", - "reason": "Grund der Suspendierung", - "documents": "Liste der fehlenden Zustimmungen", - "consent_url": "URL zur Zustimmung", - }, - }, - } -} - // CreateEmailTemplate creates a new email template type func (s *EmailTemplateService) CreateEmailTemplate(ctx context.Context, req *models.CreateEmailTemplateRequest) (*models.EmailTemplate, error) { template := &models.EmailTemplate{ @@ -495,1179 +285,3 @@ func (s *EmailTemplateService) UpdateVersion(ctx context.Context, id uuid.UUID, } return nil } - -// SubmitForReview submits a version for review -func (s *EmailTemplateService) SubmitForReview(ctx context.Context, versionID, submitterID uuid.UUID, comment *string) error { - tx, err := s.db.Begin(ctx) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback(ctx) - - // Update status - _, err = tx.Exec(ctx, ` - UPDATE email_template_versions SET status = 'review', updated_at = $1 WHERE id = $2 - `, time.Now(), versionID) - if err != nil { - return fmt.Errorf("failed to update status: %w", err) - } - - // Create approval record - _, err = tx.Exec(ctx, ` - INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, uuid.New(), versionID, submitterID, "submitted_for_review", comment, time.Now()) - if err != nil { - return fmt.Errorf("failed to create approval record: %w", err) - } - - return tx.Commit(ctx) -} - -// ApproveVersion approves a version (DSB) -func (s *EmailTemplateService) ApproveVersion(ctx context.Context, versionID, approverID uuid.UUID, comment *string, scheduledPublishAt *time.Time) error { - tx, err := s.db.Begin(ctx) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback(ctx) - - now := time.Now() - status := "approved" - if scheduledPublishAt != nil { - status = "scheduled" - } - - _, err = tx.Exec(ctx, ` - UPDATE email_template_versions - SET status = $1, approved_by = $2, approved_at = $3, scheduled_publish_at = $4, updated_at = $5 - WHERE id = $6 - `, status, approverID, now, scheduledPublishAt, now, versionID) - if err != nil { - return fmt.Errorf("failed to approve version: %w", err) - } - - _, err = tx.Exec(ctx, ` - INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, uuid.New(), versionID, approverID, "approved", comment, now) - if err != nil { - return fmt.Errorf("failed to create approval record: %w", err) - } - - return tx.Commit(ctx) -} - -// PublishVersion publishes an approved version -func (s *EmailTemplateService) PublishVersion(ctx context.Context, versionID, publisherID uuid.UUID) error { - tx, err := s.db.Begin(ctx) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback(ctx) - - // Get version info to find template and language - var templateID uuid.UUID - var language string - err = tx.QueryRow(ctx, `SELECT template_id, language FROM email_template_versions WHERE id = $1`, versionID).Scan(&templateID, &language) - if err != nil { - return fmt.Errorf("failed to get version info: %w", err) - } - - // Archive old published versions for same template and language - _, err = tx.Exec(ctx, ` - UPDATE email_template_versions - SET status = 'archived', updated_at = $1 - WHERE template_id = $2 AND language = $3 AND status = 'published' - `, time.Now(), templateID, language) - if err != nil { - return fmt.Errorf("failed to archive old versions: %w", err) - } - - // Publish the new version - now := time.Now() - _, err = tx.Exec(ctx, ` - UPDATE email_template_versions - SET status = 'published', published_at = $1, updated_at = $2 - WHERE id = $3 - `, now, now, versionID) - if err != nil { - return fmt.Errorf("failed to publish version: %w", err) - } - - // Create approval record - _, err = tx.Exec(ctx, ` - INSERT INTO email_template_approvals (id, version_id, approver_id, action, created_at) - VALUES ($1, $2, $3, $4, $5) - `, uuid.New(), versionID, publisherID, "published", now) - if err != nil { - return fmt.Errorf("failed to create approval record: %w", err) - } - - return tx.Commit(ctx) -} - -// RejectVersion rejects a version -func (s *EmailTemplateService) RejectVersion(ctx context.Context, versionID, rejectorID uuid.UUID, comment string) error { - tx, err := s.db.Begin(ctx) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback(ctx) - - now := time.Now() - _, err = tx.Exec(ctx, ` - UPDATE email_template_versions SET status = 'draft', updated_at = $1 WHERE id = $2 - `, now, versionID) - if err != nil { - return fmt.Errorf("failed to reject version: %w", err) - } - - _, err = tx.Exec(ctx, ` - INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, uuid.New(), versionID, rejectorID, "rejected", &comment, now) - if err != nil { - return fmt.Errorf("failed to create approval record: %w", err) - } - - return tx.Commit(ctx) -} - -// GetApprovals returns approval history for a version -func (s *EmailTemplateService) GetApprovals(ctx context.Context, versionID uuid.UUID) ([]models.EmailTemplateApproval, error) { - rows, err := s.db.Query(ctx, ` - SELECT id, version_id, approver_id, action, comment, created_at - FROM email_template_approvals - WHERE version_id = $1 - ORDER BY created_at DESC - `, versionID) - if err != nil { - return nil, fmt.Errorf("failed to get approvals: %w", err) - } - defer rows.Close() - - var approvals []models.EmailTemplateApproval - for rows.Next() { - var a models.EmailTemplateApproval - err := rows.Scan(&a.ID, &a.VersionID, &a.ApproverID, &a.Action, &a.Comment, &a.CreatedAt) - if err != nil { - return nil, fmt.Errorf("failed to scan approval: %w", err) - } - approvals = append(approvals, a) - } - - return approvals, nil -} - -// GetSettings returns global email settings -func (s *EmailTemplateService) GetSettings(ctx context.Context) (*models.EmailTemplateSettings, error) { - var settings models.EmailTemplateSettings - err := s.db.QueryRow(ctx, ` - SELECT id, logo_url, logo_base64, company_name, sender_name, sender_email, - reply_to_email, footer_html, footer_text, primary_color, secondary_color, - updated_at, updated_by - FROM email_template_settings - LIMIT 1 - `).Scan(&settings.ID, &settings.LogoURL, &settings.LogoBase64, &settings.CompanyName, - &settings.SenderName, &settings.SenderEmail, &settings.ReplyToEmail, &settings.FooterHTML, - &settings.FooterText, &settings.PrimaryColor, &settings.SecondaryColor, - &settings.UpdatedAt, &settings.UpdatedBy) - if err != nil { - return nil, fmt.Errorf("failed to get email settings: %w", err) - } - return &settings, nil -} - -// UpdateSettings updates global email settings -func (s *EmailTemplateService) UpdateSettings(ctx context.Context, req *models.UpdateEmailTemplateSettingsRequest, updatedBy uuid.UUID) error { - query := "UPDATE email_template_settings SET updated_at = $1, updated_by = $2" - args := []interface{}{time.Now(), updatedBy} - argIdx := 3 - - if req.LogoURL != nil { - query += fmt.Sprintf(", logo_url = $%d", argIdx) - args = append(args, *req.LogoURL) - argIdx++ - } - if req.LogoBase64 != nil { - query += fmt.Sprintf(", logo_base64 = $%d", argIdx) - args = append(args, *req.LogoBase64) - argIdx++ - } - if req.CompanyName != nil { - query += fmt.Sprintf(", company_name = $%d", argIdx) - args = append(args, *req.CompanyName) - argIdx++ - } - if req.SenderName != nil { - query += fmt.Sprintf(", sender_name = $%d", argIdx) - args = append(args, *req.SenderName) - argIdx++ - } - if req.SenderEmail != nil { - query += fmt.Sprintf(", sender_email = $%d", argIdx) - args = append(args, *req.SenderEmail) - argIdx++ - } - if req.ReplyToEmail != nil { - query += fmt.Sprintf(", reply_to_email = $%d", argIdx) - args = append(args, *req.ReplyToEmail) - argIdx++ - } - if req.FooterHTML != nil { - query += fmt.Sprintf(", footer_html = $%d", argIdx) - args = append(args, *req.FooterHTML) - argIdx++ - } - if req.FooterText != nil { - query += fmt.Sprintf(", footer_text = $%d", argIdx) - args = append(args, *req.FooterText) - argIdx++ - } - if req.PrimaryColor != nil { - query += fmt.Sprintf(", primary_color = $%d", argIdx) - args = append(args, *req.PrimaryColor) - argIdx++ - } - if req.SecondaryColor != nil { - query += fmt.Sprintf(", secondary_color = $%d", argIdx) - args = append(args, *req.SecondaryColor) - argIdx++ - } - - _, err := s.db.Exec(ctx, query, args...) - if err != nil { - return fmt.Errorf("failed to update settings: %w", err) - } - return nil -} - -// RenderTemplate renders a template with variables -func (s *EmailTemplateService) RenderTemplate(version *models.EmailTemplateVersion, variables map[string]string) (*models.EmailPreviewResponse, error) { - subject := version.Subject - bodyHTML := version.BodyHTML - bodyText := version.BodyText - - // Replace variables in format {{variable_name}} - re := regexp.MustCompile(`\{\{(\w+)\}\}`) - - replaceFunc := func(content string) string { - return re.ReplaceAllStringFunc(content, func(match string) string { - varName := strings.Trim(match, "{}") - if val, ok := variables[varName]; ok { - return val - } - return match // Keep placeholder if variable not provided - }) - } - - return &models.EmailPreviewResponse{ - Subject: replaceFunc(subject), - BodyHTML: replaceFunc(bodyHTML), - BodyText: replaceFunc(bodyText), - }, nil -} - -// LogEmailSend logs a sent email -func (s *EmailTemplateService) LogEmailSend(ctx context.Context, log *models.EmailSendLog) error { - _, err := s.db.Exec(ctx, ` - INSERT INTO email_send_logs - (id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - `, log.ID, log.UserID, log.VersionID, log.Recipient, log.Subject, log.Status, - log.ErrorMsg, log.Variables, log.SentAt, log.CreatedAt) - if err != nil { - return fmt.Errorf("failed to log email send: %w", err) - } - return nil -} - -// GetEmailStats returns email statistics -func (s *EmailTemplateService) GetEmailStats(ctx context.Context) (*models.EmailStats, error) { - var stats models.EmailStats - - err := s.db.QueryRow(ctx, ` - SELECT - COUNT(*) as total_sent, - COUNT(*) FILTER (WHERE status = 'delivered') as delivered, - COUNT(*) FILTER (WHERE status = 'bounced') as bounced, - COUNT(*) FILTER (WHERE status = 'failed') as failed, - COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') as recent_sent - FROM email_send_logs - `).Scan(&stats.TotalSent, &stats.Delivered, &stats.Bounced, &stats.Failed, &stats.RecentSent) - if err != nil { - return nil, fmt.Errorf("failed to get email stats: %w", err) - } - - if stats.TotalSent > 0 { - stats.DeliveryRate = float64(stats.Delivered) / float64(stats.TotalSent) * 100 - } - - return &stats, nil -} - -// GetDefaultTemplateContent returns default content for a template type -func (s *EmailTemplateService) GetDefaultTemplateContent(templateType, language string) (subject, bodyHTML, bodyText string) { - // Default templates in German - if language == "de" { - switch templateType { - case models.EmailTypeWelcome: - return s.getWelcomeTemplateDE() - case models.EmailTypeEmailVerification: - return s.getEmailVerificationTemplateDE() - case models.EmailTypePasswordReset: - return s.getPasswordResetTemplateDE() - case models.EmailTypePasswordChanged: - return s.getPasswordChangedTemplateDE() - case models.EmailType2FAEnabled: - return s.get2FAEnabledTemplateDE() - case models.EmailType2FADisabled: - return s.get2FADisabledTemplateDE() - case models.EmailTypeNewDeviceLogin: - return s.getNewDeviceLoginTemplateDE() - case models.EmailTypeSuspiciousActivity: - return s.getSuspiciousActivityTemplateDE() - case models.EmailTypeAccountLocked: - return s.getAccountLockedTemplateDE() - case models.EmailTypeAccountUnlocked: - return s.getAccountUnlockedTemplateDE() - case models.EmailTypeDeletionRequested: - return s.getDeletionRequestedTemplateDE() - case models.EmailTypeDeletionConfirmed: - return s.getDeletionConfirmedTemplateDE() - case models.EmailTypeDataExportReady: - return s.getDataExportReadyTemplateDE() - case models.EmailTypeEmailChanged: - return s.getEmailChangedTemplateDE() - case models.EmailTypeNewVersionPublished: - return s.getNewVersionPublishedTemplateDE() - case models.EmailTypeConsentReminder: - return s.getConsentReminderTemplateDE() - case models.EmailTypeConsentDeadlineWarning: - return s.getConsentDeadlineWarningTemplateDE() - case models.EmailTypeAccountSuspended: - return s.getAccountSuspendedTemplateDE() - } - } - - // Default English fallback - return "No template", "

No template available

", "No template available" -} - -// ======================================== -// Default German Templates -// ======================================== - -func (s *EmailTemplateService) getWelcomeTemplateDE() (string, string, string) { - subject := "Willkommen bei BreakPilot!" - bodyHTML := ` - - - -
-

Willkommen bei BreakPilot!

-

Hallo {{user_name}},

-

vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.

-

Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden:

-

- Jetzt anmelden -

-

Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Willkommen bei BreakPilot! - -Hallo {{user_name}}, - -vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt. - -Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden: -{{login_url}} - -Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getEmailVerificationTemplateDE() (string, string, string) { - subject := "Bitte bestätigen Sie Ihre E-Mail-Adresse" - bodyHTML := ` - - - -
-

E-Mail-Adresse bestätigen

-

Hallo {{user_name}},

-

bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:

-

- E-Mail bestätigen -

-

Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}}

-

Hinweis: Dieser Link ist nur {{expires_in}} gültig.

-

Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `E-Mail-Adresse bestätigen - -Hallo {{user_name}}, - -bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken: -{{verification_url}} - -Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}} - -Hinweis: Dieser Link ist nur {{expires_in}} gültig. - -Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getPasswordResetTemplateDE() (string, string, string) { - subject := "Passwort zurücksetzen" - bodyHTML := ` - - - -
-

Passwort zurücksetzen

-

Hallo {{user_name}},

-

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:

-

- Neues Passwort festlegen -

-

Alternativ können Sie auch diesen Code verwenden: {{reset_code}}

-

Hinweis: Dieser Link ist nur {{expires_in}} gültig.

-

- Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert. -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Passwort zurücksetzen - -Hallo {{user_name}}, - -Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen: -{{reset_url}} - -Alternativ können Sie auch diesen Code verwenden: {{reset_code}} - -Hinweis: Dieser Link ist nur {{expires_in}} gültig. - -Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getPasswordChangedTemplateDE() (string, string, string) { - subject := "Ihr Passwort wurde geändert" - bodyHTML := ` - - - -
-

Passwort geändert

-

Hallo {{user_name}},

-

Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.

-

Details:

-
    -
  • IP-Adresse: {{ip_address}}
  • -
  • Gerät: {{device_info}}
  • -
-

- Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Passwort geändert - -Hallo {{user_name}}, - -Ihr Passwort wurde am {{changed_at}} erfolgreich geändert. - -Details: -- IP-Adresse: {{ip_address}} -- Gerät: {{device_info}} - -Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) get2FAEnabledTemplateDE() (string, string, string) { - subject := "Zwei-Faktor-Authentifizierung aktiviert" - bodyHTML := ` - - - -
-

2FA aktiviert

-

Hallo {{user_name}},

-

Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.

-

Gerät: {{device_info}}

-

- Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren. -

-

Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `2FA aktiviert - -Hallo {{user_name}}, - -Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert. - -Gerät: {{device_info}} - -Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren. - -Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) get2FADisabledTemplateDE() (string, string, string) { - subject := "Zwei-Faktor-Authentifizierung deaktiviert" - bodyHTML := ` - - - -
-

2FA deaktiviert

-

Hallo {{user_name}},

-

Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.

-

IP-Adresse: {{ip_address}}

-

- Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren. -

-

Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `2FA deaktiviert - -Hallo {{user_name}}, - -Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert. - -IP-Adresse: {{ip_address}} - -Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren. - -Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getNewDeviceLoginTemplateDE() (string, string, string) { - subject := "Neuer Login auf Ihrem Konto" - bodyHTML := ` - - - -
-

Neuer Login erkannt

-

Hallo {{user_name}},

-

Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:

-
    -
  • Zeitpunkt: {{login_time}}
  • -
  • IP-Adresse: {{ip_address}}
  • -
  • Gerät: {{device_info}}
  • -
  • Standort: {{location}}
  • -
-

- Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}. -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Neuer Login erkannt - -Hallo {{user_name}}, - -Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt: - -- Zeitpunkt: {{login_time}} -- IP-Adresse: {{ip_address}} -- Gerät: {{device_info}} -- Standort: {{location}} - -Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getSuspiciousActivityTemplateDE() (string, string, string) { - subject := "Verdächtige Aktivität auf Ihrem Konto" - bodyHTML := ` - - - -
-

Verdächtige Aktivität erkannt

-

Hallo {{user_name}},

-

Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:

-
    -
  • Art: {{activity_type}}
  • -
  • Zeitpunkt: {{activity_time}}
  • -
  • IP-Adresse: {{ip_address}}
  • -
-

- Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben. -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Verdächtige Aktivität erkannt - -Hallo {{user_name}}, - -Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt: - -- Art: {{activity_type}} -- Zeitpunkt: {{activity_time}} -- IP-Adresse: {{ip_address}} - -Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getAccountLockedTemplateDE() (string, string, string) { - subject := "Ihr Konto wurde gesperrt" - bodyHTML := ` - - - -
-

Konto gesperrt

-

Hallo {{user_name}},

-

Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:

-

- {{reason}} -

-

Ihr Konto wird automatisch entsperrt am: {{unlock_time}}

-

Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Konto gesperrt - -Hallo {{user_name}}, - -Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt: - -{{reason}} - -Ihr Konto wird automatisch entsperrt am: {{unlock_time}} - -Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getAccountUnlockedTemplateDE() (string, string, string) { - subject := "Ihr Konto wurde entsperrt" - bodyHTML := ` - - - -
-

Konto entsperrt

-

Hallo {{user_name}},

-

Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.

-

- Jetzt anmelden -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Konto entsperrt - -Hallo {{user_name}}, - -Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt. - -Sie können sich jetzt wieder anmelden: {{login_url}} - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getDeletionRequestedTemplateDE() (string, string, string) { - subject := "Bestätigung: Kontolöschung angefordert" - bodyHTML := ` - - - -
-

Kontolöschung angefordert

-

Hallo {{user_name}},

-

Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.

-

- Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht. -

-

Folgende Daten werden gelöscht:

-

{{data_info}}

-

Sie können die Löschung bis zum genannten Datum abbrechen:

-

- Löschung abbrechen -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Kontolöschung angefordert - -Hallo {{user_name}}, - -Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt. - -Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht. - -Folgende Daten werden gelöscht: -{{data_info}} - -Sie können die Löschung bis zum genannten Datum abbrechen: {{cancel_url}} - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getDeletionConfirmedTemplateDE() (string, string, string) { - subject := "Ihr Konto wurde gelöscht" - bodyHTML := ` - - - -
-

Konto gelöscht

-

Hallo {{user_name}},

-

Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.

-

Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten:

-

- Feedback geben -

-

Vielen Dank für Ihre Zeit bei BreakPilot.

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Konto gelöscht - -Hallo {{user_name}}, - -Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht. - -Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten: {{feedback_url}} - -Vielen Dank für Ihre Zeit bei BreakPilot. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getDataExportReadyTemplateDE() (string, string, string) { - subject := "Ihr Datenexport ist bereit" - bodyHTML := ` - - - -
-

Datenexport bereit

-

Hallo {{user_name}},

-

Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit.

-

- Daten herunterladen ({{file_size}}) -

-

- Hinweis: Der Download-Link ist nur {{expires_in}} gültig. -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Datenexport bereit - -Hallo {{user_name}}, - -Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit: -{{download_url}} - -Dateigröße: {{file_size}} - -Hinweis: Der Download-Link ist nur {{expires_in}} gültig. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getEmailChangedTemplateDE() (string, string, string) { - subject := "Ihre E-Mail-Adresse wurde geändert" - bodyHTML := ` - - - -
-

E-Mail-Adresse geändert

-

Hallo {{user_name}},

-

Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.

-
    -
  • Alte Adresse: {{old_email}}
  • -
  • Neue Adresse: {{new_email}}
  • -
-

- Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `E-Mail-Adresse geändert - -Hallo {{user_name}}, - -Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert. - -- Alte Adresse: {{old_email}} -- Neue Adresse: {{new_email}} - -Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getNewVersionPublishedTemplateDE() (string, string, string) { - subject := "Neue Version: {{document_name}} - Ihre Zustimmung ist erforderlich" - bodyHTML := ` - - - -
-

Neue Dokumentversion

-

Hallo {{user_name}},

-

Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht.

-

Version: {{version}}

-

- Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}. -

-

- Jetzt prüfen und zustimmen -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Neue Dokumentversion - -Hallo {{user_name}}, - -Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht. - -Version: {{version}} - -Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}. - -Jetzt prüfen und zustimmen: {{consent_url}} - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getConsentReminderTemplateDE() (string, string, string) { - subject := "Erinnerung: Zustimmung zu {{document_name}} erforderlich" - bodyHTML := ` - - - -
-

Erinnerung: Zustimmung erforderlich

-

Hallo {{user_name}},

-

Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht.

-

- Noch {{days_left}} Tage bis zur Frist am {{deadline}}. -

-

- Jetzt zustimmen -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Erinnerung: Zustimmung erforderlich - -Hallo {{user_name}}, - -Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht. - -Noch {{days_left}} Tage bis zur Frist am {{deadline}}. - -Jetzt zustimmen: {{consent_url}} - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getConsentDeadlineWarningTemplateDE() (string, string, string) { - subject := "DRINGEND: Zustimmung zu {{document_name}} läuft bald ab" - bodyHTML := ` - - - -
-

Dringende Erinnerung

-

Hallo {{user_name}},

-

Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab!

-

- Wichtig: {{consequences}} -

-

- Sofort zustimmen -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Dringende Erinnerung - -Hallo {{user_name}}, - -Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab! - -Wichtig: {{consequences}} - -Sofort zustimmen: {{consent_url}} - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -func (s *EmailTemplateService) getAccountSuspendedTemplateDE() (string, string, string) { - subject := "Ihr Konto wurde suspendiert" - bodyHTML := ` - - - -
-

Konto suspendiert

-

Hallo {{user_name}},

-

Ihr Konto wurde am {{suspended_at}} suspendiert.

-

Grund: {{reason}}

-

Fehlende Zustimmungen:

-

{{documents}}

-

Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu:

-

- Konto reaktivieren -

-

Mit freundlichen Grüßen,
Ihr BreakPilot-Team

-
- -` - bodyText := `Konto suspendiert - -Hallo {{user_name}}, - -Ihr Konto wurde am {{suspended_at}} suspendiert. - -Grund: {{reason}} - -Fehlende Zustimmungen: -{{documents}} - -Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu: {{consent_url}} - -Mit freundlichen Grüßen, -Ihr BreakPilot-Team` - return subject, bodyHTML, bodyText -} - -// InitDefaultTemplates creates default email templates if they don't exist -func (s *EmailTemplateService) InitDefaultTemplates(ctx context.Context) error { - templateTypes := []struct { - Type string - Name string - Description string - SortOrder int - }{ - {models.EmailTypeWelcome, "Willkommens-E-Mail", "Wird nach erfolgreicher Registrierung gesendet", 1}, - {models.EmailTypeEmailVerification, "E-Mail-Verifizierung", "Enthält Link zur E-Mail-Bestätigung", 2}, - {models.EmailTypePasswordReset, "Passwort zurücksetzen", "Enthält Link zum Passwort-Reset", 3}, - {models.EmailTypePasswordChanged, "Passwort geändert", "Bestätigung der Passwortänderung", 4}, - {models.EmailType2FAEnabled, "2FA aktiviert", "Bestätigung der 2FA-Aktivierung", 5}, - {models.EmailType2FADisabled, "2FA deaktiviert", "Warnung über 2FA-Deaktivierung", 6}, - {models.EmailTypeNewDeviceLogin, "Neuer Login", "Benachrichtigung über Login von neuem Gerät", 7}, - {models.EmailTypeSuspiciousActivity, "Verdächtige Aktivität", "Warnung über verdächtige Kontoaktivität", 8}, - {models.EmailTypeAccountLocked, "Konto gesperrt", "Benachrichtigung über Kontosperrung", 9}, - {models.EmailTypeAccountUnlocked, "Konto entsperrt", "Bestätigung der Kontoentsperrung", 10}, - {models.EmailTypeDeletionRequested, "Löschung angefordert", "Bestätigung der Löschanfrage", 11}, - {models.EmailTypeDeletionConfirmed, "Löschung bestätigt", "Bestätigung der Kontolöschung", 12}, - {models.EmailTypeDataExportReady, "Datenexport bereit", "Benachrichtigung über fertigen Datenexport", 13}, - {models.EmailTypeEmailChanged, "E-Mail geändert", "Bestätigung der E-Mail-Änderung", 14}, - {models.EmailTypeNewVersionPublished, "Neue Version veröffentlicht", "Benachrichtigung über neue Dokumentversion", 15}, - {models.EmailTypeConsentReminder, "Zustimmungs-Erinnerung", "Erinnerung an ausstehende Zustimmung", 16}, - {models.EmailTypeConsentDeadlineWarning, "Frist-Warnung", "Dringende Warnung vor ablaufender Frist", 17}, - {models.EmailTypeAccountSuspended, "Konto suspendiert", "Benachrichtigung über Kontosuspendierung", 18}, - } - - for _, tt := range templateTypes { - // Check if template exists - var exists bool - err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM email_templates WHERE type = $1)`, tt.Type).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check template existence: %w", err) - } - - if !exists { - desc := tt.Description - _, err = s.db.Exec(ctx, ` - INSERT INTO email_templates (id, type, name, description, is_active, sort_order, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - `, uuid.New(), tt.Type, tt.Name, &desc, true, tt.SortOrder, time.Now(), time.Now()) - if err != nil { - return fmt.Errorf("failed to create template %s: %w", tt.Type, err) - } - - // Create default German version - template, err := s.GetTemplateByType(ctx, tt.Type) - if err != nil { - return fmt.Errorf("failed to get template %s: %w", tt.Type, err) - } - - subject, bodyHTML, bodyText := s.GetDefaultTemplateContent(tt.Type, "de") - _, err = s.db.Exec(ctx, ` - INSERT INTO email_template_versions - (id, template_id, version, language, subject, body_html, body_text, status, published_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - `, uuid.New(), template.ID, "1.0.0", "de", subject, bodyHTML, bodyText, "published", time.Now(), time.Now(), time.Now()) - if err != nil { - return fmt.Errorf("failed to create template version %s: %w", tt.Type, err) - } - } - } - - return nil -} - -// GetSendLogs returns email send logs with optional filtering -func (s *EmailTemplateService) GetSendLogs(ctx context.Context, limit, offset int) ([]models.EmailSendLog, int, error) { - var total int - err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM email_send_logs`).Scan(&total) - if err != nil { - return nil, 0, fmt.Errorf("failed to count send logs: %w", err) - } - - rows, err := s.db.Query(ctx, ` - SELECT id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, delivered_at, created_at - FROM email_send_logs - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - `, limit, offset) - if err != nil { - return nil, 0, fmt.Errorf("failed to get send logs: %w", err) - } - defer rows.Close() - - var logs []models.EmailSendLog - for rows.Next() { - var log models.EmailSendLog - err := rows.Scan(&log.ID, &log.UserID, &log.VersionID, &log.Recipient, &log.Subject, - &log.Status, &log.ErrorMsg, &log.Variables, &log.SentAt, &log.DeliveredAt, &log.CreatedAt) - if err != nil { - return nil, 0, fmt.Errorf("failed to scan send log: %w", err) - } - logs = append(logs, log) - } - - return logs, total, nil -} - -// SendEmail sends an email using the specified template (stub - actual sending would use SMTP) -func (s *EmailTemplateService) SendEmail(ctx context.Context, templateType, language, recipient string, variables map[string]string, userID *uuid.UUID) error { - // Get published version - version, err := s.GetPublishedVersion(ctx, templateType, language) - if err != nil { - return fmt.Errorf("failed to get published version: %w", err) - } - - // Render template - rendered, err := s.RenderTemplate(version, variables) - if err != nil { - return fmt.Errorf("failed to render template: %w", err) - } - - // Log the send attempt - variablesJSON, _ := json.Marshal(variables) - now := time.Now() - log := &models.EmailSendLog{ - ID: uuid.New(), - UserID: userID, - VersionID: version.ID, - Recipient: recipient, - Subject: rendered.Subject, - Status: "queued", - Variables: ptr(string(variablesJSON)), - CreatedAt: now, - } - - if err := s.LogEmailSend(ctx, log); err != nil { - return fmt.Errorf("failed to log email send: %w", err) - } - - // TODO: Actual email sending via SMTP would go here - // For now, we just log it as "sent" - _, err = s.db.Exec(ctx, ` - UPDATE email_send_logs SET status = 'sent', sent_at = $1 WHERE id = $2 - `, now, log.ID) - if err != nil { - return fmt.Errorf("failed to update send log status: %w", err) - } - - return nil -} - -func ptr(s string) *string { - return &s -} diff --git a/consent-service/internal/services/email_template_settings.go b/consent-service/internal/services/email_template_settings.go new file mode 100644 index 0000000..f601a9a --- /dev/null +++ b/consent-service/internal/services/email_template_settings.go @@ -0,0 +1,300 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" +) + +// GetAllTemplateTypes returns all available email template types with their variables +func (s *EmailTemplateService) GetAllTemplateTypes() []models.EmailTemplateVariables { + return []models.EmailTemplateVariables{ + { + TemplateType: models.EmailTypeWelcome, + Variables: []string{"user_name", "user_email", "login_url", "support_email"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "user_email": "E-Mail-Adresse des Benutzers", + "login_url": "URL zur Login-Seite", + "support_email": "Support E-Mail-Adresse", + }, + }, + { + TemplateType: models.EmailTypeEmailVerification, + Variables: []string{"user_name", "verification_url", "verification_code", "expires_in"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "verification_url": "URL zur E-Mail-Verifizierung", + "verification_code": "Verifizierungscode", + "expires_in": "Gültigkeit des Links (z.B. '24 Stunden')", + }, + }, + { + TemplateType: models.EmailTypePasswordReset, + Variables: []string{"user_name", "reset_url", "reset_code", "expires_in", "ip_address"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "reset_url": "URL zum Passwort-Reset", + "reset_code": "Reset-Code", + "expires_in": "Gültigkeit des Links", + "ip_address": "IP-Adresse der Anfrage", + }, + }, + { + TemplateType: models.EmailTypePasswordChanged, + Variables: []string{"user_name", "changed_at", "ip_address", "device_info", "support_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "changed_at": "Zeitpunkt der Änderung", + "ip_address": "IP-Adresse", + "device_info": "Geräte-Informationen", + "support_url": "URL zum Support", + }, + }, + { + TemplateType: models.EmailType2FAEnabled, + Variables: []string{"user_name", "enabled_at", "device_info", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "enabled_at": "Zeitpunkt der Aktivierung", + "device_info": "Geräte-Informationen", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailType2FADisabled, + Variables: []string{"user_name", "disabled_at", "ip_address", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "disabled_at": "Zeitpunkt der Deaktivierung", + "ip_address": "IP-Adresse", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailTypeNewDeviceLogin, + Variables: []string{"user_name", "login_time", "ip_address", "device_info", "location", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "login_time": "Zeitpunkt des Logins", + "ip_address": "IP-Adresse", + "device_info": "Geräte-Informationen", + "location": "Ungefährer Standort", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailTypeSuspiciousActivity, + Variables: []string{"user_name", "activity_type", "activity_time", "ip_address", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "activity_type": "Art der Aktivität", + "activity_time": "Zeitpunkt", + "ip_address": "IP-Adresse", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailTypeAccountLocked, + Variables: []string{"user_name", "locked_at", "reason", "unlock_time", "support_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "locked_at": "Zeitpunkt der Sperrung", + "reason": "Grund der Sperrung", + "unlock_time": "Zeitpunkt der automatischen Entsperrung", + "support_url": "URL zum Support", + }, + }, + { + TemplateType: models.EmailTypeAccountUnlocked, + Variables: []string{"user_name", "unlocked_at", "login_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "unlocked_at": "Zeitpunkt der Entsperrung", + "login_url": "URL zur Login-Seite", + }, + }, + { + TemplateType: models.EmailTypeDeletionRequested, + Variables: []string{"user_name", "requested_at", "deletion_date", "cancel_url", "data_info"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "requested_at": "Zeitpunkt der Anfrage", + "deletion_date": "Datum der endgültigen Löschung", + "cancel_url": "URL zum Abbrechen", + "data_info": "Info über zu löschende Daten", + }, + }, + { + TemplateType: models.EmailTypeDeletionConfirmed, + Variables: []string{"user_name", "deleted_at", "feedback_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "deleted_at": "Zeitpunkt der Löschung", + "feedback_url": "URL für Feedback", + }, + }, + { + TemplateType: models.EmailTypeDataExportReady, + Variables: []string{"user_name", "download_url", "expires_in", "file_size"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "download_url": "URL zum Download", + "expires_in": "Gültigkeit des Download-Links", + "file_size": "Dateigröße", + }, + }, + { + TemplateType: models.EmailTypeEmailChanged, + Variables: []string{"user_name", "old_email", "new_email", "changed_at", "support_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "old_email": "Alte E-Mail-Adresse", + "new_email": "Neue E-Mail-Adresse", + "changed_at": "Zeitpunkt der Änderung", + "support_url": "URL zum Support", + }, + }, + { + TemplateType: models.EmailTypeEmailChangeVerify, + Variables: []string{"user_name", "new_email", "verification_url", "expires_in"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "new_email": "Neue E-Mail-Adresse", + "verification_url": "URL zur Verifizierung", + "expires_in": "Gültigkeit des Links", + }, + }, + { + TemplateType: models.EmailTypeNewVersionPublished, + Variables: []string{"user_name", "document_name", "document_type", "version", "consent_url", "deadline"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "document_name": "Name des Dokuments", + "document_type": "Typ des Dokuments", + "version": "Versionsnummer", + "consent_url": "URL zur Zustimmung", + "deadline": "Frist für die Zustimmung", + }, + }, + { + TemplateType: models.EmailTypeConsentReminder, + Variables: []string{"user_name", "document_name", "days_left", "consent_url", "deadline"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "document_name": "Name des Dokuments", + "days_left": "Verbleibende Tage", + "consent_url": "URL zur Zustimmung", + "deadline": "Frist für die Zustimmung", + }, + }, + { + TemplateType: models.EmailTypeConsentDeadlineWarning, + Variables: []string{"user_name", "document_name", "hours_left", "consent_url", "consequences"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "document_name": "Name des Dokuments", + "hours_left": "Verbleibende Stunden", + "consent_url": "URL zur Zustimmung", + "consequences": "Konsequenzen bei Nicht-Zustimmung", + }, + }, + { + TemplateType: models.EmailTypeAccountSuspended, + Variables: []string{"user_name", "suspended_at", "reason", "documents", "consent_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "suspended_at": "Zeitpunkt der Suspendierung", + "reason": "Grund der Suspendierung", + "documents": "Liste der fehlenden Zustimmungen", + "consent_url": "URL zur Zustimmung", + }, + }, + } +} + +// GetSettings returns global email settings +func (s *EmailTemplateService) GetSettings(ctx context.Context) (*models.EmailTemplateSettings, error) { + var settings models.EmailTemplateSettings + err := s.db.QueryRow(ctx, ` + SELECT id, logo_url, logo_base64, company_name, sender_name, sender_email, + reply_to_email, footer_html, footer_text, primary_color, secondary_color, + updated_at, updated_by + FROM email_template_settings + LIMIT 1 + `).Scan(&settings.ID, &settings.LogoURL, &settings.LogoBase64, &settings.CompanyName, + &settings.SenderName, &settings.SenderEmail, &settings.ReplyToEmail, &settings.FooterHTML, + &settings.FooterText, &settings.PrimaryColor, &settings.SecondaryColor, + &settings.UpdatedAt, &settings.UpdatedBy) + if err != nil { + return nil, fmt.Errorf("failed to get email settings: %w", err) + } + return &settings, nil +} + +// UpdateSettings updates global email settings +func (s *EmailTemplateService) UpdateSettings(ctx context.Context, req *models.UpdateEmailTemplateSettingsRequest, updatedBy uuid.UUID) error { + query := "UPDATE email_template_settings SET updated_at = $1, updated_by = $2" + args := []interface{}{time.Now(), updatedBy} + argIdx := 3 + + if req.LogoURL != nil { + query += fmt.Sprintf(", logo_url = $%d", argIdx) + args = append(args, *req.LogoURL) + argIdx++ + } + if req.LogoBase64 != nil { + query += fmt.Sprintf(", logo_base64 = $%d", argIdx) + args = append(args, *req.LogoBase64) + argIdx++ + } + if req.CompanyName != nil { + query += fmt.Sprintf(", company_name = $%d", argIdx) + args = append(args, *req.CompanyName) + argIdx++ + } + if req.SenderName != nil { + query += fmt.Sprintf(", sender_name = $%d", argIdx) + args = append(args, *req.SenderName) + argIdx++ + } + if req.SenderEmail != nil { + query += fmt.Sprintf(", sender_email = $%d", argIdx) + args = append(args, *req.SenderEmail) + argIdx++ + } + if req.ReplyToEmail != nil { + query += fmt.Sprintf(", reply_to_email = $%d", argIdx) + args = append(args, *req.ReplyToEmail) + argIdx++ + } + if req.FooterHTML != nil { + query += fmt.Sprintf(", footer_html = $%d", argIdx) + args = append(args, *req.FooterHTML) + argIdx++ + } + if req.FooterText != nil { + query += fmt.Sprintf(", footer_text = $%d", argIdx) + args = append(args, *req.FooterText) + argIdx++ + } + if req.PrimaryColor != nil { + query += fmt.Sprintf(", primary_color = $%d", argIdx) + args = append(args, *req.PrimaryColor) + argIdx++ + } + if req.SecondaryColor != nil { + query += fmt.Sprintf(", secondary_color = $%d", argIdx) + args = append(args, *req.SecondaryColor) + argIdx++ + } + + _, err := s.db.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update settings: %w", err) + } + return nil +} diff --git a/consent-service/internal/services/grade_service.go b/consent-service/internal/services/grade_service.go index bd3ff9b..a7da9e8 100644 --- a/consent-service/internal/services/grade_service.go +++ b/consent-service/internal/services/grade_service.go @@ -334,210 +334,3 @@ func (s *GradeService) GetClassGradesBySubject(ctx context.Context, classID, sub return overviews, nil } -// ======================================== -// Grade Statistics -// ======================================== - -// GetStudentGradeAverage calculates the overall grade average for a student -func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) { - query := ` - SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0) - FROM grades - WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true` - - var average float64 - err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average) - if err != nil { - return 0, fmt.Errorf("failed to calculate average: %w", err) - } - - return average, nil -} - -// GetSubjectGradeStatistics gets grade statistics for a subject in a class -func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) { - query := ` - SELECT - COUNT(DISTINCT g.student_id) as student_count, - AVG(g.value) as class_average, - MIN(g.value) as best_grade, - MAX(g.value) as worst_grade, - COUNT(*) as total_grades - FROM grades g - JOIN students s ON g.student_id = s.id - WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true` - - var studentCount, totalGrades int - var classAverage, bestGrade, worstGrade float64 - - err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan( - &studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades, - ) - if err != nil { - return nil, fmt.Errorf("failed to get statistics: %w", err) - } - - // Grade distribution (for German grades 1-6) - distributionQuery := ` - SELECT - COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1, - COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2, - COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3, - COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4, - COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5, - COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6 - FROM grades g - JOIN students s ON g.student_id = s.id - WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')` - - var g1, g2, g3, g4, g5, g6 int - err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan( - &g1, &g2, &g3, &g4, &g5, &g6, - ) - if err != nil { - // Non-fatal, continue without distribution - g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0 - } - - return map[string]interface{}{ - "student_count": studentCount, - "class_average": classAverage, - "best_grade": bestGrade, - "worst_grade": worstGrade, - "total_grades": totalGrades, - "distribution": map[string]int{ - "1": g1, - "2": g2, - "3": g3, - "4": g4, - "5": g5, - "6": g6, - }, - }, nil -} - -// ======================================== -// Grade Comments -// ======================================== - -// AddGradeComment adds a comment to a grade -func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) { - gradeComment := &models.GradeComment{ - ID: uuid.New(), - GradeID: gradeID, - TeacherID: teacherID, - Comment: comment, - IsPrivate: isPrivate, - CreatedAt: time.Now(), - } - - query := ` - INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id` - - err := s.db.Pool.QueryRow(ctx, query, - gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID, - gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt, - ).Scan(&gradeComment.ID) - - if err != nil { - return nil, fmt.Errorf("failed to add grade comment: %w", err) - } - - return gradeComment, nil -} - -// GetGradeComments gets comments for a grade -func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) { - query := ` - SELECT id, grade_id, teacher_id, comment, is_private, created_at - FROM grade_comments - WHERE grade_id = $1` - - if !includePrivate { - query += ` AND is_private = false` - } - - query += ` ORDER BY created_at DESC` - - rows, err := s.db.Pool.Query(ctx, query, gradeID) - if err != nil { - return nil, fmt.Errorf("failed to get grade comments: %w", err) - } - defer rows.Close() - - var comments []models.GradeComment - for rows.Next() { - var comment models.GradeComment - err := rows.Scan( - &comment.ID, &comment.GradeID, &comment.TeacherID, - &comment.Comment, &comment.IsPrivate, &comment.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan grade comment: %w", err) - } - comments = append(comments, comment) - } - - return comments, nil -} - -// ======================================== -// Parent Notifications -// ======================================== - -func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) { - if s.matrix == nil { - return - } - - // Get student info and Matrix room - var studentFirstName, studentLastName, matrixDMRoom string - err := s.db.Pool.QueryRow(ctx, ` - SELECT first_name, last_name, matrix_dm_room - FROM students - WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) - if err != nil || matrixDMRoom == "" { - return - } - - // Get subject name - var subjectName string - err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName) - if err != nil { - return - } - - studentName := studentFirstName + " " + studentLastName - gradeType := s.getGradeTypeDisplayName(grade.Type) - - // Send Matrix notification - err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value) - if err != nil { - fmt.Printf("Failed to send grade notification: %v\n", err) - } -} - -func (s *GradeService) getGradeTypeDisplayName(gradeType string) string { - switch gradeType { - case models.GradeTypeExam: - return "Klassenarbeit" - case models.GradeTypeTest: - return "Test" - case models.GradeTypeOral: - return "Mündliche Note" - case models.GradeTypeHomework: - return "Hausaufgabe" - case models.GradeTypeProject: - return "Projekt" - case models.GradeTypeParticipation: - return "Mitarbeit" - case models.GradeTypeSemester: - return "Halbjahreszeugnis" - case models.GradeTypeFinal: - return "Zeugnisnote" - default: - return gradeType - } -} diff --git a/consent-service/internal/services/grade_service_ops.go b/consent-service/internal/services/grade_service_ops.go new file mode 100644 index 0000000..a962b96 --- /dev/null +++ b/consent-service/internal/services/grade_service_ops.go @@ -0,0 +1,219 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/google/uuid" +) + +// ======================================== +// Grade Statistics +// ======================================== + +// GetStudentGradeAverage calculates the overall grade average for a student +func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) { + query := ` + SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0) + FROM grades + WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true` + + var average float64 + err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average) + if err != nil { + return 0, fmt.Errorf("failed to calculate average: %w", err) + } + + return average, nil +} + +// GetSubjectGradeStatistics gets grade statistics for a subject in a class +func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) { + query := ` + SELECT + COUNT(DISTINCT g.student_id) as student_count, + AVG(g.value) as class_average, + MIN(g.value) as best_grade, + MAX(g.value) as worst_grade, + COUNT(*) as total_grades + FROM grades g + JOIN students s ON g.student_id = s.id + WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true` + + var studentCount, totalGrades int + var classAverage, bestGrade, worstGrade float64 + + err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan( + &studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades, + ) + if err != nil { + return nil, fmt.Errorf("failed to get statistics: %w", err) + } + + // Grade distribution (for German grades 1-6) + distributionQuery := ` + SELECT + COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1, + COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2, + COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3, + COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4, + COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5, + COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6 + FROM grades g + JOIN students s ON g.student_id = s.id + WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')` + + var g1, g2, g3, g4, g5, g6 int + err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan( + &g1, &g2, &g3, &g4, &g5, &g6, + ) + if err != nil { + // Non-fatal, continue without distribution + g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0 + } + + return map[string]interface{}{ + "student_count": studentCount, + "class_average": classAverage, + "best_grade": bestGrade, + "worst_grade": worstGrade, + "total_grades": totalGrades, + "distribution": map[string]int{ + "1": g1, + "2": g2, + "3": g3, + "4": g4, + "5": g5, + "6": g6, + }, + }, nil +} + +// ======================================== +// Grade Comments +// ======================================== + +// AddGradeComment adds a comment to a grade +func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) { + gradeComment := &models.GradeComment{ + ID: uuid.New(), + GradeID: gradeID, + TeacherID: teacherID, + Comment: comment, + IsPrivate: isPrivate, + CreatedAt: time.Now(), + } + + query := ` + INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID, + gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt, + ).Scan(&gradeComment.ID) + + if err != nil { + return nil, fmt.Errorf("failed to add grade comment: %w", err) + } + + return gradeComment, nil +} + +// GetGradeComments gets comments for a grade +func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) { + query := ` + SELECT id, grade_id, teacher_id, comment, is_private, created_at + FROM grade_comments + WHERE grade_id = $1` + + if !includePrivate { + query += ` AND is_private = false` + } + + query += ` ORDER BY created_at DESC` + + rows, err := s.db.Pool.Query(ctx, query, gradeID) + if err != nil { + return nil, fmt.Errorf("failed to get grade comments: %w", err) + } + defer rows.Close() + + var comments []models.GradeComment + for rows.Next() { + var comment models.GradeComment + err := rows.Scan( + &comment.ID, &comment.GradeID, &comment.TeacherID, + &comment.Comment, &comment.IsPrivate, &comment.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan grade comment: %w", err) + } + comments = append(comments, comment) + } + + return comments, nil +} + +// ======================================== +// Parent Notifications +// ======================================== + +func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) { + if s.matrix == nil { + return + } + + // Get student info and Matrix room + var studentFirstName, studentLastName, matrixDMRoom string + err := s.db.Pool.QueryRow(ctx, ` + SELECT first_name, last_name, matrix_dm_room + FROM students + WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) + if err != nil || matrixDMRoom == "" { + return + } + + // Get subject name + var subjectName string + err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName) + if err != nil { + return + } + + studentName := studentFirstName + " " + studentLastName + gradeType := s.getGradeTypeDisplayName(grade.Type) + + // Send Matrix notification + err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value) + if err != nil { + fmt.Printf("Failed to send grade notification: %v\n", err) + } +} + +func (s *GradeService) getGradeTypeDisplayName(gradeType string) string { + switch gradeType { + case models.GradeTypeExam: + return "Klassenarbeit" + case models.GradeTypeTest: + return "Test" + case models.GradeTypeOral: + return "Mündliche Note" + case models.GradeTypeHomework: + return "Hausaufgabe" + case models.GradeTypeProject: + return "Projekt" + case models.GradeTypeParticipation: + return "Mitarbeit" + case models.GradeTypeSemester: + return "Halbjahreszeugnis" + case models.GradeTypeFinal: + return "Zeugnisnote" + default: + return gradeType + } +} diff --git a/consent-service/internal/services/jitsi/jitsi_service.go b/consent-service/internal/services/jitsi/jitsi_service.go index e82fea9..f6071db 100644 --- a/consent-service/internal/services/jitsi/jitsi_service.go +++ b/consent-service/internal/services/jitsi/jitsi_service.go @@ -2,17 +2,10 @@ package jitsi import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" "fmt" "net/http" - "net/url" "strings" "time" - - "github.com/google/uuid" ) // JitsiService handles Jitsi Meet integration for video conferences @@ -292,275 +285,3 @@ func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string, return s.CreateMeetingLink(ctx, meeting) } -// ======================================== -// JWT Generation -// ======================================== - -// generateJWT creates a signed JWT for Jitsi authentication -func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) { - if s.appSecret == "" { - return "", nil, fmt.Errorf("app secret not configured") - } - - now := time.Now() - - // Default expiration: 24 hours or based on meeting duration - expiration := now.Add(24 * time.Hour) - if meeting.Duration > 0 { - expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute) - } - if meeting.StartTime != nil { - expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute) - } - - claims := JWTClaims{ - Audience: "jitsi", - Issuer: s.appID, - Subject: "meet.jitsi", - Room: roomName, - ExpiresAt: expiration.Unix(), - NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period - Moderator: meeting.Moderator, - Context: &JWTContext{ - User: &JWTUser{ - ID: uuid.New().String(), - Name: meeting.DisplayName, - Email: meeting.Email, - Avatar: meeting.Avatar, - Moderator: meeting.Moderator, - }, - }, - } - - // Add features if specified - if meeting.Features != nil { - claims.Features = &JWTFeatures{ - Recording: boolToString(meeting.Features.Recording), - Livestreaming: boolToString(meeting.Features.Livestreaming), - Transcription: boolToString(meeting.Features.Transcription), - OutboundCall: boolToString(meeting.Features.OutboundCall), - } - } - - // Create JWT - token, err := s.signJWT(claims) - if err != nil { - return "", nil, err - } - - return token, &expiration, nil -} - -// signJWT creates and signs a JWT token -func (s *JitsiService) signJWT(claims JWTClaims) (string, error) { - // Header - header := map[string]string{ - "alg": "HS256", - "typ": "JWT", - } - - headerJSON, err := json.Marshal(header) - if err != nil { - return "", err - } - - // Payload - payloadJSON, err := json.Marshal(claims) - if err != nil { - return "", err - } - - // Encode - headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) - - // Sign - message := headerB64 + "." + payloadB64 - h := hmac.New(sha256.New, []byte(s.appSecret)) - h.Write([]byte(message)) - signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - return message + "." + signature, nil -} - -// ======================================== -// Health Check -// ======================================== - -// HealthCheck verifies the Jitsi server is accessible -func (s *JitsiService) HealthCheck(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := s.httpClient.Do(req) - if err != nil { - return fmt.Errorf("jitsi server unreachable: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 500 { - return fmt.Errorf("jitsi server error: status %d", resp.StatusCode) - } - - return nil -} - -// GetServerInfo returns information about the Jitsi server -func (s *JitsiService) GetServerInfo() map[string]string { - return map[string]string{ - "base_url": s.baseURL, - "app_id": s.appID, - "auth_enabled": boolToString(s.appSecret != ""), - } -} - -// ======================================== -// URL Building -// ======================================== - -// BuildEmbedURL creates an embeddable iframe URL -func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string { - params := url.Values{} - - if displayName != "" { - params.Set("userInfo.displayName", displayName) - } - - if config != nil { - if config.StartWithAudioMuted { - params.Set("config.startWithAudioMuted", "true") - } - if config.StartWithVideoMuted { - params.Set("config.startWithVideoMuted", "true") - } - if config.DisableDeepLinking { - params.Set("config.disableDeepLinking", "true") - } - } - - embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName)) - if len(params) > 0 { - embedURL += "#" + params.Encode() - } - - return embedURL -} - -// BuildIFrameCode generates HTML iframe code for embedding -func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string { - if width == 0 { - width = 800 - } - if height == 0 { - height = 600 - } - - return fmt.Sprintf( - ``, - s.baseURL, - s.sanitizeRoomName(roomName), - width, - height, - ) -} - -// ======================================== -// Helper Functions -// ======================================== - -// generateRoomName creates a unique room name -func (s *JitsiService) generateRoomName() string { - return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8]) -} - -// generateTrainingRoomName creates a room name for training sessions -func (s *JitsiService) generateTrainingRoomName(title string) string { - sanitized := s.sanitizeRoomName(title) - if sanitized == "" { - sanitized = "schulung" - } - return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504")) -} - -// sanitizeRoomName removes invalid characters from room names -func (s *JitsiService) sanitizeRoomName(name string) string { - // Replace spaces and special characters - result := strings.ToLower(name) - result = strings.ReplaceAll(result, " ", "-") - result = strings.ReplaceAll(result, "ä", "ae") - result = strings.ReplaceAll(result, "ö", "oe") - result = strings.ReplaceAll(result, "ü", "ue") - result = strings.ReplaceAll(result, "ß", "ss") - - // Remove any remaining non-alphanumeric characters except hyphen - var cleaned strings.Builder - for _, r := range result { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - cleaned.WriteRune(r) - } - } - - // Remove consecutive hyphens - result = cleaned.String() - for strings.Contains(result, "--") { - result = strings.ReplaceAll(result, "--", "-") - } - - // Trim hyphens from start and end - result = strings.Trim(result, "-") - - // Limit length - if len(result) > 50 { - result = result[:50] - } - - return result -} - -// generatePassword creates a random meeting password -func (s *JitsiService) generatePassword() string { - return uuid.New().String()[:8] -} - -// buildConfigParams creates URL parameters from config -func (s *JitsiService) buildConfigParams(config *MeetingConfig) string { - params := url.Values{} - - if config.StartWithAudioMuted { - params.Set("config.startWithAudioMuted", "true") - } - if config.StartWithVideoMuted { - params.Set("config.startWithVideoMuted", "true") - } - if config.DisableDeepLinking { - params.Set("config.disableDeepLinking", "true") - } - if config.RequireDisplayName { - params.Set("config.requireDisplayName", "true") - } - if config.EnableLobby { - params.Set("config.enableLobby", "true") - } - - return params.Encode() -} - -// boolToString converts bool to "true"/"false" string -func boolToString(b bool) string { - if b { - return "true" - } - return "false" -} - -// GetBaseURL returns the configured base URL -func (s *JitsiService) GetBaseURL() string { - return s.baseURL -} - -// IsAuthEnabled returns whether JWT authentication is configured -func (s *JitsiService) IsAuthEnabled() bool { - return s.appSecret != "" -} diff --git a/consent-service/internal/services/jitsi/jitsi_service_helpers.go b/consent-service/internal/services/jitsi/jitsi_service_helpers.go new file mode 100644 index 0000000..d295c5f --- /dev/null +++ b/consent-service/internal/services/jitsi/jitsi_service_helpers.go @@ -0,0 +1,290 @@ +package jitsi + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "crypto/hmac" + "crypto/sha256" + + "github.com/google/uuid" +) + +// ======================================== +// JWT Generation +// ======================================== + +// generateJWT creates a signed JWT for Jitsi authentication +func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) { + if s.appSecret == "" { + return "", nil, fmt.Errorf("app secret not configured") + } + + now := time.Now() + + // Default expiration: 24 hours or based on meeting duration + expiration := now.Add(24 * time.Hour) + if meeting.Duration > 0 { + expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute) + } + if meeting.StartTime != nil { + expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute) + } + + claims := JWTClaims{ + Audience: "jitsi", + Issuer: s.appID, + Subject: "meet.jitsi", + Room: roomName, + ExpiresAt: expiration.Unix(), + NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period + Moderator: meeting.Moderator, + Context: &JWTContext{ + User: &JWTUser{ + ID: uuid.New().String(), + Name: meeting.DisplayName, + Email: meeting.Email, + Avatar: meeting.Avatar, + Moderator: meeting.Moderator, + }, + }, + } + + // Add features if specified + if meeting.Features != nil { + claims.Features = &JWTFeatures{ + Recording: boolToString(meeting.Features.Recording), + Livestreaming: boolToString(meeting.Features.Livestreaming), + Transcription: boolToString(meeting.Features.Transcription), + OutboundCall: boolToString(meeting.Features.OutboundCall), + } + } + + // Create JWT + token, err := s.signJWT(claims) + if err != nil { + return "", nil, err + } + + return token, &expiration, nil +} + +// signJWT creates and signs a JWT token +func (s *JitsiService) signJWT(claims JWTClaims) (string, error) { + // Header + header := map[string]string{ + "alg": "HS256", + "typ": "JWT", + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + + // Payload + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + // Encode + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + + // Sign + message := headerB64 + "." + payloadB64 + h := hmac.New(sha256.New, []byte(s.appSecret)) + h.Write([]byte(message)) + signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return message + "." + signature, nil +} + +// ======================================== +// Health Check +// ======================================== + +// HealthCheck verifies the Jitsi server is accessible +func (s *JitsiService) HealthCheck(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("jitsi server unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 { + return fmt.Errorf("jitsi server error: status %d", resp.StatusCode) + } + + return nil +} + +// GetServerInfo returns information about the Jitsi server +func (s *JitsiService) GetServerInfo() map[string]string { + return map[string]string{ + "base_url": s.baseURL, + "app_id": s.appID, + "auth_enabled": boolToString(s.appSecret != ""), + } +} + +// ======================================== +// URL Building +// ======================================== + +// BuildEmbedURL creates an embeddable iframe URL +func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string { + params := url.Values{} + + if displayName != "" { + params.Set("userInfo.displayName", displayName) + } + + if config != nil { + if config.StartWithAudioMuted { + params.Set("config.startWithAudioMuted", "true") + } + if config.StartWithVideoMuted { + params.Set("config.startWithVideoMuted", "true") + } + if config.DisableDeepLinking { + params.Set("config.disableDeepLinking", "true") + } + } + + embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName)) + if len(params) > 0 { + embedURL += "#" + params.Encode() + } + + return embedURL +} + +// BuildIFrameCode generates HTML iframe code for embedding +func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string { + if width == 0 { + width = 800 + } + if height == 0 { + height = 600 + } + + return fmt.Sprintf( + ``, + s.baseURL, + s.sanitizeRoomName(roomName), + width, + height, + ) +} + +// ======================================== +// Helper Functions +// ======================================== + +// generateRoomName creates a unique room name +func (s *JitsiService) generateRoomName() string { + return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8]) +} + +// generateTrainingRoomName creates a room name for training sessions +func (s *JitsiService) generateTrainingRoomName(title string) string { + sanitized := s.sanitizeRoomName(title) + if sanitized == "" { + sanitized = "schulung" + } + return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504")) +} + +// sanitizeRoomName removes invalid characters from room names +func (s *JitsiService) sanitizeRoomName(name string) string { + // Replace spaces and special characters + result := strings.ToLower(name) + result = strings.ReplaceAll(result, " ", "-") + result = strings.ReplaceAll(result, "ä", "ae") + result = strings.ReplaceAll(result, "ö", "oe") + result = strings.ReplaceAll(result, "ü", "ue") + result = strings.ReplaceAll(result, "ß", "ss") + + // Remove any remaining non-alphanumeric characters except hyphen + var cleaned strings.Builder + for _, r := range result { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + cleaned.WriteRune(r) + } + } + + // Remove consecutive hyphens + result = cleaned.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + + // Trim hyphens from start and end + result = strings.Trim(result, "-") + + // Limit length + if len(result) > 50 { + result = result[:50] + } + + return result +} + +// generatePassword creates a random meeting password +func (s *JitsiService) generatePassword() string { + return uuid.New().String()[:8] +} + +// buildConfigParams creates URL parameters from config +func (s *JitsiService) buildConfigParams(config *MeetingConfig) string { + params := url.Values{} + + if config.StartWithAudioMuted { + params.Set("config.startWithAudioMuted", "true") + } + if config.StartWithVideoMuted { + params.Set("config.startWithVideoMuted", "true") + } + if config.DisableDeepLinking { + params.Set("config.disableDeepLinking", "true") + } + if config.RequireDisplayName { + params.Set("config.requireDisplayName", "true") + } + if config.EnableLobby { + params.Set("config.enableLobby", "true") + } + + return params.Encode() +} + +// boolToString converts bool to "true"/"false" string +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} + +// GetBaseURL returns the configured base URL +func (s *JitsiService) GetBaseURL() string { + return s.baseURL +} + +// IsAuthEnabled returns whether JWT authentication is configured +func (s *JitsiService) IsAuthEnabled() bool { + return s.appSecret != "" +} diff --git a/consent-service/internal/services/matrix/matrix_service.go b/consent-service/internal/services/matrix/matrix_service.go index 9295cb1..c2ef8f8 100644 --- a/consent-service/internal/services/matrix/matrix_service.go +++ b/consent-service/internal/services/matrix/matrix_service.go @@ -1,11 +1,9 @@ package matrix import ( - "bytes" "context" "encoding/json" "fmt" - "io" "net/http" "net/url" "time" @@ -392,157 +390,3 @@ func (s *MatrixService) SetUserPowerLevel(ctx context.Context, roomID string, us return nil } -// ======================================== -// Messaging -// ======================================== - -// SendMessage sends a text message to a room -func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error { - req := SendMessageRequest{ - MsgType: "m.text", - Body: message, - } - - return s.sendEvent(ctx, roomID, "m.room.message", req) -} - -// SendHTMLMessage sends an HTML-formatted message to a room -func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error { - req := SendMessageRequest{ - MsgType: "m.text", - Body: plainText, - Format: "org.matrix.custom.html", - FormattedBody: htmlBody, - } - - return s.sendEvent(ctx, roomID, "m.room.message", req) -} - -// SendAbsenceNotification sends an absence notification to parents -func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error { - plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber) - - htmlBody := fmt.Sprintf(`

⚠️ Abwesenheitsmeldung

-

Ihr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.

-

Bitte bestätigen Sie den Grund der Abwesenheit.

-
    -
  • ✅ Entschuldigt (Krankheit)
  • -
  • 📋 Arztbesuch
  • -
  • ❓ Sonstiges (bitte erläutern)
  • -
`, studentName, date, lessonNumber) - - return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) -} - -// SendGradeNotification sends a grade notification to parents -func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error { - plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade) - - htmlBody := fmt.Sprintf(`

📊 Neue Note eingetragen

-

Für %s wurde eine neue Note eingetragen:

- - - - -
Fach:%s
Art:%s
Note:%.1f
`, studentName, subject, gradeType, grade) - - return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) -} - -// SendClassAnnouncement sends an announcement to a class info room -func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error { - plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName) - - htmlBody := fmt.Sprintf(`

📢 %s

-

%s

-

— %s

`, title, content, teacherName) - - return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) -} - -// ======================================== -// Internal Helpers -// ======================================== - -func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error { - body, err := json.Marshal(content) - if err != nil { - return fmt.Errorf("failed to marshal content: %w", err) - } - - txnID := uuid.New().String() - endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s", - url.PathEscape(roomID), url.PathEscape(eventType), txnID) - - resp, err := s.doRequest(ctx, "PUT", endpoint, body) - if err != nil { - return fmt.Errorf("failed to send event: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return s.parseError(resp) - } - - return nil -} - -func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) { - fullURL := s.homeserverURL + endpoint - - var bodyReader io.Reader - if body != nil { - bodyReader = bytes.NewReader(body) - } - - req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+s.accessToken) - req.Header.Set("Content-Type", "application/json") - - return s.httpClient.Do(req) -} - -func (s *MatrixService) parseError(resp *http.Response) error { - body, _ := io.ReadAll(resp.Body) - var errResp struct { - ErrCode string `json:"errcode"` - Error string `json:"error"` - } - if err := json.Unmarshal(body, &errResp); err != nil { - return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) - } - return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error) -} - -// ======================================== -// Health Check -// ======================================== - -// HealthCheck checks if the Matrix server is reachable -func (s *MatrixService) HealthCheck(ctx context.Context) error { - resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil) - if err != nil { - return fmt.Errorf("matrix server unreachable: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("matrix server returned status %d", resp.StatusCode) - } - - return nil -} - -// GetServerName returns the configured server name -func (s *MatrixService) GetServerName() string { - return s.serverName -} - -// GenerateUserID generates a Matrix user ID from a username -func (s *MatrixService) GenerateUserID(username string) string { - return fmt.Sprintf("@%s:%s", username, s.serverName) -} diff --git a/consent-service/internal/services/matrix/matrix_service_messaging.go b/consent-service/internal/services/matrix/matrix_service_messaging.go new file mode 100644 index 0000000..99cd647 --- /dev/null +++ b/consent-service/internal/services/matrix/matrix_service_messaging.go @@ -0,0 +1,168 @@ +package matrix + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/google/uuid" +) + +// ======================================== +// Messaging +// ======================================== + +// SendMessage sends a text message to a room +func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error { + req := SendMessageRequest{ + MsgType: "m.text", + Body: message, + } + + return s.sendEvent(ctx, roomID, "m.room.message", req) +} + +// SendHTMLMessage sends an HTML-formatted message to a room +func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error { + req := SendMessageRequest{ + MsgType: "m.text", + Body: plainText, + Format: "org.matrix.custom.html", + FormattedBody: htmlBody, + } + + return s.sendEvent(ctx, roomID, "m.room.message", req) +} + +// SendAbsenceNotification sends an absence notification to parents +func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error { + plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber) + + htmlBody := fmt.Sprintf(`

⚠️ Abwesenheitsmeldung

+

Ihr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.

+

Bitte bestätigen Sie den Grund der Abwesenheit.

+
    +
  • ✅ Entschuldigt (Krankheit)
  • +
  • 📋 Arztbesuch
  • +
  • ❓ Sonstiges (bitte erläutern)
  • +
`, studentName, date, lessonNumber) + + return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) +} + +// SendGradeNotification sends a grade notification to parents +func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error { + plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade) + + htmlBody := fmt.Sprintf(`

📊 Neue Note eingetragen

+

Für %s wurde eine neue Note eingetragen:

+ + + + +
Fach:%s
Art:%s
Note:%.1f
`, studentName, subject, gradeType, grade) + + return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) +} + +// SendClassAnnouncement sends an announcement to a class info room +func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error { + plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName) + + htmlBody := fmt.Sprintf(`

📢 %s

+

%s

+

— %s

`, title, content, teacherName) + + return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) +} + +// ======================================== +// Internal Helpers +// ======================================== + +func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error { + body, err := json.Marshal(content) + if err != nil { + return fmt.Errorf("failed to marshal content: %w", err) + } + + txnID := uuid.New().String() + endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s", + url.PathEscape(roomID), url.PathEscape(eventType), txnID) + + resp, err := s.doRequest(ctx, "PUT", endpoint, body) + if err != nil { + return fmt.Errorf("failed to send event: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return s.parseError(resp) + } + + return nil +} + +func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) { + fullURL := s.homeserverURL + endpoint + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+s.accessToken) + req.Header.Set("Content-Type", "application/json") + + return s.httpClient.Do(req) +} + +func (s *MatrixService) parseError(resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + var errResp struct { + ErrCode string `json:"errcode"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errResp); err != nil { + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) + } + return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error) +} + +// ======================================== +// Health Check +// ======================================== + +// HealthCheck checks if the Matrix server is reachable +func (s *MatrixService) HealthCheck(ctx context.Context) error { + resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil) + if err != nil { + return fmt.Errorf("matrix server unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("matrix server returned status %d", resp.StatusCode) + } + + return nil +} + +// GetServerName returns the configured server name +func (s *MatrixService) GetServerName() string { + return s.serverName +} + +// GenerateUserID generates a Matrix user ID from a username +func (s *MatrixService) GenerateUserID(username string) string { + return fmt.Sprintf("@%s:%s", username, s.serverName) +} diff --git a/consent-service/internal/services/oauth_service.go b/consent-service/internal/services/oauth_service.go index 22abfca..92c4b71 100644 --- a/consent-service/internal/services/oauth_service.go +++ b/consent-service/internal/services/oauth_service.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -20,25 +19,25 @@ import ( ) var ( - ErrInvalidClient = errors.New("invalid_client") - ErrInvalidGrant = errors.New("invalid_grant") - ErrInvalidScope = errors.New("invalid_scope") - ErrInvalidRequest = errors.New("invalid_request") - ErrUnauthorizedClient = errors.New("unauthorized_client") - ErrAccessDenied = errors.New("access_denied") - ErrInvalidRedirectURI = errors.New("invalid redirect_uri") - ErrCodeExpired = errors.New("authorization code expired") - ErrCodeUsed = errors.New("authorization code already used") - ErrPKCERequired = errors.New("PKCE code_challenge required for public clients") - ErrPKCEVerifyFailed = errors.New("PKCE verification failed") + ErrInvalidClient = errors.New("invalid_client") + ErrInvalidGrant = errors.New("invalid_grant") + ErrInvalidScope = errors.New("invalid_scope") + ErrInvalidRequest = errors.New("invalid_request") + ErrUnauthorizedClient = errors.New("unauthorized_client") + ErrAccessDenied = errors.New("access_denied") + ErrInvalidRedirectURI = errors.New("invalid redirect_uri") + ErrCodeExpired = errors.New("authorization code expired") + ErrCodeUsed = errors.New("authorization code already used") + ErrPKCERequired = errors.New("PKCE code_challenge required for public clients") + ErrPKCEVerifyFailed = errors.New("PKCE verification failed") ) // OAuthService handles OAuth 2.0 Authorization Code Flow with PKCE type OAuthService struct { - db *pgxpool.Pool - jwtSecret string - authCodeExpiration time.Duration - accessTokenExpiration time.Duration + db *pgxpool.Pool + jwtSecret string + authCodeExpiration time.Duration + accessTokenExpiration time.Duration refreshTokenExpiration time.Duration } @@ -47,12 +46,16 @@ func NewOAuthService(db *pgxpool.Pool, jwtSecret string) *OAuthService { return &OAuthService{ db: db, jwtSecret: jwtSecret, - authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly - accessTokenExpiration: time.Hour, // 1 hour - refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days + authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly + accessTokenExpiration: time.Hour, // 1 hour + refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days } } +// ======================================== +// Client Validation +// ======================================== + // ValidateClient validates an OAuth client func (s *OAuthService) ValidateClient(ctx context.Context, clientID string) (*models.OAuthClient, error) { var client models.OAuthClient @@ -133,6 +136,10 @@ func (s *OAuthService) ValidateScopes(client *models.OAuthClient, requestedScope return validScopes, nil } +// ======================================== +// Authorization Code +// ======================================== + // GenerateAuthorizationCode generates a new authorization code func (s *OAuthService) GenerateAuthorizationCode( ctx context.Context, @@ -181,295 +188,9 @@ func (s *OAuthService) GenerateAuthorizationCode( return code, nil } -// ExchangeAuthorizationCode exchanges an authorization code for tokens -func (s *OAuthService) ExchangeAuthorizationCode( - ctx context.Context, - code string, - clientID string, - redirectURI string, - codeVerifier string, -) (*models.OAuthTokenResponse, error) { - // Hash the code to look it up - codeHash := sha256.Sum256([]byte(code)) - hashedCode := hex.EncodeToString(codeHash[:]) - - var authCode models.OAuthAuthorizationCode - var scopesJSON []byte - - err := s.db.QueryRow(ctx, ` - SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at - FROM oauth_authorization_codes WHERE code = $1 - `, hashedCode).Scan( - &authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI, - &scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod, - &authCode.ExpiresAt, &authCode.UsedAt, - ) - - if err != nil { - return nil, ErrInvalidGrant - } - - // Check if code was already used - if authCode.UsedAt != nil { - return nil, ErrCodeUsed - } - - // Check if code is expired - if time.Now().After(authCode.ExpiresAt) { - return nil, ErrCodeExpired - } - - // Verify client_id matches - if authCode.ClientID != clientID { - return nil, ErrInvalidGrant - } - - // Verify redirect_uri matches - if authCode.RedirectURI != redirectURI { - return nil, ErrInvalidGrant - } - - // Verify PKCE if code_challenge was provided - if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" { - if codeVerifier == "" { - return nil, ErrPKCEVerifyFailed - } - - var expectedChallenge string - if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" { - // SHA256 hash of verifier - hash := sha256.Sum256([]byte(codeVerifier)) - expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:]) - } else { - // Plain method - expectedChallenge = codeVerifier - } - - if expectedChallenge != *authCode.CodeChallenge { - return nil, ErrPKCEVerifyFailed - } - } - - // Mark code as used - _, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID) - if err != nil { - return nil, fmt.Errorf("failed to mark code as used: %w", err) - } - - // Parse scopes - var scopes []string - json.Unmarshal(scopesJSON, &scopes) - - // Generate tokens - return s.generateTokens(ctx, clientID, authCode.UserID, scopes) -} - -// RefreshAccessToken refreshes an access token using a refresh token -func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) { - // Hash the refresh token - tokenHash := sha256.Sum256([]byte(refreshToken)) - hashedToken := hex.EncodeToString(tokenHash[:]) - - var rt models.OAuthRefreshToken - var scopesJSON []byte - - err := s.db.QueryRow(ctx, ` - SELECT id, client_id, user_id, scopes, expires_at, revoked_at - FROM oauth_refresh_tokens WHERE token_hash = $1 - `, hashedToken).Scan( - &rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt, - ) - - if err != nil { - return nil, ErrInvalidGrant - } - - // Check if token is revoked - if rt.RevokedAt != nil { - return nil, ErrInvalidGrant - } - - // Check if token is expired - if time.Now().After(rt.ExpiresAt) { - return nil, ErrInvalidGrant - } - - // Verify client_id matches - if rt.ClientID != clientID { - return nil, ErrInvalidGrant - } - - // Parse original scopes - var originalScopes []string - json.Unmarshal(scopesJSON, &originalScopes) - - // Determine scopes for new tokens - var scopes []string - if requestedScope != "" { - // Validate that requested scopes are subset of original scopes - originalMap := make(map[string]bool) - for _, s := range originalScopes { - originalMap[s] = true - } - - for _, s := range strings.Split(requestedScope, " ") { - if originalMap[s] { - scopes = append(scopes, s) - } - } - - if len(scopes) == 0 { - return nil, ErrInvalidScope - } - } else { - scopes = originalScopes - } - - // Revoke old refresh token (rotate) - _, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID) - - // Generate new tokens - return s.generateTokens(ctx, clientID, rt.UserID, scopes) -} - -// generateTokens generates access and refresh tokens -func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) { - // Get user info for JWT - var user models.User - err := s.db.QueryRow(ctx, ` - SELECT id, email, name, role, account_status FROM users WHERE id = $1 - `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus) - - if err != nil { - return nil, ErrInvalidGrant - } - - // Generate access token (JWT) - accessTokenClaims := jwt.MapClaims{ - "sub": userID.String(), - "email": user.Email, - "role": user.Role, - "account_status": user.AccountStatus, - "client_id": clientID, - "scope": strings.Join(scopes, " "), - "iat": time.Now().Unix(), - "exp": time.Now().Add(s.accessTokenExpiration).Unix(), - "iss": "breakpilot-consent-service", - "aud": clientID, - } - - if user.Name != nil { - accessTokenClaims["name"] = *user.Name - } - - accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims) - accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret)) - if err != nil { - return nil, fmt.Errorf("failed to sign access token: %w", err) - } - - // Hash access token for storage - accessTokenHash := sha256.Sum256([]byte(accessTokenString)) - hashedAccessToken := hex.EncodeToString(accessTokenHash[:]) - - scopesJSON, _ := json.Marshal(scopes) - - // Store access token - var accessTokenID uuid.UUID - err = s.db.QueryRow(ctx, ` - INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id - `, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID) - - if err != nil { - return nil, fmt.Errorf("failed to store access token: %w", err) - } - - // Generate refresh token (opaque) - refreshTokenBytes := make([]byte, 32) - if _, err := rand.Read(refreshTokenBytes); err != nil { - return nil, fmt.Errorf("failed to generate refresh token: %w", err) - } - refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes) - - // Hash refresh token for storage - refreshTokenHash := sha256.Sum256([]byte(refreshTokenString)) - hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:]) - - // Store refresh token - _, err = s.db.Exec(ctx, ` - INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration)) - - if err != nil { - return nil, fmt.Errorf("failed to store refresh token: %w", err) - } - - return &models.OAuthTokenResponse{ - AccessToken: accessTokenString, - TokenType: "Bearer", - ExpiresIn: int(s.accessTokenExpiration.Seconds()), - RefreshToken: refreshTokenString, - Scope: strings.Join(scopes, " "), - }, nil -} - -// RevokeToken revokes an access or refresh token -func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error { - tokenHash := sha256.Sum256([]byte(token)) - hashedToken := hex.EncodeToString(tokenHash[:]) - - // Try to revoke as access token - if tokenTypeHint == "" || tokenTypeHint == "access_token" { - result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken) - if err == nil && result.RowsAffected() > 0 { - return nil - } - } - - // Try to revoke as refresh token - if tokenTypeHint == "" || tokenTypeHint == "refresh_token" { - result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken) - if err == nil && result.RowsAffected() > 0 { - return nil - } - } - - return nil // RFC 7009: Always return success -} - -// ValidateAccessToken validates an OAuth access token -func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(s.jwtSecret), nil - }) - - if err != nil { - return nil, ErrInvalidToken - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok || !token.Valid { - return nil, ErrInvalidToken - } - - // Check if token is revoked in database - tokenHash := sha256.Sum256([]byte(tokenString)) - hashedToken := hex.EncodeToString(tokenHash[:]) - - var revokedAt *time.Time - err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt) - if err == nil && revokedAt != nil { - return nil, ErrInvalidToken - } - - return &claims, nil -} +// ======================================== +// Client Management (Admin) +// ======================================== // GetClientByID retrieves an OAuth client by its client_id func (s *OAuthService) GetClientByID(ctx context.Context, clientID string) (*models.OAuthClient, error) { diff --git a/consent-service/internal/services/oauth_token_service.go b/consent-service/internal/services/oauth_token_service.go new file mode 100644 index 0000000..44df7d4 --- /dev/null +++ b/consent-service/internal/services/oauth_token_service.go @@ -0,0 +1,308 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/breakpilot/consent-service/internal/models" +) + +// ExchangeAuthorizationCode exchanges an authorization code for tokens +func (s *OAuthService) ExchangeAuthorizationCode( + ctx context.Context, + code string, + clientID string, + redirectURI string, + codeVerifier string, +) (*models.OAuthTokenResponse, error) { + // Hash the code to look it up + codeHash := sha256.Sum256([]byte(code)) + hashedCode := hex.EncodeToString(codeHash[:]) + + var authCode models.OAuthAuthorizationCode + var scopesJSON []byte + + err := s.db.QueryRow(ctx, ` + SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at + FROM oauth_authorization_codes WHERE code = $1 + `, hashedCode).Scan( + &authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI, + &scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod, + &authCode.ExpiresAt, &authCode.UsedAt, + ) + + if err != nil { + return nil, ErrInvalidGrant + } + + // Check if code was already used + if authCode.UsedAt != nil { + return nil, ErrCodeUsed + } + + // Check if code is expired + if time.Now().After(authCode.ExpiresAt) { + return nil, ErrCodeExpired + } + + // Verify client_id matches + if authCode.ClientID != clientID { + return nil, ErrInvalidGrant + } + + // Verify redirect_uri matches + if authCode.RedirectURI != redirectURI { + return nil, ErrInvalidGrant + } + + // Verify PKCE if code_challenge was provided + if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" { + if codeVerifier == "" { + return nil, ErrPKCEVerifyFailed + } + + var expectedChallenge string + if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" { + // SHA256 hash of verifier + hash := sha256.Sum256([]byte(codeVerifier)) + expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:]) + } else { + // Plain method + expectedChallenge = codeVerifier + } + + if expectedChallenge != *authCode.CodeChallenge { + return nil, ErrPKCEVerifyFailed + } + } + + // Mark code as used + _, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID) + if err != nil { + return nil, fmt.Errorf("failed to mark code as used: %w", err) + } + + // Parse scopes + var scopes []string + json.Unmarshal(scopesJSON, &scopes) + + // Generate tokens + return s.generateTokens(ctx, clientID, authCode.UserID, scopes) +} + +// RefreshAccessToken refreshes an access token using a refresh token +func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) { + // Hash the refresh token + tokenHash := sha256.Sum256([]byte(refreshToken)) + hashedToken := hex.EncodeToString(tokenHash[:]) + + var rt models.OAuthRefreshToken + var scopesJSON []byte + + err := s.db.QueryRow(ctx, ` + SELECT id, client_id, user_id, scopes, expires_at, revoked_at + FROM oauth_refresh_tokens WHERE token_hash = $1 + `, hashedToken).Scan( + &rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt, + ) + + if err != nil { + return nil, ErrInvalidGrant + } + + // Check if token is revoked + if rt.RevokedAt != nil { + return nil, ErrInvalidGrant + } + + // Check if token is expired + if time.Now().After(rt.ExpiresAt) { + return nil, ErrInvalidGrant + } + + // Verify client_id matches + if rt.ClientID != clientID { + return nil, ErrInvalidGrant + } + + // Parse original scopes + var originalScopes []string + json.Unmarshal(scopesJSON, &originalScopes) + + // Determine scopes for new tokens + var scopes []string + if requestedScope != "" { + // Validate that requested scopes are subset of original scopes + originalMap := make(map[string]bool) + for _, s := range originalScopes { + originalMap[s] = true + } + + for _, s := range strings.Split(requestedScope, " ") { + if originalMap[s] { + scopes = append(scopes, s) + } + } + + if len(scopes) == 0 { + return nil, ErrInvalidScope + } + } else { + scopes = originalScopes + } + + // Revoke old refresh token (rotate) + _, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID) + + // Generate new tokens + return s.generateTokens(ctx, clientID, rt.UserID, scopes) +} + +// generateTokens generates access and refresh tokens +func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) { + // Get user info for JWT + var user models.User + err := s.db.QueryRow(ctx, ` + SELECT id, email, name, role, account_status FROM users WHERE id = $1 + `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus) + + if err != nil { + return nil, ErrInvalidGrant + } + + // Generate access token (JWT) + accessTokenClaims := jwt.MapClaims{ + "sub": userID.String(), + "email": user.Email, + "role": user.Role, + "account_status": user.AccountStatus, + "client_id": clientID, + "scope": strings.Join(scopes, " "), + "iat": time.Now().Unix(), + "exp": time.Now().Add(s.accessTokenExpiration).Unix(), + "iss": "breakpilot-consent-service", + "aud": clientID, + } + + if user.Name != nil { + accessTokenClaims["name"] = *user.Name + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims) + accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret)) + if err != nil { + return nil, fmt.Errorf("failed to sign access token: %w", err) + } + + // Hash access token for storage + accessTokenHash := sha256.Sum256([]byte(accessTokenString)) + hashedAccessToken := hex.EncodeToString(accessTokenHash[:]) + + scopesJSON, _ := json.Marshal(scopes) + + // Store access token + var accessTokenID uuid.UUID + err = s.db.QueryRow(ctx, ` + INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID) + + if err != nil { + return nil, fmt.Errorf("failed to store access token: %w", err) + } + + // Generate refresh token (opaque) + refreshTokenBytes := make([]byte, 32) + if _, err := rand.Read(refreshTokenBytes); err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes) + + // Hash refresh token for storage + refreshTokenHash := sha256.Sum256([]byte(refreshTokenString)) + hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:]) + + // Store refresh token + _, err = s.db.Exec(ctx, ` + INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration)) + + if err != nil { + return nil, fmt.Errorf("failed to store refresh token: %w", err) + } + + return &models.OAuthTokenResponse{ + AccessToken: accessTokenString, + TokenType: "Bearer", + ExpiresIn: int(s.accessTokenExpiration.Seconds()), + RefreshToken: refreshTokenString, + Scope: strings.Join(scopes, " "), + }, nil +} + +// RevokeToken revokes an access or refresh token +func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error { + tokenHash := sha256.Sum256([]byte(token)) + hashedToken := hex.EncodeToString(tokenHash[:]) + + // Try to revoke as access token + if tokenTypeHint == "" || tokenTypeHint == "access_token" { + result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken) + if err == nil && result.RowsAffected() > 0 { + return nil + } + } + + // Try to revoke as refresh token + if tokenTypeHint == "" || tokenTypeHint == "refresh_token" { + result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken) + if err == nil && result.RowsAffected() > 0 { + return nil + } + } + + return nil // RFC 7009: Always return success +} + +// ValidateAccessToken validates an OAuth access token +func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.jwtSecret), nil + }) + + if err != nil { + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + + // Check if token is revoked in database + tokenHash := sha256.Sum256([]byte(tokenString)) + hashedToken := hex.EncodeToString(tokenHash[:]) + + var revokedAt *time.Time + err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt) + if err == nil && revokedAt != nil { + return nil, ErrInvalidToken + } + + return &claims, nil +} diff --git a/consent-service/internal/services/school_service.go b/consent-service/internal/services/school_service.go index 3d63f00..b07e0f9 100644 --- a/consent-service/internal/services/school_service.go +++ b/consent-service/internal/services/school_service.go @@ -2,8 +2,6 @@ package services import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "time" @@ -35,21 +33,21 @@ func NewSchoolService(db *database.DB, matrixService *matrix.MatrixService) *Sch // CreateSchool creates a new school func (s *SchoolService) CreateSchool(ctx context.Context, req models.CreateSchoolRequest) (*models.School, error) { school := &models.School{ - ID: uuid.New(), - Name: req.Name, - ShortName: req.ShortName, - Type: req.Type, - Address: req.Address, - City: req.City, + ID: uuid.New(), + Name: req.Name, + ShortName: req.ShortName, + Type: req.Type, + Address: req.Address, + City: req.City, PostalCode: req.PostalCode, - State: req.State, - Country: "DE", - Phone: req.Phone, - Email: req.Email, - Website: req.Website, - IsActive: true, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + State: req.State, + Country: "DE", + Phone: req.Phone, + Email: req.Email, + Website: req.Website, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } query := ` @@ -298,350 +296,6 @@ func (s *SchoolService) ListClasses(ctx context.Context, schoolID, schoolYearID return classes, nil } -// ======================================== -// Student Management -// ======================================== - -// CreateStudent creates a new student -func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) { - classID, err := uuid.Parse(req.ClassID) - if err != nil { - return nil, fmt.Errorf("invalid class ID: %w", err) - } - - student := &models.Student{ - ID: uuid.New(), - SchoolID: schoolID, - ClassID: classID, - StudentNumber: req.StudentNumber, - FirstName: req.FirstName, - LastName: req.LastName, - Gender: req.Gender, - IsActive: true, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if req.DateOfBirth != nil { - dob, err := time.Parse("2006-01-02", *req.DateOfBirth) - if err == nil { - student.DateOfBirth = &dob - } - } - - query := ` - INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id` - - err = s.db.Pool.QueryRow(ctx, query, - student.ID, student.SchoolID, student.ClassID, student.StudentNumber, - student.FirstName, student.LastName, student.DateOfBirth, student.Gender, - student.IsActive, student.CreatedAt, student.UpdatedAt, - ).Scan(&student.ID) - - if err != nil { - return nil, fmt.Errorf("failed to create student: %w", err) - } - - return student, nil -} - -// GetStudent retrieves a student by ID -func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) { - query := ` - SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at - FROM students - WHERE id = $1` - - student := &models.Student{} - err := s.db.Pool.QueryRow(ctx, query, studentID).Scan( - &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, - &student.StudentNumber, &student.FirstName, &student.LastName, - &student.DateOfBirth, &student.Gender, &student.MatrixUserID, - &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, - ) - - if err != nil { - return nil, fmt.Errorf("failed to get student: %w", err) - } - - return student, nil -} - -// ListStudentsByClass lists all students in a class -func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) { - query := ` - SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at - FROM students - WHERE class_id = $1 AND is_active = true - ORDER BY last_name, first_name` - - rows, err := s.db.Pool.Query(ctx, query, classID) - if err != nil { - return nil, fmt.Errorf("failed to list students: %w", err) - } - defer rows.Close() - - var students []models.Student - for rows.Next() { - var student models.Student - err := rows.Scan( - &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, - &student.StudentNumber, &student.FirstName, &student.LastName, - &student.DateOfBirth, &student.Gender, &student.MatrixUserID, - &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan student: %w", err) - } - students = append(students, student) - } - - return students, nil -} - -// ======================================== -// Teacher Management -// ======================================== - -// CreateTeacher creates a new teacher linked to a user account -func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) { - teacher := &models.Teacher{ - ID: uuid.New(), - SchoolID: schoolID, - UserID: userID, - TeacherCode: teacherCode, - Title: title, - FirstName: firstName, - LastName: lastName, - IsActive: true, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - query := ` - INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id` - - err := s.db.Pool.QueryRow(ctx, query, - teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode, - teacher.Title, teacher.FirstName, teacher.LastName, - teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt, - ).Scan(&teacher.ID) - - if err != nil { - return nil, fmt.Errorf("failed to create teacher: %w", err) - } - - return teacher, nil -} - -// GetTeacher retrieves a teacher by ID -func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) { - query := ` - SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at - FROM teachers - WHERE id = $1` - - teacher := &models.Teacher{} - err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan( - &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, - &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, - &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, - ) - - if err != nil { - return nil, fmt.Errorf("failed to get teacher: %w", err) - } - - return teacher, nil -} - -// GetTeacherByUserID retrieves a teacher by their user ID -func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) { - query := ` - SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at - FROM teachers - WHERE user_id = $1 AND is_active = true` - - teacher := &models.Teacher{} - err := s.db.Pool.QueryRow(ctx, query, userID).Scan( - &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, - &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, - &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, - ) - - if err != nil { - return nil, fmt.Errorf("failed to get teacher by user ID: %w", err) - } - - return teacher, nil -} - -// AssignClassTeacher assigns a teacher to a class -func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error { - query := ` - INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary` - - _, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now()) - if err != nil { - return fmt.Errorf("failed to assign class teacher: %w", err) - } - - return nil -} - -// ======================================== -// Subject Management -// ======================================== - -// CreateSubject creates a new subject -func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) { - subject := &models.Subject{ - ID: uuid.New(), - SchoolID: schoolID, - Name: name, - ShortName: shortName, - Color: color, - IsActive: true, - CreatedAt: time.Now(), - } - - query := ` - INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id` - - err := s.db.Pool.QueryRow(ctx, query, - subject.ID, subject.SchoolID, subject.Name, subject.ShortName, - subject.Color, subject.IsActive, subject.CreatedAt, - ).Scan(&subject.ID) - - if err != nil { - return nil, fmt.Errorf("failed to create subject: %w", err) - } - - return subject, nil -} - -// ListSubjects lists all subjects for a school -func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) { - query := ` - SELECT id, school_id, name, short_name, color, is_active, created_at - FROM subjects - WHERE school_id = $1 AND is_active = true - ORDER BY name` - - rows, err := s.db.Pool.Query(ctx, query, schoolID) - if err != nil { - return nil, fmt.Errorf("failed to list subjects: %w", err) - } - defer rows.Close() - - var subjects []models.Subject - for rows.Next() { - var subject models.Subject - err := rows.Scan( - &subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, - &subject.Color, &subject.IsActive, &subject.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan subject: %w", err) - } - subjects = append(subjects, subject) - } - - return subjects, nil -} - -// ======================================== -// Parent Onboarding -// ======================================== - -// GenerateParentOnboardingToken generates a QR code token for parent onboarding -func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) { - // Generate secure random token - tokenBytes := make([]byte, 32) - if _, err := rand.Read(tokenBytes); err != nil { - return nil, fmt.Errorf("failed to generate token: %w", err) - } - token := hex.EncodeToString(tokenBytes) - - onboardingToken := &models.ParentOnboardingToken{ - ID: uuid.New(), - SchoolID: schoolID, - ClassID: classID, - StudentID: studentID, - Token: token, - Role: role, - ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours - CreatedAt: time.Now(), - CreatedBy: createdByUserID, - } - - query := ` - INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id` - - err := s.db.Pool.QueryRow(ctx, query, - onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID, - onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role, - onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy, - ).Scan(&onboardingToken.ID) - - if err != nil { - return nil, fmt.Errorf("failed to create onboarding token: %w", err) - } - - return onboardingToken, nil -} - -// ValidateOnboardingToken validates and retrieves info for an onboarding token -func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) { - query := ` - SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by - FROM parent_onboarding_tokens - WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()` - - onboardingToken := &models.ParentOnboardingToken{} - err := s.db.Pool.QueryRow(ctx, query, token).Scan( - &onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID, - &onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role, - &onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID, - &onboardingToken.CreatedAt, &onboardingToken.CreatedBy, - ) - - if err != nil { - return nil, fmt.Errorf("invalid or expired token: %w", err) - } - - return onboardingToken, nil -} - -// RedeemOnboardingToken marks a token as used and creates the parent account -func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error { - query := ` - UPDATE parent_onboarding_tokens - SET used_at = NOW(), used_by_user_id = $1 - WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()` - - result, err := s.db.Pool.Exec(ctx, query, userID, token) - if err != nil { - return fmt.Errorf("failed to redeem token: %w", err) - } - - if result.RowsAffected() == 0 { - return fmt.Errorf("token not found or already used") - } - - return nil -} - // ======================================== // Helper Functions // ======================================== diff --git a/consent-service/internal/services/school_service_members.go b/consent-service/internal/services/school_service_members.go new file mode 100644 index 0000000..bf490f1 --- /dev/null +++ b/consent-service/internal/services/school_service_members.go @@ -0,0 +1,357 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/google/uuid" +) + +// ======================================== +// Student Management +// ======================================== + +// CreateStudent creates a new student +func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) { + classID, err := uuid.Parse(req.ClassID) + if err != nil { + return nil, fmt.Errorf("invalid class ID: %w", err) + } + + student := &models.Student{ + ID: uuid.New(), + SchoolID: schoolID, + ClassID: classID, + StudentNumber: req.StudentNumber, + FirstName: req.FirstName, + LastName: req.LastName, + Gender: req.Gender, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if req.DateOfBirth != nil { + dob, err := time.Parse("2006-01-02", *req.DateOfBirth) + if err == nil { + student.DateOfBirth = &dob + } + } + + query := ` + INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + student.ID, student.SchoolID, student.ClassID, student.StudentNumber, + student.FirstName, student.LastName, student.DateOfBirth, student.Gender, + student.IsActive, student.CreatedAt, student.UpdatedAt, + ).Scan(&student.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create student: %w", err) + } + + return student, nil +} + +// GetStudent retrieves a student by ID +func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) { + query := ` + SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at + FROM students + WHERE id = $1` + + student := &models.Student{} + err := s.db.Pool.QueryRow(ctx, query, studentID).Scan( + &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, + &student.StudentNumber, &student.FirstName, &student.LastName, + &student.DateOfBirth, &student.Gender, &student.MatrixUserID, + &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get student: %w", err) + } + + return student, nil +} + +// ListStudentsByClass lists all students in a class +func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) { + query := ` + SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at + FROM students + WHERE class_id = $1 AND is_active = true + ORDER BY last_name, first_name` + + rows, err := s.db.Pool.Query(ctx, query, classID) + if err != nil { + return nil, fmt.Errorf("failed to list students: %w", err) + } + defer rows.Close() + + var students []models.Student + for rows.Next() { + var student models.Student + err := rows.Scan( + &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, + &student.StudentNumber, &student.FirstName, &student.LastName, + &student.DateOfBirth, &student.Gender, &student.MatrixUserID, + &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan student: %w", err) + } + students = append(students, student) + } + + return students, nil +} + +// ======================================== +// Teacher Management +// ======================================== + +// CreateTeacher creates a new teacher linked to a user account +func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) { + teacher := &models.Teacher{ + ID: uuid.New(), + SchoolID: schoolID, + UserID: userID, + TeacherCode: teacherCode, + Title: title, + FirstName: firstName, + LastName: lastName, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode, + teacher.Title, teacher.FirstName, teacher.LastName, + teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt, + ).Scan(&teacher.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create teacher: %w", err) + } + + return teacher, nil +} + +// GetTeacher retrieves a teacher by ID +func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) { + query := ` + SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at + FROM teachers + WHERE id = $1` + + teacher := &models.Teacher{} + err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan( + &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, + &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, + &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get teacher: %w", err) + } + + return teacher, nil +} + +// GetTeacherByUserID retrieves a teacher by their user ID +func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) { + query := ` + SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at + FROM teachers + WHERE user_id = $1 AND is_active = true` + + teacher := &models.Teacher{} + err := s.db.Pool.QueryRow(ctx, query, userID).Scan( + &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, + &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, + &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get teacher by user ID: %w", err) + } + + return teacher, nil +} + +// AssignClassTeacher assigns a teacher to a class +func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error { + query := ` + INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary` + + _, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now()) + if err != nil { + return fmt.Errorf("failed to assign class teacher: %w", err) + } + + return nil +} + +// ======================================== +// Subject Management +// ======================================== + +// CreateSubject creates a new subject +func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) { + subject := &models.Subject{ + ID: uuid.New(), + SchoolID: schoolID, + Name: name, + ShortName: shortName, + Color: color, + IsActive: true, + CreatedAt: time.Now(), + } + + query := ` + INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + subject.ID, subject.SchoolID, subject.Name, subject.ShortName, + subject.Color, subject.IsActive, subject.CreatedAt, + ).Scan(&subject.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create subject: %w", err) + } + + return subject, nil +} + +// ListSubjects lists all subjects for a school +func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) { + query := ` + SELECT id, school_id, name, short_name, color, is_active, created_at + FROM subjects + WHERE school_id = $1 AND is_active = true + ORDER BY name` + + rows, err := s.db.Pool.Query(ctx, query, schoolID) + if err != nil { + return nil, fmt.Errorf("failed to list subjects: %w", err) + } + defer rows.Close() + + var subjects []models.Subject + for rows.Next() { + var subject models.Subject + err := rows.Scan( + &subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, + &subject.Color, &subject.IsActive, &subject.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan subject: %w", err) + } + subjects = append(subjects, subject) + } + + return subjects, nil +} + +// ======================================== +// Parent Onboarding +// ======================================== + +// GenerateParentOnboardingToken generates a QR code token for parent onboarding +func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) { + // Generate secure random token + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + token := hex.EncodeToString(tokenBytes) + + onboardingToken := &models.ParentOnboardingToken{ + ID: uuid.New(), + SchoolID: schoolID, + ClassID: classID, + StudentID: studentID, + Token: token, + Role: role, + ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours + CreatedAt: time.Now(), + CreatedBy: createdByUserID, + } + + query := ` + INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID, + onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role, + onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy, + ).Scan(&onboardingToken.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create onboarding token: %w", err) + } + + return onboardingToken, nil +} + +// ValidateOnboardingToken validates and retrieves info for an onboarding token +func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) { + query := ` + SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by + FROM parent_onboarding_tokens + WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()` + + onboardingToken := &models.ParentOnboardingToken{} + err := s.db.Pool.QueryRow(ctx, query, token).Scan( + &onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID, + &onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role, + &onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID, + &onboardingToken.CreatedAt, &onboardingToken.CreatedBy, + ) + + if err != nil { + return nil, fmt.Errorf("invalid or expired token: %w", err) + } + + return onboardingToken, nil +} + +// RedeemOnboardingToken marks a token as used and creates the parent account +func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error { + query := ` + UPDATE parent_onboarding_tokens + SET used_at = NOW(), used_by_user_id = $1 + WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()` + + result, err := s.db.Pool.Exec(ctx, query, userID, token) + if err != nil { + return fmt.Errorf("failed to redeem token: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("token not found or already used") + } + + return nil +} diff --git a/pitch-deck/app/pitch-admin/(authed)/versions/[id]/_components/TabEditors.tsx b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/_components/TabEditors.tsx new file mode 100644 index 0000000..5a33852 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/_components/TabEditors.tsx @@ -0,0 +1,382 @@ +'use client' + +import BilingualField from '@/components/pitch-admin/editors/BilingualField' +import FormField from '@/components/pitch-admin/editors/FormField' +import ArrayField from '@/components/pitch-admin/editors/ArrayField' +import RowTable from '@/components/pitch-admin/editors/RowTable' +import CardList from '@/components/pitch-admin/editors/CardList' + +type R = Record + +interface TabEditorProps { + activeTab: string + data: unknown[] + single: R + jsonMode: boolean + jsonText: string + isDraft: boolean + onJsonTextChange: (text: string) => void + onDirty: () => void + updateData: (newData: unknown[]) => void + updateRecord: (index: number, key: string, value: unknown) => void + updateSingle: (key: string, value: unknown) => void +} + +export default function TabEditor({ + activeTab, + data, + single, + jsonMode, + jsonText, + isDraft, + onJsonTextChange, + onDirty, + updateData, + updateRecord, + updateSingle, +}: TabEditorProps) { + if (jsonMode) { + return ( +