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:
@@ -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);
|
||||
Reference in New Issue
Block a user