/** * Whistleblower System API Client * * API client for Hinweisgeberschutzgesetz (HinSchG) compliant * Whistleblower/Hinweisgebersystem management * Connects to the ai-compliance-sdk backend */ import { WhistleblowerReport, WhistleblowerStatistics, ReportListResponse, ReportFilters, PublicReportSubmission, ReportUpdateRequest, MessageSendRequest, AnonymousMessage, WhistleblowerMeasure, FileAttachment, ReportCategory, ReportStatus, ReportPriority, generateAccessKey } from './types' // ============================================================================= // CONFIGURATION // ============================================================================= const WB_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093' const API_TIMEOUT = 30000 // 30 seconds // ============================================================================= // HELPER FUNCTIONS // ============================================================================= function getTenantId(): string { if (typeof window !== 'undefined') { return localStorage.getItem('bp_tenant_id') || 'default-tenant' } return 'default-tenant' } function getAuthHeaders(): HeadersInit { const headers: HeadersInit = { 'Content-Type': 'application/json', 'X-Tenant-ID': getTenantId() } if (typeof window !== 'undefined') { const token = localStorage.getItem('authToken') if (token) { headers['Authorization'] = `Bearer ${token}` } const userId = localStorage.getItem('bp_user_id') if (userId) { headers['X-User-ID'] = userId } } return headers } async function fetchWithTimeout( url: string, options: RequestInit = {}, timeout: number = API_TIMEOUT ): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const response = await fetch(url, { ...options, signal: controller.signal, headers: { ...getAuthHeaders(), ...options.headers } }) if (!response.ok) { const errorBody = await response.text() let errorMessage = `HTTP ${response.status}: ${response.statusText}` try { const errorJson = JSON.parse(errorBody) errorMessage = errorJson.error || errorJson.message || errorMessage } catch { // Keep the HTTP status message } throw new Error(errorMessage) } // Handle empty responses const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { return response.json() } return {} as T } finally { clearTimeout(timeoutId) } } // ============================================================================= // ADMIN CRUD - Reports // ============================================================================= /** * Alle Meldungen abrufen (Admin) */ export async function fetchReports(filters?: ReportFilters): Promise { const params = new URLSearchParams() if (filters) { if (filters.status) { const statuses = Array.isArray(filters.status) ? filters.status : [filters.status] statuses.forEach(s => params.append('status', s)) } if (filters.category) { const categories = Array.isArray(filters.category) ? filters.category : [filters.category] categories.forEach(c => params.append('category', c)) } if (filters.priority) params.set('priority', filters.priority) if (filters.assignedTo) params.set('assignedTo', filters.assignedTo) if (filters.isAnonymous !== undefined) params.set('isAnonymous', String(filters.isAnonymous)) if (filters.search) params.set('search', filters.search) if (filters.dateFrom) params.set('dateFrom', filters.dateFrom) if (filters.dateTo) params.set('dateTo', filters.dateTo) } const queryString = params.toString() const url = `${WB_API_BASE}/api/v1/admin/whistleblower/reports${queryString ? `?${queryString}` : ''}` return fetchWithTimeout(url) } /** * Einzelne Meldung abrufen (Admin) */ export async function fetchReport(id: string): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}` ) } /** * Meldung aktualisieren (Status, Prioritaet, Kategorie, Zuweisung) */ export async function updateReport(id: string, update: ReportUpdateRequest): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`, { method: 'PUT', body: JSON.stringify(update) } ) } /** * Meldung loeschen (soft delete) */ export async function deleteReport(id: string): Promise { await fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`, { method: 'DELETE' } ) } // ============================================================================= // PUBLIC ENDPOINTS - Kein Auth erforderlich // ============================================================================= /** * Neue Meldung einreichen (oeffentlich, keine Auth) */ export async function submitPublicReport( data: PublicReportSubmission ): Promise<{ report: WhistleblowerReport; accessKey: string }> { const response = await fetch( `${WB_API_BASE}/api/v1/public/whistleblower/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) } ) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() } /** * Meldung ueber Zugangscode abrufen (oeffentlich, keine Auth) */ export async function fetchReportByAccessKey( accessKey: string ): Promise { const response = await fetch( `${WB_API_BASE}/api/v1/public/whistleblower/report/${accessKey}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } } ) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() } // ============================================================================= // WORKFLOW ACTIONS // ============================================================================= /** * Eingangsbestaetigung versenden (HinSchG ss 17 Abs. 1) */ export async function acknowledgeReport(id: string): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`, { method: 'POST' } ) } /** * Untersuchung starten */ export async function startInvestigation(id: string): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/investigate`, { method: 'POST' } ) } /** * Massnahme zu einer Meldung hinzufuegen */ export async function addMeasure( id: string, measure: Omit ): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/measures`, { method: 'POST', body: JSON.stringify(measure) } ) } /** * Meldung abschliessen mit Begruendung */ export async function closeReport( id: string, resolution: { reason: string; notes: string } ): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/close`, { method: 'POST', body: JSON.stringify(resolution) } ) } // ============================================================================= // ANONYMOUS MESSAGING // ============================================================================= /** * Nachricht im anonymen Kanal senden */ export async function sendMessage( reportId: string, message: string, role: 'reporter' | 'ombudsperson' ): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`, { method: 'POST', body: JSON.stringify({ senderRole: role, message }) } ) } /** * Nachrichten fuer eine Meldung abrufen */ export async function fetchMessages(reportId: string): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages` ) } // ============================================================================= // ATTACHMENTS // ============================================================================= /** * Anhang zu einer Meldung hochladen */ export async function uploadAttachment( reportId: string, file: File ): Promise { const formData = new FormData() formData.append('file', file) const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 60000) // 60s for uploads try { const headers: HeadersInit = { 'X-Tenant-ID': getTenantId() } if (typeof window !== 'undefined') { const token = localStorage.getItem('authToken') if (token) { headers['Authorization'] = `Bearer ${token}` } } const response = await fetch( `${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/attachments`, { method: 'POST', headers, body: formData, signal: controller.signal } ) if (!response.ok) { throw new Error(`Upload fehlgeschlagen: ${response.statusText}`) } return response.json() } finally { clearTimeout(timeoutId) } } /** * Anhang loeschen */ export async function deleteAttachment(id: string): Promise { await fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`, { method: 'DELETE' } ) } // ============================================================================= // STATISTICS // ============================================================================= /** * Statistiken fuer das Whistleblower-Dashboard abrufen */ export async function fetchWhistleblowerStatistics(): Promise { return fetchWithTimeout( `${WB_API_BASE}/api/v1/admin/whistleblower/statistics` ) } // ============================================================================= // SDK PROXY FUNCTION (via Next.js proxy) // ============================================================================= /** * Fetch Whistleblower-Daten via SDK Proxy mit Fallback auf Mock-Daten */ export async function fetchSDKWhistleblowerList(): Promise<{ reports: WhistleblowerReport[] statistics: WhistleblowerStatistics }> { try { const [reportsResponse, statsResponse] = await Promise.all([ fetchReports(), fetchWhistleblowerStatistics() ]) return { reports: reportsResponse.reports, statistics: statsResponse } } catch (error) { console.error('Failed to load Whistleblower data from API, using mock data:', error) // Fallback to mock data const reports = createMockReports() const statistics = createMockStatistics() return { reports, statistics } } } // ============================================================================= // MOCK DATA (Demo/Entwicklung) // ============================================================================= /** * Erstellt Demo-Meldungen fuer Entwicklung und Praesentationen */ export function createMockReports(): WhistleblowerReport[] { const now = new Date() // Helper: Berechne Fristen function calcDeadlines(receivedAt: Date): { ack: string; fb: string } { const ack = new Date(receivedAt) ack.setDate(ack.getDate() + 7) const fb = new Date(receivedAt) fb.setMonth(fb.getMonth() + 3) return { ack: ack.toISOString(), fb: fb.toISOString() } } const received1 = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) const deadlines1 = calcDeadlines(received1) const received2 = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000) const deadlines2 = calcDeadlines(received2) const received3 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) const deadlines3 = calcDeadlines(received3) const received4 = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) const deadlines4 = calcDeadlines(received4) return [ // Report 1: Neu { id: 'wb-001', referenceNumber: 'WB-2026-000001', accessKey: generateAccessKey(), category: 'corruption', status: 'new', priority: 'high', title: 'Unregelmaessigkeiten bei Auftragsvergabe', description: 'Bei der Vergabe des IT-Rahmenvertrags im November wurden offenbar Angebote eines bestimmten Anbieters bevorzugt. Der zustaendige Abteilungsleiter hat private Verbindungen zum Geschaeftsfuehrer des Anbieters.', isAnonymous: true, receivedAt: received1.toISOString(), deadlineAcknowledgment: deadlines1.ack, deadlineFeedback: deadlines1.fb, measures: [], messages: [], attachments: [], auditTrail: [ { id: 'audit-001', action: 'report_created', description: 'Meldung ueber Online-Meldeformular eingegangen', performedBy: 'system', performedAt: received1.toISOString() } ] }, // Report 2: In Pruefung (under_review) { id: 'wb-002', referenceNumber: 'WB-2026-000002', accessKey: generateAccessKey(), category: 'data_protection', status: 'under_review', priority: 'normal', title: 'Unerlaubte Weitergabe von Kundendaten', description: 'Ein Mitarbeiter der Vertriebsabteilung gibt regelmaessig Kundenlisten an externe Dienstleister weiter, ohne dass eine Auftragsverarbeitungsvereinbarung vorliegt.', isAnonymous: false, reporterName: 'Maria Schmidt', reporterEmail: 'maria.schmidt@example.de', assignedTo: 'DSB Mueller', receivedAt: received2.toISOString(), acknowledgedAt: new Date(received2.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(), deadlineAcknowledgment: deadlines2.ack, deadlineFeedback: deadlines2.fb, measures: [], messages: [ { id: 'msg-001', reportId: 'wb-002', senderRole: 'ombudsperson', message: 'Vielen Dank fuer Ihre Meldung. Koennen Sie uns mitteilen, welche Dienstleister konkret betroffen sind?', createdAt: new Date(received2.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(), isRead: true }, { id: 'msg-002', reportId: 'wb-002', senderRole: 'reporter', message: 'Es handelt sich um die Firma DataServ GmbH und MarketPro AG. Die Listen werden per unverschluesselter E-Mail versendet.', createdAt: new Date(received2.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), isRead: true } ], attachments: [ { id: 'att-001', fileName: 'email_screenshot_vertrieb.png', fileSize: 245000, mimeType: 'image/png', uploadedAt: received2.toISOString(), uploadedBy: 'reporter' } ], auditTrail: [ { id: 'audit-002', action: 'report_created', description: 'Meldung per E-Mail eingegangen', performedBy: 'system', performedAt: received2.toISOString() }, { id: 'audit-003', action: 'acknowledged', description: 'Eingangsbestaetigung an Hinweisgeber versendet', performedBy: 'DSB Mueller', performedAt: new Date(received2.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString() }, { id: 'audit-004', action: 'status_changed', description: 'Status geaendert: Bestaetigt -> In Pruefung', performedBy: 'DSB Mueller', performedAt: new Date(received2.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString() } ] }, // Report 3: Untersuchung (investigation) { id: 'wb-003', referenceNumber: 'WB-2026-000003', accessKey: generateAccessKey(), category: 'product_safety', status: 'investigation', priority: 'critical', title: 'Fehlende Sicherheitspruefungen bei Produktfreigabe', description: 'In der Fertigung werden seit Wochen Produkte ohne die vorgeschriebenen Sicherheitspruefungen freigegeben. Pruefprotokolle werden nachtraeglich erstellt, ohne dass tatsaechliche Pruefungen stattfinden.', isAnonymous: true, assignedTo: 'Qualitaetsbeauftragter Weber', receivedAt: received3.toISOString(), acknowledgedAt: new Date(received3.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString(), deadlineAcknowledgment: deadlines3.ack, deadlineFeedback: deadlines3.fb, measures: [ { id: 'msr-001', reportId: 'wb-003', title: 'Sofortiger Produktionsstopp fuer betroffene Charge', description: 'Produktion der betroffenen Produktlinie stoppen bis Pruefverfahren sichergestellt ist', status: 'completed', responsible: 'Fertigungsleitung', dueDate: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(), completedAt: new Date(received3.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString() }, { id: 'msr-002', reportId: 'wb-003', title: 'Externe Pruefung der Pruefprotokolle', description: 'Unabhaengige Pruefstelle mit der Revision aller Pruefprotokolle der letzten 6 Monate beauftragen', status: 'in_progress', responsible: 'Qualitaetsmanagement', dueDate: new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString() } ], messages: [], attachments: [ { id: 'att-002', fileName: 'pruefprotokoll_vergleich.pdf', fileSize: 890000, mimeType: 'application/pdf', uploadedAt: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(), uploadedBy: 'ombudsperson' } ], auditTrail: [ { id: 'audit-005', action: 'report_created', description: 'Meldung ueber Online-Meldeformular eingegangen', performedBy: 'system', performedAt: received3.toISOString() }, { id: 'audit-006', action: 'acknowledged', description: 'Eingangsbestaetigung versendet', performedBy: 'Qualitaetsbeauftragter Weber', performedAt: new Date(received3.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString() }, { id: 'audit-007', action: 'investigation_started', description: 'Formelle Untersuchung eingeleitet', performedBy: 'Qualitaetsbeauftragter Weber', performedAt: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString() } ] }, // Report 4: Abgeschlossen (closed) { id: 'wb-004', referenceNumber: 'WB-2026-000004', accessKey: generateAccessKey(), category: 'fraud', status: 'closed', priority: 'high', title: 'Gefaelschte Reisekostenabrechnungen', description: 'Ein leitender Mitarbeiter reicht seit ueber einem Jahr gefaelschte Reisekostenabrechnungen ein. Hotelrechnungen werden manipuliert, Taxiquittungen erfunden.', isAnonymous: false, reporterName: 'Thomas Klein', reporterEmail: 'thomas.klein@example.de', reporterPhone: '+49 170 9876543', assignedTo: 'Compliance-Abteilung', receivedAt: received4.toISOString(), acknowledgedAt: new Date(received4.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(), deadlineAcknowledgment: deadlines4.ack, deadlineFeedback: deadlines4.fb, closedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(), measures: [ { id: 'msr-003', reportId: 'wb-004', title: 'Interne Revision der Reisekosten', description: 'Pruefung aller Reisekostenabrechnungen des betroffenen Mitarbeiters der letzten 24 Monate', status: 'completed', responsible: 'Interne Revision', dueDate: new Date(received4.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), completedAt: new Date(received4.getTime() + 25 * 24 * 60 * 60 * 1000).toISOString() }, { id: 'msr-004', reportId: 'wb-004', title: 'Arbeitsrechtliche Konsequenzen', description: 'Einleitung arbeitsrechtlicher Schritte nach Bestaetigung des Betrugs', status: 'completed', responsible: 'Personalabteilung', dueDate: new Date(received4.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString(), completedAt: new Date(received4.getTime() + 55 * 24 * 60 * 60 * 1000).toISOString() } ], messages: [], attachments: [ { id: 'att-003', fileName: 'vergleich_originalrechnung_einreichung.pdf', fileSize: 567000, mimeType: 'application/pdf', uploadedAt: received4.toISOString(), uploadedBy: 'reporter' } ], auditTrail: [ { id: 'audit-008', action: 'report_created', description: 'Meldung per Brief eingegangen', performedBy: 'system', performedAt: received4.toISOString() }, { id: 'audit-009', action: 'acknowledged', description: 'Eingangsbestaetigung versendet', performedBy: 'Compliance-Abteilung', performedAt: new Date(received4.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString() }, { id: 'audit-010', action: 'closed', description: 'Fall abgeschlossen - Betrug bestaetigt, arbeitsrechtliche Massnahmen eingeleitet', performedBy: 'Compliance-Abteilung', performedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString() } ] } ] } /** * Berechnet Statistiken aus den Mock-Daten */ export function createMockStatistics(): WhistleblowerStatistics { const reports = createMockReports() const now = new Date() const byStatus: Record = { new: 0, acknowledged: 0, under_review: 0, investigation: 0, measures_taken: 0, closed: 0, rejected: 0 } const byCategory: Record = { corruption: 0, fraud: 0, data_protection: 0, discrimination: 0, environment: 0, competition: 0, product_safety: 0, tax_evasion: 0, other: 0 } reports.forEach(r => { byStatus[r.status]++ byCategory[r.category]++ }) const closedStatuses: ReportStatus[] = ['closed', 'rejected'] // Pruefe ueberfaellige Eingangsbestaetigungen const overdueAcknowledgment = reports.filter(r => { if (r.status !== 'new') return false return now > new Date(r.deadlineAcknowledgment) }).length // Pruefe ueberfaellige Rueckmeldungen const overdueFeedback = reports.filter(r => { if (closedStatuses.includes(r.status)) return false return now > new Date(r.deadlineFeedback) }).length return { totalReports: reports.length, newReports: byStatus.new, underReview: byStatus.under_review + byStatus.investigation, closed: byStatus.closed + byStatus.rejected, overdueAcknowledgment, overdueFeedback, byCategory, byStatus } }