diff --git a/admin-compliance/app/sdk/cmp/page.tsx b/admin-compliance/app/sdk/cmp/page.tsx index 9ee4f96..0ea9075 100644 --- a/admin-compliance/app/sdk/cmp/page.tsx +++ b/admin-compliance/app/sdk/cmp/page.tsx @@ -174,6 +174,44 @@ export default function CMPDashboardPage() { + {/* Banner-Bedarf Hinweis (TTDSG § 25) */} + {bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && ( +
+
+ +
+
+

Kein Cookie-Banner erforderlich

+

+ Es wurden keine Cookies, Tracker oder Analytics-Dienste erkannt. Gemaess TTDSG § 25 ist kein + Cookie-Banner erforderlich, da keine Informationen auf dem Endgeraet gespeichert werden. +

+

+ Weiterhin Pflicht: Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13) +

+
+
+ )} + + {/* Banner-Warnung wenn Tracker ohne Banner */} + {bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && ( +
+
+ +
+
+

Cookie-Banner fehlt!

+

+ Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert. + Gemaess TTDSG § 25 ist eine Einwilligung erforderlich. +

+ + Jetzt Cookie-Banner einrichten + +
+
+ )} + {/* Compliance Status */}

Compliance-Status

diff --git a/admin-compliance/lib/sdk/whistleblower/api-operations.ts b/admin-compliance/lib/sdk/whistleblower/api-operations.ts index f5363a3..aa059cb 100644 --- a/admin-compliance/lib/sdk/whistleblower/api-operations.ts +++ b/admin-compliance/lib/sdk/whistleblower/api-operations.ts @@ -21,7 +21,8 @@ import { // CONFIGURATION // ============================================================================= -const WB_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093' +// Use compliance backend proxy (Python) instead of Go SDK +const WB_API_BASE = '/api/sdk/v1/compliance' const API_TIMEOUT = 30000 // ============================================================================= @@ -121,27 +122,27 @@ export async function fetchReports(filters?: ReportFilters): Promise(url) } export async function fetchReport(id: string): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}` + `${WB_API_BASE}/whistleblower/reports/${id}` ) } export async function updateReport(id: string, update: ReportUpdateRequest): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`, + `${WB_API_BASE}/whistleblower/reports/${id}`, { method: 'PUT', body: JSON.stringify(update) } ) } export async function deleteReport(id: string): Promise { await fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`, + `${WB_API_BASE}/whistleblower/reports/${id}`, { method: 'DELETE' } ) } @@ -154,7 +155,7 @@ export async function submitPublicReport( data: PublicReportSubmission ): Promise<{ report: WhistleblowerReport; accessKey: string }> { const response = await fetch( - `${WB_API_BASE}/api/v1/public/whistleblower/submit`, + `${WB_API_BASE}/whistleblower/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -173,7 +174,7 @@ export async function fetchReportByAccessKey( accessKey: string ): Promise { const response = await fetch( - `${WB_API_BASE}/api/v1/public/whistleblower/report/${accessKey}`, + `${WB_API_BASE}/whistleblower/check/${accessKey}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } } ) @@ -190,14 +191,14 @@ export async function fetchReportByAccessKey( export async function acknowledgeReport(id: string): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`, + `${WB_API_BASE}/whistleblower/reports/${id}/acknowledge`, { method: 'POST' } ) } export async function startInvestigation(id: string): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/investigate`, + `${WB_API_BASE}/whistleblower/reports/${id}/investigate`, { method: 'POST' } ) } @@ -207,7 +208,7 @@ export async function addMeasure( measure: Omit ): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/measures`, + `${WB_API_BASE}/whistleblower/reports/${id}/measures`, { method: 'POST', body: JSON.stringify(measure) } ) } @@ -217,7 +218,7 @@ export async function closeReport( resolution: { reason: string; notes: string } ): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/close`, + `${WB_API_BASE}/whistleblower/reports/${id}/close`, { method: 'POST', body: JSON.stringify(resolution) } ) } @@ -232,14 +233,14 @@ export async function sendMessage( role: 'reporter' | 'ombudsperson' ): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`, + `${WB_API_BASE}/whistleblower/reports/${reportId}/messages`, { method: 'POST', body: JSON.stringify({ senderRole: role, message }) } ) } export async function fetchMessages(reportId: string): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages` + `${WB_API_BASE}/whistleblower/reports/${reportId}/messages` ) } @@ -269,7 +270,7 @@ export async function uploadAttachment( } const response = await fetch( - `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/attachments`, + `${WB_API_BASE}/whistleblower/reports/${reportId}/attachments`, { method: 'POST', headers, @@ -290,7 +291,7 @@ export async function uploadAttachment( export async function deleteAttachment(id: string): Promise { await fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`, + `${WB_API_BASE}/whistleblower/attachments/${id}`, { method: 'DELETE' } ) } @@ -301,6 +302,6 @@ export async function deleteAttachment(id: string): Promise { export async function fetchWhistleblowerStatistics(): Promise { return fetchWithTimeout( - `${WB_API_BASE}/api/v1/admin/whistleblower/statistics` + `${WB_API_BASE}/whistleblower/reports/stats` ) } diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index 490b743..fbd0deb 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -68,6 +68,7 @@ _ROUTER_MODULES = [ "banner_analytics_routes", "banner_ab_routes", "compliance_report_routes", + "whistleblower_routes", ] _loaded_count = 0 diff --git a/backend-compliance/compliance/api/whistleblower_routes.py b/backend-compliance/compliance/api/whistleblower_routes.py new file mode 100644 index 0000000..1326cea --- /dev/null +++ b/backend-compliance/compliance/api/whistleblower_routes.py @@ -0,0 +1,310 @@ +""" +FastAPI routes for Whistleblower (HinSchG) — Hinweisgeberschutz. + +Admin endpoints for managing reports + public endpoint for anonymous submissions. +Deadlines: 7 days acknowledgment (§ 17 Abs. 1), 3 months feedback (§ 17 Abs. 2). + +Endpoints: + GET /whistleblower/reports — list with filters + GET /whistleblower/reports/stats — counts by status/category + POST /whistleblower/reports — create report (admin) + GET /whistleblower/reports/{id} — single report with messages + PUT /whistleblower/reports/{id} — update status/priority/assignment + POST /whistleblower/reports/{id}/acknowledge — send acknowledgment + POST /whistleblower/reports/{id}/close — close report + POST /whistleblower/reports/{id}/messages — add message + GET /whistleblower/reports/{id}/measures — list measures + POST /whistleblower/reports/{id}/measures — add measure + POST /whistleblower/submit — public anonymous submission + GET /whistleblower/check/{access_key} — reporter checks status +""" + +import logging +import secrets +import string +from datetime import datetime, timedelta, timezone +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/whistleblower", tags=["whistleblower"]) + +VALID_CATEGORIES = {"corruption", "fraud", "data_protection", "discrimination", + "environment", "competition", "product_safety", "tax_evasion", "other"} +VALID_STATUSES = {"new", "acknowledged", "under_review", "investigation", + "measures_taken", "closed", "rejected"} + + +def _gen_ref(tenant_id: str, db: Session) -> str: + year = datetime.now().year + q = text("SELECT COUNT(*) FROM compliance_whistleblower_reports WHERE tenant_id = :tid") + count = db.execute(q, {"tid": tenant_id}).scalar() or 0 + return f"WB-{year}-{count + 1:06d}" + + +def _gen_access_key() -> str: + chars = string.ascii_uppercase + string.digits + parts = [''.join(secrets.choice(chars) for _ in range(4)) for _ in range(3)] + return '-'.join(parts) + + +# ============================================================================= +# Schemas +# ============================================================================= + +class ReportCreate(BaseModel): + category: str = "other" + title: str + description: str + is_anonymous: bool = True + reporter_name: Optional[str] = None + reporter_email: Optional[str] = None + reporter_phone: Optional[str] = None + priority: str = "normal" + +class ReportUpdate(BaseModel): + status: Optional[str] = None + priority: Optional[str] = None + assigned_to: Optional[str] = None + category: Optional[str] = None + +class MessageCreate(BaseModel): + message: str + sender_type: str = "admin" + is_internal: bool = False + +class MeasureCreate(BaseModel): + title: str + description: Optional[str] = None + responsible: Optional[str] = None + due_date: Optional[str] = None + + +# ============================================================================= +# Admin Routes +# ============================================================================= + +@router.get("/reports") +def list_reports( + status: Optional[str] = Query(None), + category: Optional[str] = Query(None), + limit: int = Query(50, le=200), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + where = ["tenant_id = :tid"] + params = {"tid": tenant_id, "lim": limit} + if status: + where.append("status = :st") + params["st"] = status + if category: + where.append("category = :cat") + params["cat"] = category + q = text(f"SELECT * FROM compliance_whistleblower_reports WHERE {' AND '.join(where)} ORDER BY received_at DESC LIMIT :lim") + return [_row_to_dict(r) for r in db.execute(q, params).fetchall()] + + +@router.get("/reports/stats") +def report_stats( + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + now = datetime.now(timezone.utc) + q = text("SELECT status, COUNT(*) as cnt FROM compliance_whistleblower_reports WHERE tenant_id = :tid GROUP BY status") + by_status = {r.status: r.cnt for r in db.execute(q, {"tid": tenant_id}).fetchall()} + q2 = text("SELECT category, COUNT(*) as cnt FROM compliance_whistleblower_reports WHERE tenant_id = :tid GROUP BY category") + by_category = {r.category: r.cnt for r in db.execute(q2, {"tid": tenant_id}).fetchall()} + q3 = text("SELECT COUNT(*) FROM compliance_whistleblower_reports WHERE tenant_id = :tid AND deadline_acknowledgment < :now AND acknowledged_at IS NULL AND status = 'new'") + overdue_ack = db.execute(q3, {"tid": tenant_id, "now": now}).scalar() or 0 + q4 = text("SELECT COUNT(*) FROM compliance_whistleblower_reports WHERE tenant_id = :tid AND deadline_feedback < :now AND status NOT IN ('closed', 'rejected')") + overdue_fb = db.execute(q4, {"tid": tenant_id, "now": now}).scalar() or 0 + total = sum(by_status.values()) + return {"total": total, "by_status": by_status, "by_category": by_category, "overdue_acknowledgment": overdue_ack, "overdue_feedback": overdue_fb} + + +@router.post("/reports") +def create_report( + body: ReportCreate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + now = datetime.now(timezone.utc) + ref = _gen_ref(tenant_id, db) + ak = _gen_access_key() + q = text(""" + INSERT INTO compliance_whistleblower_reports + (tenant_id, reference_number, access_key, category, title, description, + is_anonymous, reporter_name, reporter_email, reporter_phone, priority, + received_at, deadline_acknowledgment, deadline_feedback) + VALUES (:tid, :ref, :ak, :cat, :title, :desc, + :anon, :rn, :re, :rp, :pri, + :now, :dl_ack, :dl_fb) + RETURNING * + """) + row = db.execute(q, { + "tid": tenant_id, "ref": ref, "ak": ak, + "cat": body.category, "title": body.title, "desc": body.description, + "anon": body.is_anonymous, "rn": body.reporter_name, + "re": body.reporter_email, "rp": body.reporter_phone, + "pri": body.priority, "now": now, + "dl_ack": now + timedelta(days=7), + "dl_fb": now + timedelta(days=90), + }).fetchone() + db.commit() + return _row_to_dict(row) + + +@router.get("/reports/{report_id}") +def get_report( + report_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + row = db.execute(text("SELECT * FROM compliance_whistleblower_reports WHERE id = :rid AND tenant_id = :tid"), + {"rid": report_id, "tid": tenant_id}).fetchone() + if not row: + raise HTTPException(404, "Report not found") + result = _row_to_dict(row) + msgs = db.execute(text("SELECT * FROM compliance_whistleblower_messages WHERE report_id = :rid ORDER BY created_at"), + {"rid": report_id}).fetchall() + result["messages"] = [_row_to_dict(m) for m in msgs] + measures = db.execute(text("SELECT * FROM compliance_whistleblower_measures WHERE report_id = :rid ORDER BY created_at"), + {"rid": report_id}).fetchall() + result["measures"] = [_row_to_dict(m) for m in measures] + return result + + +@router.put("/reports/{report_id}") +def update_report( + report_id: str, + body: ReportUpdate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + sets, params = [], {"rid": report_id, "tid": tenant_id} + for field in ["status", "priority", "assigned_to", "category"]: + val = getattr(body, field, None) + if val is not None: + sets.append(f"{field} = :{field}") + params[field] = val + if not sets: + raise HTTPException(400, "No fields to update") + sets.append("updated_at = NOW()") + q = text(f"UPDATE compliance_whistleblower_reports SET {', '.join(sets)} WHERE id = :rid AND tenant_id = :tid RETURNING *") + row = db.execute(q, params).fetchone() + if not row: + raise HTTPException(404, "Report not found") + db.commit() + return _row_to_dict(row) + + +@router.post("/reports/{report_id}/acknowledge") +def acknowledge_report( + report_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + UPDATE compliance_whistleblower_reports + SET status = 'acknowledged', acknowledged_at = NOW(), updated_at = NOW() + WHERE id = :rid AND tenant_id = :tid RETURNING * + """) + row = db.execute(q, {"rid": report_id, "tid": tenant_id}).fetchone() + if not row: + raise HTTPException(404, "Report not found") + db.commit() + return _row_to_dict(row) + + +@router.post("/reports/{report_id}/close") +def close_report( + report_id: str, + reason: str = Query(""), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + UPDATE compliance_whistleblower_reports + SET status = 'closed', closed_at = NOW(), closure_reason = :reason, updated_at = NOW() + WHERE id = :rid AND tenant_id = :tid RETURNING * + """) + row = db.execute(q, {"rid": report_id, "tid": tenant_id, "reason": reason}).fetchone() + if not row: + raise HTTPException(404, "Report not found") + db.commit() + return _row_to_dict(row) + + +@router.post("/reports/{report_id}/messages") +def add_message( + report_id: str, + body: MessageCreate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + INSERT INTO compliance_whistleblower_messages (report_id, sender_type, message, is_internal) + VALUES (:rid, :st, :msg, :internal) RETURNING * + """) + row = db.execute(q, {"rid": report_id, "st": body.sender_type, "msg": body.message, "internal": body.is_internal}).fetchone() + db.commit() + return _row_to_dict(row) + + +@router.get("/reports/{report_id}/measures") +def list_measures(report_id: str, db: Session = Depends(get_db)): + return [_row_to_dict(r) for r in db.execute(text( + "SELECT * FROM compliance_whistleblower_measures WHERE report_id = :rid ORDER BY created_at" + ), {"rid": report_id}).fetchall()] + + +@router.post("/reports/{report_id}/measures") +def add_measure( + report_id: str, body: MeasureCreate, + db: Session = Depends(get_db), +): + q = text(""" + INSERT INTO compliance_whistleblower_measures (report_id, title, description, responsible, due_date) + VALUES (:rid, :title, :desc, :resp, :due) RETURNING * + """) + row = db.execute(q, {"rid": report_id, "title": body.title, "desc": body.description, + "resp": body.responsible, "due": body.due_date}).fetchone() + db.commit() + return _row_to_dict(row) + + +# ============================================================================= +# Public Routes (Anonymous) +# ============================================================================= + +@router.post("/submit") +def submit_report(body: ReportCreate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id)): + """Public anonymous submission — same as create but returns only access_key.""" + body.is_anonymous = True + result = create_report(body, db, tenant_id) + return {"access_key": result["access_key"], "reference_number": result["reference_number"], + "message": "Ihre Meldung wurde erfolgreich eingereicht. Nutzen Sie den Zugangscode um den Status zu pruefen."} + + +@router.get("/check/{access_key}") +def check_status(access_key: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id)): + """Reporter checks status anonymously via access key.""" + row = db.execute(text( + "SELECT id, reference_number, status, category, received_at, acknowledged_at FROM compliance_whistleblower_reports WHERE access_key = :ak AND tenant_id = :tid" + ), {"ak": access_key, "tid": tenant_id}).fetchone() + if not row: + raise HTTPException(404, "Meldung nicht gefunden") + result = _row_to_dict(row) + msgs = db.execute(text( + "SELECT message, sender_type, created_at FROM compliance_whistleblower_messages WHERE report_id = :rid AND is_internal = FALSE ORDER BY created_at" + ), {"rid": result["id"]}).fetchall() + result["messages"] = [_row_to_dict(m) for m in msgs] + return result diff --git a/backend-compliance/migrations/118_whistleblower.sql b/backend-compliance/migrations/118_whistleblower.sql new file mode 100644 index 0000000..9c4b301 --- /dev/null +++ b/backend-compliance/migrations/118_whistleblower.sql @@ -0,0 +1,58 @@ +-- Migration 118: Whistleblower (HinSchG) — Report + Message tables +-- Meldestelle fuer Hinweisgeber gemaess HinSchG §§ 12-18 + +CREATE TABLE IF NOT EXISTS compliance_whistleblower_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + reference_number VARCHAR(50) NOT NULL, + access_key VARCHAR(20) NOT NULL, + category VARCHAR(30) NOT NULL DEFAULT 'other', + status VARCHAR(30) NOT NULL DEFAULT 'new', + priority VARCHAR(20) NOT NULL DEFAULT 'normal', + title VARCHAR(500) NOT NULL, + description TEXT NOT NULL, + is_anonymous BOOLEAN NOT NULL DEFAULT TRUE, + reporter_name VARCHAR(300), + reporter_email VARCHAR(300), + reporter_phone VARCHAR(100), + assigned_to VARCHAR(300), + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + acknowledged_at TIMESTAMPTZ, + deadline_acknowledgment TIMESTAMPTZ, + deadline_feedback TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + closure_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, reference_number), + UNIQUE(tenant_id, access_key) +); + +CREATE INDEX IF NOT EXISTS idx_wb_reports_tenant ON compliance_whistleblower_reports(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wb_reports_status ON compliance_whistleblower_reports(tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_wb_reports_access ON compliance_whistleblower_reports(access_key); + +CREATE TABLE IF NOT EXISTS compliance_whistleblower_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + report_id UUID NOT NULL REFERENCES compliance_whistleblower_reports(id) ON DELETE CASCADE, + sender_type VARCHAR(20) NOT NULL DEFAULT 'reporter', + message TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_wb_messages_report ON compliance_whistleblower_messages(report_id); + +CREATE TABLE IF NOT EXISTS compliance_whistleblower_measures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + report_id UUID NOT NULL REFERENCES compliance_whistleblower_reports(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'planned', + responsible VARCHAR(300), + due_date TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_wb_measures_report ON compliance_whistleblower_measures(report_id);