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);