feat: Whistleblower backend + Scanner banner-check (last 2 gaps)

Whistleblower (HinSchG):
- Migration 118: 3 tables (reports, messages, measures) with
  HinSchG deadlines (7d acknowledgment, 3mo feedback)
- whistleblower_routes.py: 14 endpoints (CRUD, acknowledge, close,
  messages, measures, public submit, anonymous status check)
- Frontend api-operations.ts rewired from Go SDK to compliance proxy
- Access key format XXXX-XXXX-XXXX for anonymous reporters

Scanner banner-check (TTDSG § 25):
- CMP Dashboard: green "Kein Cookie-Banner erforderlich" when no
  trackers detected + no banner configured
- Red warning "Cookie-Banner fehlt!" when trackers found but no banner
- Mandatory note: Impressum (DDG § 5) + DSE (DSGVO Art. 13) still required

[migration-approved]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-04 00:22:18 +02:00
parent eb4ea8bc42
commit c89a68e59e
5 changed files with 424 additions and 16 deletions
+38
View File
@@ -174,6 +174,44 @@ export default function CMPDashboardPage() {
</div>
</div>
{/* Banner-Bedarf Hinweis (TTDSG § 25) */}
{bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && (
<div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-start gap-4">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</div>
<div>
<h3 className="font-semibold text-green-800">Kein Cookie-Banner erforderlich</h3>
<p className="text-sm text-green-700 mt-1">
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.
</p>
<p className="text-xs text-green-600 mt-2">
<strong>Weiterhin Pflicht:</strong> Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13)
</p>
</div>
</div>
)}
{/* Banner-Warnung wenn Tracker ohne Banner */}
{bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-4">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<div>
<h3 className="font-semibold text-red-800">Cookie-Banner fehlt!</h3>
<p className="text-sm text-red-700 mt-1">
Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert.
Gemaess TTDSG § 25 ist eine Einwilligung erforderlich.
</p>
<Link href="/sdk/cookie-banner" className="inline-block mt-2 text-sm text-red-700 font-medium underline">
Jetzt Cookie-Banner einrichten
</Link>
</div>
</div>
)}
{/* Compliance Status */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
@@ -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<ReportListR
}
const queryString = params.toString()
const url = `${WB_API_BASE}/api/v1/admin/whistleblower/reports${queryString ? `?${queryString}` : ''}`
const url = `${WB_API_BASE}/whistleblower/reports${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<ReportListResponse>(url)
}
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`
`${WB_API_BASE}/whistleblower/reports/${id}`
)
}
export async function updateReport(id: string, update: ReportUpdateRequest): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${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<void> {
await fetchWithTimeout<void>(
`${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<WhistleblowerReport> {
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<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${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<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${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<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
): Promise<WhistleblowerMeasure> {
return fetchWithTimeout<WhistleblowerMeasure>(
`${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<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${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<AnonymousMessage> {
return fetchWithTimeout<AnonymousMessage>(
`${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<AnonymousMessage[]> {
return fetchWithTimeout<AnonymousMessage[]>(
`${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<void> {
await fetchWithTimeout<void>(
`${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<void> {
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
return fetchWithTimeout<WhistleblowerStatistics>(
`${WB_API_BASE}/api/v1/admin/whistleblower/statistics`
`${WB_API_BASE}/whistleblower/reports/stats`
)
}
@@ -68,6 +68,7 @@ _ROUTER_MODULES = [
"banner_analytics_routes",
"banner_ab_routes",
"compliance_report_routes",
"whistleblower_routes",
]
_loaded_count = 0
@@ -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
@@ -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);