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>
|
||||||
</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 */}
|
{/* Compliance Status */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
// CONFIGURATION
|
// 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
|
const API_TIMEOUT = 30000
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -121,27 +122,27 @@ export async function fetchReports(filters?: ReportFilters): Promise<ReportListR
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryString = params.toString()
|
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)
|
return fetchWithTimeout<ReportListResponse>(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
|
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
|
||||||
return fetchWithTimeout<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> {
|
export async function updateReport(id: string, update: ReportUpdateRequest): Promise<WhistleblowerReport> {
|
||||||
return fetchWithTimeout<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) }
|
{ method: 'PUT', body: JSON.stringify(update) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReport(id: string): Promise<void> {
|
export async function deleteReport(id: string): Promise<void> {
|
||||||
await fetchWithTimeout<void>(
|
await fetchWithTimeout<void>(
|
||||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
|
`${WB_API_BASE}/whistleblower/reports/${id}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,7 +155,7 @@ export async function submitPublicReport(
|
|||||||
data: PublicReportSubmission
|
data: PublicReportSubmission
|
||||||
): Promise<{ report: WhistleblowerReport; accessKey: string }> {
|
): Promise<{ report: WhistleblowerReport; accessKey: string }> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${WB_API_BASE}/api/v1/public/whistleblower/submit`,
|
`${WB_API_BASE}/whistleblower/submit`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -173,7 +174,7 @@ export async function fetchReportByAccessKey(
|
|||||||
accessKey: string
|
accessKey: string
|
||||||
): Promise<WhistleblowerReport> {
|
): Promise<WhistleblowerReport> {
|
||||||
const response = await fetch(
|
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' } }
|
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,14 +191,14 @@ export async function fetchReportByAccessKey(
|
|||||||
|
|
||||||
export async function acknowledgeReport(id: string): Promise<WhistleblowerReport> {
|
export async function acknowledgeReport(id: string): Promise<WhistleblowerReport> {
|
||||||
return fetchWithTimeout<WhistleblowerReport>(
|
return fetchWithTimeout<WhistleblowerReport>(
|
||||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`,
|
`${WB_API_BASE}/whistleblower/reports/${id}/acknowledge`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startInvestigation(id: string): Promise<WhistleblowerReport> {
|
export async function startInvestigation(id: string): Promise<WhistleblowerReport> {
|
||||||
return fetchWithTimeout<WhistleblowerReport>(
|
return fetchWithTimeout<WhistleblowerReport>(
|
||||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/investigate`,
|
`${WB_API_BASE}/whistleblower/reports/${id}/investigate`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -207,7 +208,7 @@ export async function addMeasure(
|
|||||||
measure: Omit<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
|
measure: Omit<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
|
||||||
): Promise<WhistleblowerMeasure> {
|
): Promise<WhistleblowerMeasure> {
|
||||||
return fetchWithTimeout<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) }
|
{ method: 'POST', body: JSON.stringify(measure) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +218,7 @@ export async function closeReport(
|
|||||||
resolution: { reason: string; notes: string }
|
resolution: { reason: string; notes: string }
|
||||||
): Promise<WhistleblowerReport> {
|
): Promise<WhistleblowerReport> {
|
||||||
return fetchWithTimeout<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) }
|
{ method: 'POST', body: JSON.stringify(resolution) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -232,14 +233,14 @@ export async function sendMessage(
|
|||||||
role: 'reporter' | 'ombudsperson'
|
role: 'reporter' | 'ombudsperson'
|
||||||
): Promise<AnonymousMessage> {
|
): Promise<AnonymousMessage> {
|
||||||
return fetchWithTimeout<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 }) }
|
{ method: 'POST', body: JSON.stringify({ senderRole: role, message }) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMessages(reportId: string): Promise<AnonymousMessage[]> {
|
export async function fetchMessages(reportId: string): Promise<AnonymousMessage[]> {
|
||||||
return fetchWithTimeout<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(
|
const response = await fetch(
|
||||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/attachments`,
|
`${WB_API_BASE}/whistleblower/reports/${reportId}/attachments`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -290,7 +291,7 @@ export async function uploadAttachment(
|
|||||||
|
|
||||||
export async function deleteAttachment(id: string): Promise<void> {
|
export async function deleteAttachment(id: string): Promise<void> {
|
||||||
await fetchWithTimeout<void>(
|
await fetchWithTimeout<void>(
|
||||||
`${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`,
|
`${WB_API_BASE}/whistleblower/attachments/${id}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -301,6 +302,6 @@ export async function deleteAttachment(id: string): Promise<void> {
|
|||||||
|
|
||||||
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
|
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
|
||||||
return fetchWithTimeout<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_analytics_routes",
|
||||||
"banner_ab_routes",
|
"banner_ab_routes",
|
||||||
"compliance_report_routes",
|
"compliance_report_routes",
|
||||||
|
"whistleblower_routes",
|
||||||
]
|
]
|
||||||
|
|
||||||
_loaded_count = 0
|
_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