refactor(admin): split 8 oversized lib/ files into focused modules under 500 LOC
Split these files that exceeded the 500-line hard cap: - privacy-policy.ts (965 LOC) -> sections + renderers - academy/api.ts (787 LOC) -> courses + mock-data - whistleblower/api.ts (755 LOC) -> operations + mock-data - vvt-profiling.ts (659 LOC) -> data + logic - cookie-banner.ts (595 LOC) -> config + embed - dsr/types.ts (581 LOC) -> core + api types - tom-generator/rules-engine.ts (560 LOC) -> evaluator + gap-analysis - datapoint-helpers.ts (548 LOC) -> generators + validators Each original file becomes a barrel re-export for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
385
admin-compliance/lib/sdk/academy/api-courses.ts
Normal file
385
admin-compliance/lib/sdk/academy/api-courses.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Academy API Client — Course, Enrollment, Certificate, Quiz, Statistics, Generation
|
||||
*
|
||||
* API client for the Compliance E-Learning Academy module
|
||||
* Connects to the ai-compliance-sdk backend via Next.js proxy
|
||||
*/
|
||||
|
||||
import type {
|
||||
Course,
|
||||
CourseCategory,
|
||||
CourseCreateRequest,
|
||||
CourseUpdateRequest,
|
||||
GenerateCourseRequest,
|
||||
Enrollment,
|
||||
EnrollmentStatus,
|
||||
EnrollmentListResponse,
|
||||
EnrollUserRequest,
|
||||
UpdateProgressRequest,
|
||||
Certificate,
|
||||
AcademyStatistics,
|
||||
LessonType,
|
||||
SubmitQuizRequest,
|
||||
SubmitQuizResponse,
|
||||
} from './types'
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
const ACADEMY_API_BASE = '/api/sdk/v1/academy'
|
||||
const API_TIMEOUT = 30000
|
||||
|
||||
// =============================================================================
|
||||
// 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<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = API_TIMEOUT
|
||||
): Promise<T> {
|
||||
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)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return {} as T
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACKEND TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface BackendCourse {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: CourseCategory
|
||||
duration_minutes: number
|
||||
required_for_roles: string[]
|
||||
is_active: boolean
|
||||
passing_score?: number
|
||||
status?: string
|
||||
lessons?: BackendLesson[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface BackendQuizQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
correct_index: number
|
||||
explanation: string
|
||||
}
|
||||
|
||||
interface BackendLesson {
|
||||
id: string
|
||||
course_id: string
|
||||
title: string
|
||||
description?: string
|
||||
lesson_type: LessonType
|
||||
content_url?: string
|
||||
duration_minutes: number
|
||||
order_index: number
|
||||
quiz_questions?: BackendQuizQuestion[]
|
||||
}
|
||||
|
||||
function mapCourseFromBackend(bc: BackendCourse): Course {
|
||||
return {
|
||||
id: bc.id,
|
||||
title: bc.title,
|
||||
description: bc.description || '',
|
||||
category: bc.category,
|
||||
durationMinutes: bc.duration_minutes || 0,
|
||||
passingScore: bc.passing_score ?? 70,
|
||||
isActive: bc.is_active ?? true,
|
||||
status: (bc.status as 'draft' | 'published') ?? 'draft',
|
||||
requiredForRoles: bc.required_for_roles || [],
|
||||
lessons: (bc.lessons || []).map(l => ({
|
||||
id: l.id,
|
||||
courseId: l.course_id,
|
||||
title: l.title,
|
||||
type: l.lesson_type,
|
||||
contentMarkdown: l.content_url || '',
|
||||
durationMinutes: l.duration_minutes || 0,
|
||||
order: l.order_index,
|
||||
quizQuestions: (l.quiz_questions || []).map(q => ({
|
||||
id: q.id || `q-${Math.random().toString(36).slice(2)}`,
|
||||
lessonId: l.id,
|
||||
question: q.question,
|
||||
options: q.options,
|
||||
correctOptionIndex: q.correct_index,
|
||||
explanation: q.explanation,
|
||||
})),
|
||||
})),
|
||||
createdAt: bc.created_at,
|
||||
updatedAt: bc.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapCoursesFromBackend(courses: BackendCourse[]): Course[] {
|
||||
return courses.map(mapCourseFromBackend)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COURSE CRUD
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchCourses(): Promise<Course[]> {
|
||||
const res = await fetchWithTimeout<{ courses: BackendCourse[]; total: number }>(
|
||||
`${ACADEMY_API_BASE}/courses`
|
||||
)
|
||||
return mapCoursesFromBackend(res.courses || [])
|
||||
}
|
||||
|
||||
export async function fetchCourse(id: string): Promise<Course> {
|
||||
const res = await fetchWithTimeout<{ course: BackendCourse }>(
|
||||
`${ACADEMY_API_BASE}/courses/${id}`
|
||||
)
|
||||
return mapCourseFromBackend(res.course)
|
||||
}
|
||||
|
||||
export async function createCourse(request: CourseCreateRequest): Promise<Course> {
|
||||
return fetchWithTimeout<Course>(
|
||||
`${ACADEMY_API_BASE}/courses`,
|
||||
{ method: 'POST', body: JSON.stringify(request) }
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateCourse(id: string, update: CourseUpdateRequest): Promise<Course> {
|
||||
return fetchWithTimeout<Course>(
|
||||
`${ACADEMY_API_BASE}/courses/${id}`,
|
||||
{ method: 'PUT', body: JSON.stringify(update) }
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteCourse(id: string): Promise<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${ACADEMY_API_BASE}/courses/${id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENROLLMENTS
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (courseId) {
|
||||
params.set('course_id', courseId)
|
||||
}
|
||||
const queryString = params.toString()
|
||||
const url = `${ACADEMY_API_BASE}/enrollments${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const res = await fetchWithTimeout<{ enrollments: Enrollment[]; total: number }>(url)
|
||||
return res.enrollments || []
|
||||
}
|
||||
|
||||
export async function enrollUser(request: EnrollUserRequest): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments`,
|
||||
{ method: 'POST', body: JSON.stringify(request) }
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateProgress(enrollmentId: string, update: UpdateProgressRequest): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/progress`,
|
||||
{ method: 'PUT', body: JSON.stringify(update) }
|
||||
)
|
||||
}
|
||||
|
||||
export async function completeEnrollment(enrollmentId: string): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/complete`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteEnrollment(id: string): Promise<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateEnrollment(id: string, data: { deadline?: string }): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${id}`,
|
||||
{ method: 'PUT', body: JSON.stringify(data) }
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchCertificate(id: string): Promise<Certificate> {
|
||||
return fetchWithTimeout<Certificate>(
|
||||
`${ACADEMY_API_BASE}/certificates/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateCertificate(enrollmentId: string): Promise<Certificate> {
|
||||
return fetchWithTimeout<Certificate>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/certificate`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchCertificates(): Promise<Certificate[]> {
|
||||
const res = await fetchWithTimeout<{ certificates: Certificate[]; total: number }>(
|
||||
`${ACADEMY_API_BASE}/certificates`
|
||||
)
|
||||
return res.certificates || []
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QUIZ
|
||||
// =============================================================================
|
||||
|
||||
export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest): Promise<SubmitQuizResponse> {
|
||||
return fetchWithTimeout<SubmitQuizResponse>(
|
||||
`${ACADEMY_API_BASE}/lessons/${lessonId}/quiz-test`,
|
||||
{ method: 'POST', body: JSON.stringify(answers) }
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateLesson(lessonId: string, update: {
|
||||
title?: string
|
||||
description?: string
|
||||
content_url?: string
|
||||
duration_minutes?: number
|
||||
quiz_questions?: Array<{ question: string; options: string[]; correct_index: number; explanation: string }>
|
||||
}): Promise<{ lesson: any }> {
|
||||
return fetchWithTimeout(
|
||||
`${ACADEMY_API_BASE}/lessons/${lessonId}`,
|
||||
{ method: 'PUT', body: JSON.stringify(update) }
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
||||
const res = await fetchWithTimeout<{
|
||||
total_courses: number
|
||||
total_enrollments: number
|
||||
completion_rate: number
|
||||
overdue_count: number
|
||||
avg_completion_days: number
|
||||
by_category?: Record<string, number>
|
||||
by_status?: Record<string, number>
|
||||
}>(`${ACADEMY_API_BASE}/stats`)
|
||||
|
||||
return {
|
||||
totalCourses: res.total_courses || 0,
|
||||
totalEnrollments: res.total_enrollments || 0,
|
||||
completionRate: res.completion_rate || 0,
|
||||
overdueCount: res.overdue_count || 0,
|
||||
byCategory: (res.by_category || {}) as Record<CourseCategory, number>,
|
||||
byStatus: (res.by_status || {}) as Record<EnrollmentStatus, number>,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COURSE GENERATION
|
||||
// =============================================================================
|
||||
|
||||
export async function generateCourse(request: GenerateCourseRequest): Promise<{ course: Course }> {
|
||||
return fetchWithTimeout<{ course: Course }>(
|
||||
`${ACADEMY_API_BASE}/courses/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ module_id: request.moduleId || request.title })
|
||||
},
|
||||
120000
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateAllCourses(): Promise<{ generated: number; skipped: number; errors: string[]; total: number }> {
|
||||
return fetchWithTimeout(
|
||||
`${ACADEMY_API_BASE}/courses/generate-all`,
|
||||
{ method: 'POST' },
|
||||
300000
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateVideos(courseId: string): Promise<{ status: string; jobId?: string }> {
|
||||
return fetchWithTimeout<{ status: string; jobId?: string }>(
|
||||
`${ACADEMY_API_BASE}/courses/${courseId}/generate-videos`,
|
||||
{ method: 'POST' },
|
||||
300000
|
||||
)
|
||||
}
|
||||
|
||||
export async function getVideoStatus(courseId: string): Promise<{
|
||||
status: string
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
videos: Array<{ lessonId: string; status: string; url?: string }>
|
||||
}> {
|
||||
return fetchWithTimeout(
|
||||
`${ACADEMY_API_BASE}/courses/${courseId}/video-status`
|
||||
)
|
||||
}
|
||||
157
admin-compliance/lib/sdk/academy/api-mock-data.ts
Normal file
157
admin-compliance/lib/sdk/academy/api-mock-data.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Academy API — Mock Data & SDK Proxy
|
||||
*
|
||||
* Fallback mock data for development and SDK proxy function
|
||||
*/
|
||||
|
||||
import type {
|
||||
Course,
|
||||
CourseCategory,
|
||||
Enrollment,
|
||||
EnrollmentStatus,
|
||||
AcademyStatistics,
|
||||
} from './types'
|
||||
import { isEnrollmentOverdue } from './types'
|
||||
import {
|
||||
fetchCourses,
|
||||
fetchEnrollments,
|
||||
fetchAcademyStatistics,
|
||||
} from './api-courses'
|
||||
|
||||
// =============================================================================
|
||||
// SDK PROXY FUNCTION
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchSDKAcademyList(): Promise<{
|
||||
courses: Course[]
|
||||
enrollments: Enrollment[]
|
||||
statistics: AcademyStatistics
|
||||
}> {
|
||||
try {
|
||||
const [courses, enrollments, statistics] = await Promise.all([
|
||||
fetchCourses(),
|
||||
fetchEnrollments(),
|
||||
fetchAcademyStatistics()
|
||||
])
|
||||
|
||||
return { courses, enrollments, statistics }
|
||||
} catch (error) {
|
||||
console.error('Failed to load Academy data from backend, using mock data:', error)
|
||||
|
||||
const courses = createMockCourses()
|
||||
const enrollments = createMockEnrollments()
|
||||
const statistics = createMockStatistics(courses, enrollments)
|
||||
|
||||
return { courses, enrollments, statistics }
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
export function createMockCourses(): Course[] {
|
||||
const now = new Date()
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'course-001',
|
||||
title: 'DSGVO-Grundlagen fuer Mitarbeiter',
|
||||
description: 'Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Dieses Pflichttraining vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten im Arbeitsalltag.',
|
||||
category: 'dsgvo_basics',
|
||||
durationMinutes: 90,
|
||||
passingScore: 80,
|
||||
isActive: true,
|
||||
status: 'published',
|
||||
requiredForRoles: ['all'],
|
||||
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessons: [
|
||||
{ id: 'lesson-001-01', courseId: 'course-001', order: 1, title: 'Was ist die DSGVO?', type: 'text', contentMarkdown: '# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der Europaeischen Union...', durationMinutes: 15 },
|
||||
{ id: 'lesson-001-02', courseId: 'course-001', order: 2, title: 'Die 7 Grundsaetze der DSGVO', type: 'video', contentMarkdown: 'Videoerklaerung der Grundsaetze: Rechtmaessigkeit, Zweckbindung, Datenminimierung, Richtigkeit, Speicherbegrenzung, Integritaet und Vertraulichkeit, Rechenschaftspflicht.', durationMinutes: 20, videoUrl: '/videos/dsgvo-grundsaetze.mp4' },
|
||||
{ id: 'lesson-001-03', courseId: 'course-001', order: 3, title: 'Betroffenenrechte (Art. 15-21)', type: 'text', contentMarkdown: '# Betroffenenrechte\n\nUebersicht der Betroffenenrechte: Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenuebertragbarkeit, Widerspruch.', durationMinutes: 20 },
|
||||
{ id: 'lesson-001-04', courseId: 'course-001', order: 4, title: 'Personenbezogene Daten im Arbeitsalltag', type: 'video', contentMarkdown: 'Praxisbeispiele fuer den korrekten Umgang mit personenbezogenen Daten am Arbeitsplatz.', durationMinutes: 15, videoUrl: '/videos/dsgvo-praxis.mp4' },
|
||||
{ id: 'lesson-001-05', courseId: 'course-001', order: 5, title: 'Wissenstest: DSGVO-Grundlagen', type: 'quiz', contentMarkdown: 'Testen Sie Ihr Wissen zu den DSGVO-Grundlagen.', durationMinutes: 20 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-002',
|
||||
title: 'IT-Sicherheit & Cybersecurity Awareness',
|
||||
description: 'Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern, Social Engineering und sicherer Kommunikation.',
|
||||
category: 'it_security',
|
||||
durationMinutes: 60,
|
||||
passingScore: 75,
|
||||
isActive: true,
|
||||
status: 'published',
|
||||
requiredForRoles: ['all'],
|
||||
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessons: [
|
||||
{ id: 'lesson-002-01', courseId: 'course-002', order: 1, title: 'Phishing erkennen und vermeiden', type: 'video', contentMarkdown: 'Wie erkennt man Phishing-E-Mails und was tut man im Verdachtsfall?', durationMinutes: 15, videoUrl: '/videos/phishing-awareness.mp4' },
|
||||
{ id: 'lesson-002-02', courseId: 'course-002', order: 2, title: 'Sichere Passwoerter und MFA', type: 'text', contentMarkdown: '# Sichere Passwoerter\n\nRichtlinien fuer starke Passwoerter, Passwort-Manager und Multi-Faktor-Authentifizierung.', durationMinutes: 15 },
|
||||
{ id: 'lesson-002-03', courseId: 'course-002', order: 3, title: 'Social Engineering und Manipulation', type: 'text', contentMarkdown: '# Social Engineering\n\nMethoden von Angreifern zur Manipulation von Mitarbeitern und Schutzmassnahmen.', durationMinutes: 15 },
|
||||
{ id: 'lesson-002-04', courseId: 'course-002', order: 4, title: 'Wissenstest: IT-Sicherheit', type: 'quiz', contentMarkdown: 'Testen Sie Ihr Wissen zur IT-Sicherheit.', durationMinutes: 15 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-003',
|
||||
title: 'AI Literacy - Sicherer Umgang mit KI',
|
||||
description: 'Grundlagen kuenstlicher Intelligenz, EU AI Act, verantwortungsvoller Einsatz von KI-Werkzeugen und Risiken bei der Nutzung von Large Language Models (LLMs) im Unternehmen.',
|
||||
category: 'ai_literacy',
|
||||
durationMinutes: 75,
|
||||
passingScore: 70,
|
||||
isActive: true,
|
||||
status: 'draft',
|
||||
requiredForRoles: ['admin', 'data_protection_officer'],
|
||||
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessons: [
|
||||
{ id: 'lesson-003-01', courseId: 'course-003', order: 1, title: 'Was ist Kuenstliche Intelligenz?', type: 'text', contentMarkdown: '# Was ist KI?\n\nGrundlagen von Machine Learning, Deep Learning und Large Language Models in verstaendlicher Sprache.', durationMinutes: 15 },
|
||||
{ id: 'lesson-003-02', courseId: 'course-003', order: 2, title: 'Der EU AI Act - Was bedeutet er fuer uns?', type: 'video', contentMarkdown: 'Ueberblick ueber den EU AI Act, Risikoklassen und Anforderungen fuer Unternehmen.', durationMinutes: 20, videoUrl: '/videos/eu-ai-act.mp4' },
|
||||
{ id: 'lesson-003-03', courseId: 'course-003', order: 3, title: 'KI-Werkzeuge sicher nutzen', type: 'text', contentMarkdown: '# KI-Werkzeuge sicher nutzen\n\nRichtlinien fuer den Einsatz von ChatGPT, Copilot & Co.', durationMinutes: 20 },
|
||||
{ id: 'lesson-003-04', courseId: 'course-003', order: 4, title: 'Wissenstest: AI Literacy', type: 'quiz', contentMarkdown: 'Testen Sie Ihr Wissen zum sicheren Umgang mit KI.', durationMinutes: 20 },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function createMockEnrollments(): Enrollment[] {
|
||||
const now = new Date()
|
||||
|
||||
return [
|
||||
{ id: 'enr-001', courseId: 'course-001', userId: 'user-001', userName: 'Maria Fischer', userEmail: 'maria.fischer@example.de', status: 'in_progress', progress: 40, startedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(), deadline: new Date(now.getTime() + 20 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
{ id: 'enr-002', courseId: 'course-002', userId: 'user-002', userName: 'Stefan Mueller', userEmail: 'stefan.mueller@example.de', status: 'completed', progress: 100, startedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(), completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString(), certificateId: 'cert-001', deadline: new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
{ id: 'enr-003', courseId: 'course-001', userId: 'user-003', userName: 'Laura Schneider', userEmail: 'laura.schneider@example.de', status: 'not_started', progress: 0, startedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(), deadline: new Date(now.getTime() + 28 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
{ id: 'enr-004', courseId: 'course-003', userId: 'user-004', userName: 'Thomas Wagner', userEmail: 'thomas.wagner@example.de', status: 'expired', progress: 25, startedAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(), deadline: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
{ id: 'enr-005', courseId: 'course-002', userId: 'user-005', userName: 'Julia Becker', userEmail: 'julia.becker@example.de', status: 'in_progress', progress: 50, startedAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), deadline: new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
]
|
||||
}
|
||||
|
||||
export function createMockStatistics(courses?: Course[], enrollments?: Enrollment[]): AcademyStatistics {
|
||||
const c = courses || createMockCourses()
|
||||
const e = enrollments || createMockEnrollments()
|
||||
|
||||
const completedCount = e.filter(en => en.status === 'completed').length
|
||||
const completionRate = e.length > 0 ? Math.round((completedCount / e.length) * 100) : 0
|
||||
const overdueCount = e.filter(en => isEnrollmentOverdue(en)).length
|
||||
|
||||
return {
|
||||
totalCourses: c.length,
|
||||
totalEnrollments: e.length,
|
||||
completionRate,
|
||||
overdueCount,
|
||||
byCategory: {
|
||||
dsgvo_basics: c.filter(co => co.category === 'dsgvo_basics').length,
|
||||
it_security: c.filter(co => co.category === 'it_security').length,
|
||||
ai_literacy: c.filter(co => co.category === 'ai_literacy').length,
|
||||
whistleblower_protection: c.filter(co => co.category === 'whistleblower_protection').length,
|
||||
custom: c.filter(co => co.category === 'custom').length,
|
||||
},
|
||||
byStatus: {
|
||||
not_started: e.filter(en => en.status === 'not_started').length,
|
||||
in_progress: e.filter(en => en.status === 'in_progress').length,
|
||||
completed: e.filter(en => en.status === 'completed').length,
|
||||
expired: e.filter(en => en.status === 'expired').length,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,787 +1,38 @@
|
||||
/**
|
||||
* Academy API Client
|
||||
* Academy API Client — barrel re-export
|
||||
*
|
||||
* API client for the Compliance E-Learning Academy module
|
||||
* Connects to the ai-compliance-sdk backend via Next.js proxy
|
||||
* Split into:
|
||||
* - api-courses.ts (CRUD, enrollments, certificates, quiz, stats, generation)
|
||||
* - api-mock-data.ts (mock data + SDK proxy)
|
||||
*/
|
||||
|
||||
import type {
|
||||
Course,
|
||||
CourseCategory,
|
||||
CourseCreateRequest,
|
||||
CourseUpdateRequest,
|
||||
GenerateCourseRequest,
|
||||
Enrollment,
|
||||
EnrollmentStatus,
|
||||
EnrollmentListResponse,
|
||||
EnrollUserRequest,
|
||||
UpdateProgressRequest,
|
||||
Certificate,
|
||||
AcademyStatistics,
|
||||
LessonType,
|
||||
SubmitQuizRequest,
|
||||
SubmitQuizResponse,
|
||||
} from './types'
|
||||
import { isEnrollmentOverdue } from './types'
|
||||
export {
|
||||
fetchCourses,
|
||||
fetchCourse,
|
||||
createCourse,
|
||||
updateCourse,
|
||||
deleteCourse,
|
||||
fetchEnrollments,
|
||||
enrollUser,
|
||||
updateProgress,
|
||||
completeEnrollment,
|
||||
deleteEnrollment,
|
||||
updateEnrollment,
|
||||
fetchCertificate,
|
||||
generateCertificate,
|
||||
fetchCertificates,
|
||||
submitQuiz,
|
||||
updateLesson,
|
||||
fetchAcademyStatistics,
|
||||
generateCourse,
|
||||
generateAllCourses,
|
||||
generateVideos,
|
||||
getVideoStatus,
|
||||
} from './api-courses'
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
const ACADEMY_API_BASE = '/api/sdk/v1/academy'
|
||||
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<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = API_TIMEOUT
|
||||
): Promise<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COURSE CRUD
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Alle Kurse abrufen
|
||||
*/
|
||||
export async function fetchCourses(): Promise<Course[]> {
|
||||
const res = await fetchWithTimeout<{ courses: BackendCourse[]; total: number }>(
|
||||
`${ACADEMY_API_BASE}/courses`
|
||||
)
|
||||
return mapCoursesFromBackend(res.courses || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Kurs abrufen
|
||||
*/
|
||||
export async function fetchCourse(id: string): Promise<Course> {
|
||||
const res = await fetchWithTimeout<{ course: BackendCourse }>(
|
||||
`${ACADEMY_API_BASE}/courses/${id}`
|
||||
)
|
||||
return mapCourseFromBackend(res.course)
|
||||
}
|
||||
|
||||
// Backend returns snake_case, frontend uses camelCase
|
||||
interface BackendCourse {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: CourseCategory
|
||||
duration_minutes: number
|
||||
required_for_roles: string[]
|
||||
is_active: boolean
|
||||
passing_score?: number
|
||||
status?: string
|
||||
lessons?: BackendLesson[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface BackendQuizQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
correct_index: number
|
||||
explanation: string
|
||||
}
|
||||
|
||||
interface BackendLesson {
|
||||
id: string
|
||||
course_id: string
|
||||
title: string
|
||||
description?: string
|
||||
lesson_type: LessonType
|
||||
content_url?: string
|
||||
duration_minutes: number
|
||||
order_index: number
|
||||
quiz_questions?: BackendQuizQuestion[]
|
||||
}
|
||||
|
||||
function mapCourseFromBackend(bc: BackendCourse): Course {
|
||||
return {
|
||||
id: bc.id,
|
||||
title: bc.title,
|
||||
description: bc.description || '',
|
||||
category: bc.category,
|
||||
durationMinutes: bc.duration_minutes || 0,
|
||||
passingScore: bc.passing_score ?? 70,
|
||||
isActive: bc.is_active ?? true,
|
||||
status: (bc.status as 'draft' | 'published') ?? 'draft',
|
||||
requiredForRoles: bc.required_for_roles || [],
|
||||
lessons: (bc.lessons || []).map(l => ({
|
||||
id: l.id,
|
||||
courseId: l.course_id,
|
||||
title: l.title,
|
||||
type: l.lesson_type,
|
||||
contentMarkdown: l.content_url || '',
|
||||
durationMinutes: l.duration_minutes || 0,
|
||||
order: l.order_index,
|
||||
quizQuestions: (l.quiz_questions || []).map(q => ({
|
||||
id: q.id || `q-${Math.random().toString(36).slice(2)}`,
|
||||
lessonId: l.id,
|
||||
question: q.question,
|
||||
options: q.options,
|
||||
correctOptionIndex: q.correct_index,
|
||||
explanation: q.explanation,
|
||||
})),
|
||||
})),
|
||||
createdAt: bc.created_at,
|
||||
updatedAt: bc.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapCoursesFromBackend(courses: BackendCourse[]): Course[] {
|
||||
return courses.map(mapCourseFromBackend)
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Kurs erstellen
|
||||
*/
|
||||
export async function createCourse(request: CourseCreateRequest): Promise<Course> {
|
||||
return fetchWithTimeout<Course>(
|
||||
`${ACADEMY_API_BASE}/courses`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurs aktualisieren
|
||||
*/
|
||||
export async function updateCourse(id: string, update: CourseUpdateRequest): Promise<Course> {
|
||||
return fetchWithTimeout<Course>(
|
||||
`${ACADEMY_API_BASE}/courses/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(update)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurs loeschen
|
||||
*/
|
||||
export async function deleteCourse(id: string): Promise<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${ACADEMY_API_BASE}/courses/${id}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENROLLMENTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Einschreibungen abrufen (optional gefiltert nach Kurs-ID)
|
||||
*/
|
||||
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (courseId) {
|
||||
params.set('course_id', courseId)
|
||||
}
|
||||
const queryString = params.toString()
|
||||
const url = `${ACADEMY_API_BASE}/enrollments${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const res = await fetchWithTimeout<{ enrollments: Enrollment[]; total: number }>(url)
|
||||
return res.enrollments || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer in einen Kurs einschreiben
|
||||
*/
|
||||
export async function enrollUser(request: EnrollUserRequest): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fortschritt einer Einschreibung aktualisieren
|
||||
*/
|
||||
export async function updateProgress(enrollmentId: string, update: UpdateProgressRequest): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/progress`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(update)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Einschreibung als abgeschlossen markieren
|
||||
*/
|
||||
export async function completeEnrollment(enrollmentId: string): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/complete`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Zertifikat abrufen
|
||||
*/
|
||||
export async function fetchCertificate(id: string): Promise<Certificate> {
|
||||
return fetchWithTimeout<Certificate>(
|
||||
`${ACADEMY_API_BASE}/certificates/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zertifikat generieren nach erfolgreichem Kursabschluss
|
||||
*/
|
||||
export async function generateCertificate(enrollmentId: string): Promise<Certificate> {
|
||||
return fetchWithTimeout<Certificate>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/certificate`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Zertifikate abrufen
|
||||
*/
|
||||
export async function fetchCertificates(): Promise<Certificate[]> {
|
||||
const res = await fetchWithTimeout<{ certificates: Certificate[]; total: number }>(
|
||||
`${ACADEMY_API_BASE}/certificates`
|
||||
)
|
||||
return res.certificates || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Einschreibung loeschen
|
||||
*/
|
||||
export async function deleteEnrollment(id: string): Promise<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Einschreibung aktualisieren (z.B. Deadline)
|
||||
*/
|
||||
export async function updateEnrollment(id: string, data: { deadline?: string }): Promise<Enrollment> {
|
||||
return fetchWithTimeout<Enrollment>(
|
||||
`${ACADEMY_API_BASE}/enrollments/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QUIZ
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Quiz-Antworten einreichen und auswerten (ohne Enrollment)
|
||||
*/
|
||||
export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest): Promise<SubmitQuizResponse> {
|
||||
return fetchWithTimeout<SubmitQuizResponse>(
|
||||
`${ACADEMY_API_BASE}/lessons/${lessonId}/quiz-test`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(answers)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lektion aktualisieren (Content, Titel, Quiz-Fragen)
|
||||
*/
|
||||
export async function updateLesson(lessonId: string, update: {
|
||||
title?: string
|
||||
description?: string
|
||||
content_url?: string
|
||||
duration_minutes?: number
|
||||
quiz_questions?: Array<{ question: string; options: string[]; correct_index: number; explanation: string }>
|
||||
}): Promise<{ lesson: any }> {
|
||||
return fetchWithTimeout(
|
||||
`${ACADEMY_API_BASE}/lessons/${lessonId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(update)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Academy-Statistiken abrufen
|
||||
*/
|
||||
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
||||
const res = await fetchWithTimeout<{
|
||||
total_courses: number
|
||||
total_enrollments: number
|
||||
completion_rate: number
|
||||
overdue_count: number
|
||||
avg_completion_days: number
|
||||
by_category?: Record<string, number>
|
||||
by_status?: Record<string, number>
|
||||
}>(`${ACADEMY_API_BASE}/stats`)
|
||||
|
||||
return {
|
||||
totalCourses: res.total_courses || 0,
|
||||
totalEnrollments: res.total_enrollments || 0,
|
||||
completionRate: res.completion_rate || 0,
|
||||
overdueCount: res.overdue_count || 0,
|
||||
byCategory: (res.by_category || {}) as Record<CourseCategory, number>,
|
||||
byStatus: (res.by_status || {}) as Record<EnrollmentStatus, number>,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COURSE GENERATION (via Training Engine)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Academy-Kurs aus einem Training-Modul generieren
|
||||
*/
|
||||
export async function generateCourse(request: GenerateCourseRequest): Promise<{ course: Course }> {
|
||||
return fetchWithTimeout<{ course: Course }>(
|
||||
`${ACADEMY_API_BASE}/courses/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ module_id: request.moduleId || request.title })
|
||||
},
|
||||
120000 // 2 min timeout
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Academy-Kurse aus Training-Modulen generieren
|
||||
*/
|
||||
export async function generateAllCourses(): Promise<{ generated: number; skipped: number; errors: string[]; total: number }> {
|
||||
return fetchWithTimeout(
|
||||
`${ACADEMY_API_BASE}/courses/generate-all`,
|
||||
{ method: 'POST' },
|
||||
300000 // 5 min timeout
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Videos fuer alle Lektionen eines Kurses generieren
|
||||
*/
|
||||
export async function generateVideos(courseId: string): Promise<{ status: string; jobId?: string }> {
|
||||
return fetchWithTimeout<{ status: string; jobId?: string }>(
|
||||
`${ACADEMY_API_BASE}/courses/${courseId}/generate-videos`,
|
||||
{
|
||||
method: 'POST'
|
||||
},
|
||||
300000 // 5 min timeout for video generation
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Video-Generierungsstatus abrufen
|
||||
*/
|
||||
export async function getVideoStatus(courseId: string): Promise<{
|
||||
status: string
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
videos: Array<{ lessonId: string; status: string; url?: string }>
|
||||
}> {
|
||||
return fetchWithTimeout(
|
||||
`${ACADEMY_API_BASE}/courses/${courseId}/video-status`
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SDK PROXY FUNCTION (wraps fetchCourses + fetchAcademyStatistics)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Kurse und Statistiken laden - mit Fallback auf Mock-Daten
|
||||
*/
|
||||
export async function fetchSDKAcademyList(): Promise<{
|
||||
courses: Course[]
|
||||
enrollments: Enrollment[]
|
||||
statistics: AcademyStatistics
|
||||
}> {
|
||||
try {
|
||||
const [courses, enrollments, statistics] = await Promise.all([
|
||||
fetchCourses(),
|
||||
fetchEnrollments(),
|
||||
fetchAcademyStatistics()
|
||||
])
|
||||
|
||||
return { courses, enrollments, statistics }
|
||||
} catch (error) {
|
||||
console.error('Failed to load Academy data from backend, using mock data:', error)
|
||||
|
||||
// Fallback to mock data
|
||||
const courses = createMockCourses()
|
||||
const enrollments = createMockEnrollments()
|
||||
const statistics = createMockStatistics(courses, enrollments)
|
||||
|
||||
return { courses, enrollments, statistics }
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA (Fallback / Demo)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Demo-Kurse mit deutschen Titeln erstellen
|
||||
*/
|
||||
export function createMockCourses(): Course[] {
|
||||
const now = new Date()
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'course-001',
|
||||
title: 'DSGVO-Grundlagen fuer Mitarbeiter',
|
||||
description: 'Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Dieses Pflichttraining vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten im Arbeitsalltag.',
|
||||
category: 'dsgvo_basics',
|
||||
durationMinutes: 90,
|
||||
passingScore: 80,
|
||||
isActive: true,
|
||||
status: 'published',
|
||||
requiredForRoles: ['all'],
|
||||
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessons: [
|
||||
{
|
||||
id: 'lesson-001-01',
|
||||
courseId: 'course-001',
|
||||
order: 1,
|
||||
title: 'Was ist die DSGVO?',
|
||||
type: 'text',
|
||||
contentMarkdown: '# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der Europaeischen Union...',
|
||||
durationMinutes: 15
|
||||
},
|
||||
{
|
||||
id: 'lesson-001-02',
|
||||
courseId: 'course-001',
|
||||
order: 2,
|
||||
title: 'Die 7 Grundsaetze der DSGVO',
|
||||
type: 'video',
|
||||
contentMarkdown: 'Videoerklaerung der Grundsaetze: Rechtmaessigkeit, Zweckbindung, Datenminimierung, Richtigkeit, Speicherbegrenzung, Integritaet und Vertraulichkeit, Rechenschaftspflicht.',
|
||||
durationMinutes: 20,
|
||||
videoUrl: '/videos/dsgvo-grundsaetze.mp4'
|
||||
},
|
||||
{
|
||||
id: 'lesson-001-03',
|
||||
courseId: 'course-001',
|
||||
order: 3,
|
||||
title: 'Betroffenenrechte (Art. 15-21)',
|
||||
type: 'text',
|
||||
contentMarkdown: '# Betroffenenrechte\n\nUebersicht der Betroffenenrechte: Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenuebertragbarkeit, Widerspruch.',
|
||||
durationMinutes: 20
|
||||
},
|
||||
{
|
||||
id: 'lesson-001-04',
|
||||
courseId: 'course-001',
|
||||
order: 4,
|
||||
title: 'Personenbezogene Daten im Arbeitsalltag',
|
||||
type: 'video',
|
||||
contentMarkdown: 'Praxisbeispiele fuer den korrekten Umgang mit personenbezogenen Daten am Arbeitsplatz.',
|
||||
durationMinutes: 15,
|
||||
videoUrl: '/videos/dsgvo-praxis.mp4'
|
||||
},
|
||||
{
|
||||
id: 'lesson-001-05',
|
||||
courseId: 'course-001',
|
||||
order: 5,
|
||||
title: 'Wissenstest: DSGVO-Grundlagen',
|
||||
type: 'quiz',
|
||||
contentMarkdown: 'Testen Sie Ihr Wissen zu den DSGVO-Grundlagen.',
|
||||
durationMinutes: 20
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-002',
|
||||
title: 'IT-Sicherheit & Cybersecurity Awareness',
|
||||
description: 'Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern, Social Engineering und sicherer Kommunikation.',
|
||||
category: 'it_security',
|
||||
durationMinutes: 60,
|
||||
passingScore: 75,
|
||||
isActive: true,
|
||||
status: 'published',
|
||||
requiredForRoles: ['all'],
|
||||
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessons: [
|
||||
{
|
||||
id: 'lesson-002-01',
|
||||
courseId: 'course-002',
|
||||
order: 1,
|
||||
title: 'Phishing erkennen und vermeiden',
|
||||
type: 'video',
|
||||
contentMarkdown: 'Wie erkennt man Phishing-E-Mails und was tut man im Verdachtsfall?',
|
||||
durationMinutes: 15,
|
||||
videoUrl: '/videos/phishing-awareness.mp4'
|
||||
},
|
||||
{
|
||||
id: 'lesson-002-02',
|
||||
courseId: 'course-002',
|
||||
order: 2,
|
||||
title: 'Sichere Passwoerter und MFA',
|
||||
type: 'text',
|
||||
contentMarkdown: '# Sichere Passwoerter\n\nRichtlinien fuer starke Passwoerter, Passwort-Manager und Multi-Faktor-Authentifizierung.',
|
||||
durationMinutes: 15
|
||||
},
|
||||
{
|
||||
id: 'lesson-002-03',
|
||||
courseId: 'course-002',
|
||||
order: 3,
|
||||
title: 'Social Engineering und Manipulation',
|
||||
type: 'text',
|
||||
contentMarkdown: '# Social Engineering\n\nMethoden von Angreifern zur Manipulation von Mitarbeitern und Schutzmassnahmen.',
|
||||
durationMinutes: 15
|
||||
},
|
||||
{
|
||||
id: 'lesson-002-04',
|
||||
courseId: 'course-002',
|
||||
order: 4,
|
||||
title: 'Wissenstest: IT-Sicherheit',
|
||||
type: 'quiz',
|
||||
contentMarkdown: 'Testen Sie Ihr Wissen zur IT-Sicherheit.',
|
||||
durationMinutes: 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-003',
|
||||
title: 'AI Literacy - Sicherer Umgang mit KI',
|
||||
description: 'Grundlagen kuenstlicher Intelligenz, EU AI Act, verantwortungsvoller Einsatz von KI-Werkzeugen und Risiken bei der Nutzung von Large Language Models (LLMs) im Unternehmen.',
|
||||
category: 'ai_literacy',
|
||||
durationMinutes: 75,
|
||||
passingScore: 70,
|
||||
isActive: true,
|
||||
status: 'draft',
|
||||
requiredForRoles: ['admin', 'data_protection_officer'],
|
||||
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessons: [
|
||||
{
|
||||
id: 'lesson-003-01',
|
||||
courseId: 'course-003',
|
||||
order: 1,
|
||||
title: 'Was ist Kuenstliche Intelligenz?',
|
||||
type: 'text',
|
||||
contentMarkdown: '# Was ist KI?\n\nGrundlagen von Machine Learning, Deep Learning und Large Language Models in verstaendlicher Sprache.',
|
||||
durationMinutes: 15
|
||||
},
|
||||
{
|
||||
id: 'lesson-003-02',
|
||||
courseId: 'course-003',
|
||||
order: 2,
|
||||
title: 'Der EU AI Act - Was bedeutet er fuer uns?',
|
||||
type: 'video',
|
||||
contentMarkdown: 'Ueberblick ueber den EU AI Act, Risikoklassen und Anforderungen fuer Unternehmen.',
|
||||
durationMinutes: 20,
|
||||
videoUrl: '/videos/eu-ai-act.mp4'
|
||||
},
|
||||
{
|
||||
id: 'lesson-003-03',
|
||||
courseId: 'course-003',
|
||||
order: 3,
|
||||
title: 'KI-Werkzeuge sicher nutzen',
|
||||
type: 'text',
|
||||
contentMarkdown: '# KI-Werkzeuge sicher nutzen\n\nRichtlinien fuer den Einsatz von ChatGPT, Copilot & Co.',
|
||||
durationMinutes: 20
|
||||
},
|
||||
{
|
||||
id: 'lesson-003-04',
|
||||
courseId: 'course-003',
|
||||
order: 4,
|
||||
title: 'Wissenstest: AI Literacy',
|
||||
type: 'quiz',
|
||||
contentMarkdown: 'Testen Sie Ihr Wissen zum sicheren Umgang mit KI.',
|
||||
durationMinutes: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo-Einschreibungen erstellen
|
||||
*/
|
||||
export function createMockEnrollments(): Enrollment[] {
|
||||
const now = new Date()
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'enr-001',
|
||||
courseId: 'course-001',
|
||||
userId: 'user-001',
|
||||
userName: 'Maria Fischer',
|
||||
userEmail: 'maria.fischer@example.de',
|
||||
status: 'in_progress',
|
||||
progress: 40,
|
||||
startedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
deadline: new Date(now.getTime() + 20 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'enr-002',
|
||||
courseId: 'course-002',
|
||||
userId: 'user-002',
|
||||
userName: 'Stefan Mueller',
|
||||
userEmail: 'stefan.mueller@example.de',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
certificateId: 'cert-001',
|
||||
deadline: new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'enr-003',
|
||||
courseId: 'course-001',
|
||||
userId: 'user-003',
|
||||
userName: 'Laura Schneider',
|
||||
userEmail: 'laura.schneider@example.de',
|
||||
status: 'not_started',
|
||||
progress: 0,
|
||||
startedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
deadline: new Date(now.getTime() + 28 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'enr-004',
|
||||
courseId: 'course-003',
|
||||
userId: 'user-004',
|
||||
userName: 'Thomas Wagner',
|
||||
userEmail: 'thomas.wagner@example.de',
|
||||
status: 'expired',
|
||||
progress: 25,
|
||||
startedAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
deadline: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'enr-005',
|
||||
courseId: 'course-002',
|
||||
userId: 'user-005',
|
||||
userName: 'Julia Becker',
|
||||
userEmail: 'julia.becker@example.de',
|
||||
status: 'in_progress',
|
||||
progress: 50,
|
||||
startedAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
deadline: new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo-Statistiken aus Kursen und Einschreibungen berechnen
|
||||
*/
|
||||
export function createMockStatistics(courses?: Course[], enrollments?: Enrollment[]): AcademyStatistics {
|
||||
const c = courses || createMockCourses()
|
||||
const e = enrollments || createMockEnrollments()
|
||||
|
||||
const completedCount = e.filter(en => en.status === 'completed').length
|
||||
const completionRate = e.length > 0 ? Math.round((completedCount / e.length) * 100) : 0
|
||||
const overdueCount = e.filter(en => isEnrollmentOverdue(en)).length
|
||||
|
||||
return {
|
||||
totalCourses: c.length,
|
||||
totalEnrollments: e.length,
|
||||
completionRate,
|
||||
overdueCount,
|
||||
byCategory: {
|
||||
dsgvo_basics: c.filter(co => co.category === 'dsgvo_basics').length,
|
||||
it_security: c.filter(co => co.category === 'it_security').length,
|
||||
ai_literacy: c.filter(co => co.category === 'ai_literacy').length,
|
||||
whistleblower_protection: c.filter(co => co.category === 'whistleblower_protection').length,
|
||||
custom: c.filter(co => co.category === 'custom').length,
|
||||
},
|
||||
byStatus: {
|
||||
not_started: e.filter(en => en.status === 'not_started').length,
|
||||
in_progress: e.filter(en => en.status === 'in_progress').length,
|
||||
completed: e.filter(en => en.status === 'completed').length,
|
||||
expired: e.filter(en => en.status === 'expired').length,
|
||||
}
|
||||
}
|
||||
}
|
||||
export {
|
||||
fetchSDKAcademyList,
|
||||
createMockCourses,
|
||||
createMockEnrollments,
|
||||
createMockStatistics,
|
||||
} from './api-mock-data'
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Datapoint Helpers — Generation Functions
|
||||
*
|
||||
* Functions that generate DSGVO-compliant text blocks from data points
|
||||
* for the document generator.
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
LegalBasis,
|
||||
RetentionPeriod,
|
||||
RiskLevel,
|
||||
CATEGORY_METADATA,
|
||||
LEGAL_BASIS_INFO,
|
||||
RETENTION_PERIOD_INFO,
|
||||
RISK_LEVEL_STYLING,
|
||||
LocalizedText,
|
||||
SupportedLanguage
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type Language = SupportedLanguage
|
||||
|
||||
export interface DataPointPlaceholders {
|
||||
'[DATENPUNKTE_COUNT]': string
|
||||
'[DATENPUNKTE_LIST]': string
|
||||
'[DATENPUNKTE_TABLE]': string
|
||||
'[VERARBEITUNGSZWECKE]': string
|
||||
'[RECHTSGRUNDLAGEN]': string
|
||||
'[SPEICHERFRISTEN]': string
|
||||
'[EMPFAENGER]': string
|
||||
'[BESONDERE_KATEGORIEN]': string
|
||||
'[DRITTLAND_TRANSFERS]': string
|
||||
'[RISIKO_ZUSAMMENFASSUNG]': string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function getText(text: LocalizedText, lang: Language): string {
|
||||
return text[lang] || text.de
|
||||
}
|
||||
|
||||
export function groupByRetention(
|
||||
dataPoints: DataPoint[]
|
||||
): Record<RetentionPeriod, DataPoint[]> {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
const key = dp.retentionPeriod
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(dp)
|
||||
return acc
|
||||
}, {} as Record<RetentionPeriod, DataPoint[]>)
|
||||
}
|
||||
|
||||
export function groupByCategory(
|
||||
dataPoints: DataPoint[]
|
||||
): Record<DataPointCategory, DataPoint[]> {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
const key = dp.category
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(dp)
|
||||
return acc
|
||||
}, {} as Record<DataPointCategory, DataPoint[]>)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function generateDataPointsTable(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
if (dataPoints.length === 0) {
|
||||
return lang === 'de' ? '*Keine Datenpunkte ausgewaehlt.*' : '*No data points selected.*'
|
||||
}
|
||||
|
||||
const header = lang === 'de'
|
||||
? '| Datenpunkt | Kategorie | Zweck | Rechtsgrundlage | Speicherfrist |'
|
||||
: '| Data Point | Category | Purpose | Legal Basis | Retention Period |'
|
||||
const separator = '|------------|-----------|-------|-----------------|---------------|'
|
||||
|
||||
const rows = dataPoints.map(dp => {
|
||||
const category = CATEGORY_METADATA[dp.category]
|
||||
const legalBasis = LEGAL_BASIS_INFO[dp.legalBasis]
|
||||
const retention = RETENTION_PERIOD_INFO[dp.retentionPeriod]
|
||||
|
||||
const name = getText(dp.name, lang)
|
||||
const categoryName = getText(category.name, lang)
|
||||
const purpose = getText(dp.purpose, lang)
|
||||
const legalBasisName = getText(legalBasis.name, lang)
|
||||
const retentionLabel = getText(retention.label, lang)
|
||||
|
||||
const truncatedPurpose = purpose.length > 50 ? purpose.slice(0, 47) + '...' : purpose
|
||||
|
||||
return `| ${name} | ${categoryName} | ${truncatedPurpose} | ${legalBasisName} | ${retentionLabel} |`
|
||||
}).join('\n')
|
||||
|
||||
return `${header}\n${separator}\n${rows}`
|
||||
}
|
||||
|
||||
export function generateSpecialCategorySection(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const special = dataPoints.filter(dp => dp.isSpecialCategory)
|
||||
if (special.length === 0) return ''
|
||||
|
||||
if (lang === 'de') {
|
||||
const items = special.map(dp =>
|
||||
`- **${getText(dp.name, lang)}**: ${getText(dp.description, lang)}`
|
||||
).join('\n')
|
||||
|
||||
return `## Verarbeitung besonderer Kategorien personenbezogener Daten (Art. 9 DSGVO)
|
||||
|
||||
Wir verarbeiten folgende besondere Kategorien personenbezogener Daten:
|
||||
|
||||
${items}
|
||||
|
||||
Die Verarbeitung erfolgt auf Grundlage Ihrer ausdruecklichen Einwilligung gemaess Art. 9 Abs. 2 lit. a DSGVO. Sie koennen Ihre Einwilligung jederzeit mit Wirkung fuer die Zukunft widerrufen.`
|
||||
} else {
|
||||
const items = special.map(dp =>
|
||||
`- **${getText(dp.name, lang)}**: ${getText(dp.description, lang)}`
|
||||
).join('\n')
|
||||
|
||||
return `## Processing of Special Categories of Personal Data (Art. 9 GDPR)
|
||||
|
||||
We process the following special categories of personal data:
|
||||
|
||||
${items}
|
||||
|
||||
Processing is based on your explicit consent pursuant to Art. 9(2)(a) GDPR. You may withdraw your consent at any time with effect for the future.`
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePurposesList(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const purposes = new Set<string>()
|
||||
dataPoints.forEach(dp => purposes.add(getText(dp.purpose, lang)))
|
||||
return [...purposes].join(', ')
|
||||
}
|
||||
|
||||
export function generateLegalBasisList(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const bases = new Set<LegalBasis>()
|
||||
dataPoints.forEach(dp => bases.add(dp.legalBasis))
|
||||
|
||||
return [...bases].map(basis => {
|
||||
const info = LEGAL_BASIS_INFO[basis]
|
||||
return `${info.article} (${getText(info.name, lang)})`
|
||||
}).join(', ')
|
||||
}
|
||||
|
||||
export function generateRetentionList(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const grouped = groupByRetention(dataPoints)
|
||||
const entries: string[] = []
|
||||
|
||||
for (const [period, points] of Object.entries(grouped)) {
|
||||
const retentionInfo = RETENTION_PERIOD_INFO[period as RetentionPeriod]
|
||||
const categories = [...new Set(points.map(p => getText(CATEGORY_METADATA[p.category].name, lang)))]
|
||||
entries.push(`${getText(retentionInfo.label, lang)}: ${categories.join(', ')}`)
|
||||
}
|
||||
|
||||
return entries.join('; ')
|
||||
}
|
||||
|
||||
export function generateRecipientsList(dataPoints: DataPoint[]): string {
|
||||
const recipients = new Set<string>()
|
||||
dataPoints.forEach(dp => {
|
||||
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
|
||||
})
|
||||
if (recipients.size === 0) return ''
|
||||
return [...recipients].join(', ')
|
||||
}
|
||||
|
||||
export function generateThirdCountrySection(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const thirdCountryIndicators = ['Google', 'AWS', 'Microsoft', 'Meta', 'Facebook', 'Cloudflare']
|
||||
|
||||
const thirdCountryPoints = dataPoints.filter(dp =>
|
||||
dp.thirdPartyRecipients?.some(r =>
|
||||
thirdCountryIndicators.some(indicator =>
|
||||
r.toLowerCase().includes(indicator.toLowerCase())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (thirdCountryPoints.length === 0) return ''
|
||||
|
||||
const recipients = new Set<string>()
|
||||
thirdCountryPoints.forEach(dp => {
|
||||
dp.thirdPartyRecipients?.forEach(r => {
|
||||
if (thirdCountryIndicators.some(i => r.toLowerCase().includes(i.toLowerCase()))) {
|
||||
recipients.add(r)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (lang === 'de') {
|
||||
return `## Uebermittlung in Drittlaender
|
||||
|
||||
Wir uebermitteln personenbezogene Daten an folgende Empfaenger in Drittlaendern (ausserhalb der EU/des EWR):
|
||||
|
||||
${[...recipients].map(r => `- ${r}`).join('\n')}
|
||||
|
||||
Die Uebermittlung erfolgt auf Grundlage von Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO) bzw. einem Angemessenheitsbeschluss der EU-Kommission (Art. 45 DSGVO).`
|
||||
} else {
|
||||
return `## Transfers to Third Countries
|
||||
|
||||
We transfer personal data to the following recipients in third countries (outside the EU/EEA):
|
||||
|
||||
${[...recipients].map(r => `- ${r}`).join('\n')}
|
||||
|
||||
The transfer is based on Standard Contractual Clauses (Art. 46(2)(c) GDPR) or an adequacy decision by the EU Commission (Art. 45 GDPR).`
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRiskSummary(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
|
||||
dataPoints.forEach(dp => riskCounts[dp.riskLevel]++)
|
||||
|
||||
const parts = Object.entries(riskCounts)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([level, count]) => {
|
||||
const styling = RISK_LEVEL_STYLING[level as RiskLevel]
|
||||
return `${count} ${getText(styling.label, lang).toLowerCase()}`
|
||||
})
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
export function generateAllPlaceholders(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): DataPointPlaceholders {
|
||||
return {
|
||||
'[DATENPUNKTE_COUNT]': String(dataPoints.length),
|
||||
'[DATENPUNKTE_LIST]': dataPoints.map(dp => getText(dp.name, lang)).join(', '),
|
||||
'[DATENPUNKTE_TABLE]': generateDataPointsTable(dataPoints, lang),
|
||||
'[VERARBEITUNGSZWECKE]': generatePurposesList(dataPoints, lang),
|
||||
'[RECHTSGRUNDLAGEN]': generateLegalBasisList(dataPoints, lang),
|
||||
'[SPEICHERFRISTEN]': generateRetentionList(dataPoints, lang),
|
||||
'[EMPFAENGER]': generateRecipientsList(dataPoints),
|
||||
'[BESONDERE_KATEGORIEN]': generateSpecialCategorySection(dataPoints, lang),
|
||||
'[DRITTLAND_TRANSFERS]': generateThirdCountrySection(dataPoints, lang),
|
||||
'[RISIKO_ZUSAMMENFASSUNG]': generateRiskSummary(dataPoints, lang)
|
||||
}
|
||||
}
|
||||
@@ -1,548 +1,37 @@
|
||||
/**
|
||||
* Helper-Funktionen für die Integration von Einwilligungen-Datenpunkten
|
||||
* in den Dokumentengenerator.
|
||||
* Datapoint Helpers — barrel re-export
|
||||
*
|
||||
* Diese Funktionen generieren DSGVO-konforme Textbausteine basierend auf
|
||||
* den vom Benutzer ausgewählten Datenpunkten.
|
||||
* Split into:
|
||||
* - datapoint-generators.ts (text generation functions)
|
||||
* - datapoint-validators.ts (document validation checks)
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
LegalBasis,
|
||||
RetentionPeriod,
|
||||
RiskLevel,
|
||||
CATEGORY_METADATA,
|
||||
LEGAL_BASIS_INFO,
|
||||
RETENTION_PERIOD_INFO,
|
||||
RISK_LEVEL_STYLING,
|
||||
LocalizedText,
|
||||
SupportedLanguage
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sprach-Option für alle Helper-Funktionen
|
||||
*/
|
||||
export type Language = SupportedLanguage
|
||||
|
||||
/**
|
||||
* Generierte Platzhalter-Map für den Dokumentengenerator
|
||||
*/
|
||||
export interface DataPointPlaceholders {
|
||||
'[DATENPUNKTE_COUNT]': string
|
||||
'[DATENPUNKTE_LIST]': string
|
||||
'[DATENPUNKTE_TABLE]': string
|
||||
'[VERARBEITUNGSZWECKE]': string
|
||||
'[RECHTSGRUNDLAGEN]': string
|
||||
'[SPEICHERFRISTEN]': string
|
||||
'[EMPFAENGER]': string
|
||||
'[BESONDERE_KATEGORIEN]': string
|
||||
'[DRITTLAND_TRANSFERS]': string
|
||||
'[RISIKO_ZUSAMMENFASSUNG]': string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extrahiert Text aus LocalizedText basierend auf Sprache
|
||||
*/
|
||||
function getText(text: LocalizedText, lang: Language): string {
|
||||
return text[lang] || text.de
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine Markdown-Tabelle der Datenpunkte
|
||||
*
|
||||
* @param dataPoints - Liste der ausgewählten Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Markdown-Tabelle als String
|
||||
*/
|
||||
export function generateDataPointsTable(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
if (dataPoints.length === 0) {
|
||||
return lang === 'de'
|
||||
? '*Keine Datenpunkte ausgewählt.*'
|
||||
: '*No data points selected.*'
|
||||
}
|
||||
|
||||
const header = lang === 'de'
|
||||
? '| Datenpunkt | Kategorie | Zweck | Rechtsgrundlage | Speicherfrist |'
|
||||
: '| Data Point | Category | Purpose | Legal Basis | Retention Period |'
|
||||
const separator = '|------------|-----------|-------|-----------------|---------------|'
|
||||
|
||||
const rows = dataPoints.map(dp => {
|
||||
const category = CATEGORY_METADATA[dp.category]
|
||||
const legalBasis = LEGAL_BASIS_INFO[dp.legalBasis]
|
||||
const retention = RETENTION_PERIOD_INFO[dp.retentionPeriod]
|
||||
|
||||
const name = getText(dp.name, lang)
|
||||
const categoryName = getText(category.name, lang)
|
||||
const purpose = getText(dp.purpose, lang)
|
||||
const legalBasisName = getText(legalBasis.name, lang)
|
||||
const retentionLabel = getText(retention.label, lang)
|
||||
|
||||
// Truncate long texts for table readability
|
||||
const truncatedPurpose = purpose.length > 50 ? purpose.slice(0, 47) + '...' : purpose
|
||||
|
||||
return `| ${name} | ${categoryName} | ${truncatedPurpose} | ${legalBasisName} | ${retentionLabel} |`
|
||||
}).join('\n')
|
||||
|
||||
return `${header}\n${separator}\n${rows}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Datenpunkte nach Speicherfrist
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @returns Record mit Speicherfrist als Key und Datenpunkten als Value
|
||||
*/
|
||||
export function groupByRetention(
|
||||
dataPoints: DataPoint[]
|
||||
): Record<RetentionPeriod, DataPoint[]> {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
const key = dp.retentionPeriod
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(dp)
|
||||
return acc
|
||||
}, {} as Record<RetentionPeriod, DataPoint[]>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Datenpunkte nach Kategorie
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @returns Record mit Kategorie als Key und Datenpunkten als Value
|
||||
*/
|
||||
export function groupByCategory(
|
||||
dataPoints: DataPoint[]
|
||||
): Record<DataPointCategory, DataPoint[]> {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
const key = dp.category
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(dp)
|
||||
return acc
|
||||
}, {} as Record<DataPointCategory, DataPoint[]>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert DSGVO-konformen Abschnitt für besondere Kategorien (Art. 9 DSGVO)
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Markdown-Abschnitt als String (leer wenn keine Art. 9 Daten)
|
||||
*/
|
||||
export function generateSpecialCategorySection(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const special = dataPoints.filter(dp => dp.isSpecialCategory)
|
||||
|
||||
if (special.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (lang === 'de') {
|
||||
const items = special.map(dp =>
|
||||
`- **${getText(dp.name, lang)}**: ${getText(dp.description, lang)}`
|
||||
).join('\n')
|
||||
|
||||
return `## Verarbeitung besonderer Kategorien personenbezogener Daten (Art. 9 DSGVO)
|
||||
|
||||
Wir verarbeiten folgende besondere Kategorien personenbezogener Daten:
|
||||
|
||||
${items}
|
||||
|
||||
Die Verarbeitung erfolgt auf Grundlage Ihrer ausdrücklichen Einwilligung gemäß Art. 9 Abs. 2 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen.`
|
||||
} else {
|
||||
const items = special.map(dp =>
|
||||
`- **${getText(dp.name, lang)}**: ${getText(dp.description, lang)}`
|
||||
).join('\n')
|
||||
|
||||
return `## Processing of Special Categories of Personal Data (Art. 9 GDPR)
|
||||
|
||||
We process the following special categories of personal data:
|
||||
|
||||
${items}
|
||||
|
||||
Processing is based on your explicit consent pursuant to Art. 9(2)(a) GDPR. You may withdraw your consent at any time with effect for the future.`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Liste aller eindeutigen Verarbeitungszwecke
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Kommaseparierte Liste der Zwecke
|
||||
*/
|
||||
export function generatePurposesList(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const purposes = new Set<string>()
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
purposes.add(getText(dp.purpose, lang))
|
||||
})
|
||||
|
||||
return [...purposes].join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Liste aller verwendeten Rechtsgrundlagen
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Formatierte Liste der Rechtsgrundlagen
|
||||
*/
|
||||
export function generateLegalBasisList(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const bases = new Set<LegalBasis>()
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
bases.add(dp.legalBasis)
|
||||
})
|
||||
|
||||
return [...bases].map(basis => {
|
||||
const info = LEGAL_BASIS_INFO[basis]
|
||||
return `${info.article} (${getText(info.name, lang)})`
|
||||
}).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Liste aller Speicherfristen gruppiert
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Formatierte Liste der Speicherfristen mit zugehörigen Kategorien
|
||||
*/
|
||||
export function generateRetentionList(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const grouped = groupByRetention(dataPoints)
|
||||
const entries: string[] = []
|
||||
|
||||
for (const [period, points] of Object.entries(grouped)) {
|
||||
const retentionInfo = RETENTION_PERIOD_INFO[period as RetentionPeriod]
|
||||
const categories = [...new Set(points.map(p => getText(CATEGORY_METADATA[p.category].name, lang)))]
|
||||
|
||||
entries.push(`${getText(retentionInfo.label, lang)}: ${categories.join(', ')}`)
|
||||
}
|
||||
|
||||
return entries.join('; ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Liste aller Empfänger/Drittparteien
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @returns Kommaseparierte Liste der Empfänger
|
||||
*/
|
||||
export function generateRecipientsList(dataPoints: DataPoint[]): string {
|
||||
const recipients = new Set<string>()
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
|
||||
})
|
||||
|
||||
if (recipients.size === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return [...recipients].join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Abschnitt für Drittland-Übermittlungen
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte mit thirdCountryTransfer === true
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Markdown-Abschnitt als String
|
||||
*/
|
||||
export function generateThirdCountrySection(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
// Note: We assume dataPoints have been filtered for thirdCountryTransfer
|
||||
// The actual flag would need to be added to the DataPoint interface
|
||||
// For now, we check if any thirdPartyRecipients suggest third country
|
||||
const thirdCountryIndicators = ['Google', 'AWS', 'Microsoft', 'Meta', 'Facebook', 'Cloudflare']
|
||||
|
||||
const thirdCountryPoints = dataPoints.filter(dp =>
|
||||
dp.thirdPartyRecipients?.some(r =>
|
||||
thirdCountryIndicators.some(indicator =>
|
||||
r.toLowerCase().includes(indicator.toLowerCase())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (thirdCountryPoints.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const recipients = new Set<string>()
|
||||
thirdCountryPoints.forEach(dp => {
|
||||
dp.thirdPartyRecipients?.forEach(r => {
|
||||
if (thirdCountryIndicators.some(i => r.toLowerCase().includes(i.toLowerCase()))) {
|
||||
recipients.add(r)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (lang === 'de') {
|
||||
return `## Übermittlung in Drittländer
|
||||
|
||||
Wir übermitteln personenbezogene Daten an folgende Empfänger in Drittländern (außerhalb der EU/des EWR):
|
||||
|
||||
${[...recipients].map(r => `- ${r}`).join('\n')}
|
||||
|
||||
Die Übermittlung erfolgt auf Grundlage von Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO) bzw. einem Angemessenheitsbeschluss der EU-Kommission (Art. 45 DSGVO).`
|
||||
} else {
|
||||
return `## Transfers to Third Countries
|
||||
|
||||
We transfer personal data to the following recipients in third countries (outside the EU/EEA):
|
||||
|
||||
${[...recipients].map(r => `- ${r}`).join('\n')}
|
||||
|
||||
The transfer is based on Standard Contractual Clauses (Art. 46(2)(c) GDPR) or an adequacy decision by the EU Commission (Art. 45 GDPR).`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Risiko-Zusammenfassung
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Formatierte Risiko-Zusammenfassung
|
||||
*/
|
||||
export function generateRiskSummary(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): string {
|
||||
const riskCounts: Record<RiskLevel, number> = {
|
||||
LOW: 0,
|
||||
MEDIUM: 0,
|
||||
HIGH: 0
|
||||
}
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
riskCounts[dp.riskLevel]++
|
||||
})
|
||||
|
||||
const parts = Object.entries(riskCounts)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([level, count]) => {
|
||||
const styling = RISK_LEVEL_STYLING[level as RiskLevel]
|
||||
return `${count} ${getText(styling.label, lang).toLowerCase()}`
|
||||
})
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert alle Platzhalter für den Dokumentengenerator
|
||||
*
|
||||
* @param dataPoints - Liste der ausgewählten Datenpunkte
|
||||
* @param lang - Sprache für die Ausgabe
|
||||
* @returns Objekt mit allen Platzhaltern
|
||||
*/
|
||||
export function generateAllPlaceholders(
|
||||
dataPoints: DataPoint[],
|
||||
lang: Language = 'de'
|
||||
): DataPointPlaceholders {
|
||||
return {
|
||||
'[DATENPUNKTE_COUNT]': String(dataPoints.length),
|
||||
'[DATENPUNKTE_LIST]': dataPoints.map(dp => getText(dp.name, lang)).join(', '),
|
||||
'[DATENPUNKTE_TABLE]': generateDataPointsTable(dataPoints, lang),
|
||||
'[VERARBEITUNGSZWECKE]': generatePurposesList(dataPoints, lang),
|
||||
'[RECHTSGRUNDLAGEN]': generateLegalBasisList(dataPoints, lang),
|
||||
'[SPEICHERFRISTEN]': generateRetentionList(dataPoints, lang),
|
||||
'[EMPFAENGER]': generateRecipientsList(dataPoints),
|
||||
'[BESONDERE_KATEGORIEN]': generateSpecialCategorySection(dataPoints, lang),
|
||||
'[DRITTLAND_TRANSFERS]': generateThirdCountrySection(dataPoints, lang),
|
||||
'[RISIKO_ZUSAMMENFASSUNG]': generateRiskSummary(dataPoints, lang)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VALIDATION HELPERS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Validierungswarnung für den Dokumentengenerator
|
||||
*/
|
||||
export interface ValidationWarning {
|
||||
type: 'error' | 'warning' | 'info'
|
||||
code: string
|
||||
message: string
|
||||
suggestion: string
|
||||
affectedDataPoints?: DataPoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob besondere Kategorien vorhanden sind aber kein entsprechender Abschnitt
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param documentContent - Der generierte Dokumentinhalt
|
||||
* @param lang - Sprache
|
||||
* @returns ValidationWarning oder null
|
||||
*/
|
||||
export function checkSpecialCategoriesWarning(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning | null {
|
||||
const specialCategories = dataPoints.filter(dp => dp.isSpecialCategory)
|
||||
|
||||
if (specialCategories.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasSection = lang === 'de'
|
||||
? documentContent.includes('Art. 9') || documentContent.includes('Artikel 9') || documentContent.includes('besondere Kategorie')
|
||||
: documentContent.includes('Art. 9') || documentContent.includes('Article 9') || documentContent.includes('special categor')
|
||||
|
||||
if (!hasSection) {
|
||||
return {
|
||||
type: 'error',
|
||||
code: 'MISSING_ART9_SECTION',
|
||||
message: lang === 'de'
|
||||
? `${specialCategories.length} besondere Datenkategorien (Art. 9 DSGVO) ausgewählt, aber kein entsprechender Abschnitt im Dokument gefunden.`
|
||||
: `${specialCategories.length} special data categories (Art. 9 GDPR) selected, but no corresponding section found in document.`,
|
||||
suggestion: lang === 'de'
|
||||
? 'Fügen Sie einen Abschnitt zu besonderen Kategorien personenbezogener Daten hinzu oder verwenden Sie [BESONDERE_KATEGORIEN] als Platzhalter.'
|
||||
: 'Add a section about special categories of personal data or use [BESONDERE_KATEGORIEN] as placeholder.',
|
||||
affectedDataPoints: specialCategories
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Drittland-Übermittlungen vorhanden sind aber keine SCC erwähnt werden
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param documentContent - Der generierte Dokumentinhalt
|
||||
* @param lang - Sprache
|
||||
* @returns ValidationWarning oder null
|
||||
*/
|
||||
export function checkThirdCountryWarning(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning | null {
|
||||
const thirdCountryIndicators = ['Google', 'AWS', 'Microsoft', 'Meta', 'Facebook', 'Cloudflare', 'USA', 'US']
|
||||
|
||||
const thirdCountryPoints = dataPoints.filter(dp =>
|
||||
dp.thirdPartyRecipients?.some(r =>
|
||||
thirdCountryIndicators.some(i => r.toLowerCase().includes(i.toLowerCase()))
|
||||
)
|
||||
)
|
||||
|
||||
if (thirdCountryPoints.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasSCCMention = lang === 'de'
|
||||
? documentContent.includes('Standardvertragsklauseln') || documentContent.includes('SCC') || documentContent.includes('Art. 46')
|
||||
: documentContent.includes('Standard Contractual Clauses') || documentContent.includes('SCC') || documentContent.includes('Art. 46')
|
||||
|
||||
if (!hasSCCMention) {
|
||||
return {
|
||||
type: 'warning',
|
||||
code: 'MISSING_SCC_SECTION',
|
||||
message: lang === 'de'
|
||||
? `Drittland-Übermittlung für ${thirdCountryPoints.length} Datenpunkte erkannt, aber keine Standardvertragsklauseln (SCC) erwähnt.`
|
||||
: `Third country transfer detected for ${thirdCountryPoints.length} data points, but no Standard Contractual Clauses (SCC) mentioned.`,
|
||||
suggestion: lang === 'de'
|
||||
? 'Erwägen Sie die Aufnahme eines Abschnitts zu Drittland-Übermittlungen und Standardvertragsklauseln oder verwenden Sie [DRITTLAND_TRANSFERS] als Platzhalter.'
|
||||
: 'Consider adding a section about third country transfers and Standard Contractual Clauses or use [DRITTLAND_TRANSFERS] as placeholder.',
|
||||
affectedDataPoints: thirdCountryPoints
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Datenpunkte mit expliziter Einwilligung korrekt behandelt werden
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param documentContent - Der generierte Dokumentinhalt
|
||||
* @param lang - Sprache
|
||||
* @returns ValidationWarning oder null
|
||||
*/
|
||||
export function checkExplicitConsentWarning(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning | null {
|
||||
const explicitConsentPoints = dataPoints.filter(dp => dp.requiresExplicitConsent)
|
||||
|
||||
if (explicitConsentPoints.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasConsentSection = lang === 'de'
|
||||
? documentContent.includes('Einwilligung') || documentContent.includes('Widerruf') || documentContent.includes('Art. 7')
|
||||
: documentContent.includes('consent') || documentContent.includes('withdraw') || documentContent.includes('Art. 7')
|
||||
|
||||
if (!hasConsentSection) {
|
||||
return {
|
||||
type: 'warning',
|
||||
code: 'MISSING_CONSENT_SECTION',
|
||||
message: lang === 'de'
|
||||
? `${explicitConsentPoints.length} Datenpunkte erfordern ausdrückliche Einwilligung, aber kein Abschnitt zu Einwilligung/Widerruf gefunden.`
|
||||
: `${explicitConsentPoints.length} data points require explicit consent, but no section about consent/withdrawal found.`,
|
||||
suggestion: lang === 'de'
|
||||
? 'Fügen Sie einen Abschnitt zum Widerrufsrecht hinzu.'
|
||||
: 'Add a section about the right to withdraw consent.',
|
||||
affectedDataPoints: explicitConsentPoints
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt alle Validierungsprüfungen durch
|
||||
*
|
||||
* @param dataPoints - Liste der Datenpunkte
|
||||
* @param documentContent - Der generierte Dokumentinhalt
|
||||
* @param lang - Sprache
|
||||
* @returns Array aller Warnungen
|
||||
*/
|
||||
export function validateDocument(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning[] {
|
||||
const warnings: ValidationWarning[] = []
|
||||
|
||||
const specialCatWarning = checkSpecialCategoriesWarning(dataPoints, documentContent, lang)
|
||||
if (specialCatWarning) warnings.push(specialCatWarning)
|
||||
|
||||
const thirdCountryWarning = checkThirdCountryWarning(dataPoints, documentContent, lang)
|
||||
if (thirdCountryWarning) warnings.push(thirdCountryWarning)
|
||||
|
||||
const consentWarning = checkExplicitConsentWarning(dataPoints, documentContent, lang)
|
||||
if (consentWarning) warnings.push(consentWarning)
|
||||
|
||||
return warnings
|
||||
}
|
||||
export type {
|
||||
Language,
|
||||
DataPointPlaceholders,
|
||||
} from './datapoint-generators'
|
||||
|
||||
export {
|
||||
generateDataPointsTable,
|
||||
groupByRetention,
|
||||
groupByCategory,
|
||||
generateSpecialCategorySection,
|
||||
generatePurposesList,
|
||||
generateLegalBasisList,
|
||||
generateRetentionList,
|
||||
generateRecipientsList,
|
||||
generateThirdCountrySection,
|
||||
generateRiskSummary,
|
||||
generateAllPlaceholders,
|
||||
} from './datapoint-generators'
|
||||
|
||||
export type {
|
||||
ValidationWarning,
|
||||
} from './datapoint-validators'
|
||||
|
||||
export {
|
||||
checkSpecialCategoriesWarning,
|
||||
checkThirdCountryWarning,
|
||||
checkExplicitConsentWarning,
|
||||
validateDocument,
|
||||
} from './datapoint-validators'
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Datapoint Helpers — Validation Functions
|
||||
*
|
||||
* Document validation checks for DSGVO compliance.
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
LocalizedText,
|
||||
SupportedLanguage,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
import type { Language } from './datapoint-generators'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ValidationWarning {
|
||||
type: 'error' | 'warning' | 'info'
|
||||
code: string
|
||||
message: string
|
||||
suggestion: string
|
||||
affectedDataPoints?: DataPoint[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VALIDATION FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function checkSpecialCategoriesWarning(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning | null {
|
||||
const specialCategories = dataPoints.filter(dp => dp.isSpecialCategory)
|
||||
|
||||
if (specialCategories.length === 0) return null
|
||||
|
||||
const hasSection = lang === 'de'
|
||||
? documentContent.includes('Art. 9') || documentContent.includes('Artikel 9') || documentContent.includes('besondere Kategorie')
|
||||
: documentContent.includes('Art. 9') || documentContent.includes('Article 9') || documentContent.includes('special categor')
|
||||
|
||||
if (!hasSection) {
|
||||
return {
|
||||
type: 'error',
|
||||
code: 'MISSING_ART9_SECTION',
|
||||
message: lang === 'de'
|
||||
? `${specialCategories.length} besondere Datenkategorien (Art. 9 DSGVO) ausgewaehlt, aber kein entsprechender Abschnitt im Dokument gefunden.`
|
||||
: `${specialCategories.length} special data categories (Art. 9 GDPR) selected, but no corresponding section found in document.`,
|
||||
suggestion: lang === 'de'
|
||||
? 'Fuegen Sie einen Abschnitt zu besonderen Kategorien personenbezogener Daten hinzu oder verwenden Sie [BESONDERE_KATEGORIEN] als Platzhalter.'
|
||||
: 'Add a section about special categories of personal data or use [BESONDERE_KATEGORIEN] as placeholder.',
|
||||
affectedDataPoints: specialCategories
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function checkThirdCountryWarning(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning | null {
|
||||
const thirdCountryIndicators = ['Google', 'AWS', 'Microsoft', 'Meta', 'Facebook', 'Cloudflare', 'USA', 'US']
|
||||
|
||||
const thirdCountryPoints = dataPoints.filter(dp =>
|
||||
dp.thirdPartyRecipients?.some(r =>
|
||||
thirdCountryIndicators.some(i => r.toLowerCase().includes(i.toLowerCase()))
|
||||
)
|
||||
)
|
||||
|
||||
if (thirdCountryPoints.length === 0) return null
|
||||
|
||||
const hasSCCMention = lang === 'de'
|
||||
? documentContent.includes('Standardvertragsklauseln') || documentContent.includes('SCC') || documentContent.includes('Art. 46')
|
||||
: documentContent.includes('Standard Contractual Clauses') || documentContent.includes('SCC') || documentContent.includes('Art. 46')
|
||||
|
||||
if (!hasSCCMention) {
|
||||
return {
|
||||
type: 'warning',
|
||||
code: 'MISSING_SCC_SECTION',
|
||||
message: lang === 'de'
|
||||
? `Drittland-Uebermittlung fuer ${thirdCountryPoints.length} Datenpunkte erkannt, aber keine Standardvertragsklauseln (SCC) erwaehnt.`
|
||||
: `Third country transfer detected for ${thirdCountryPoints.length} data points, but no Standard Contractual Clauses (SCC) mentioned.`,
|
||||
suggestion: lang === 'de'
|
||||
? 'Erwaegen Sie die Aufnahme eines Abschnitts zu Drittland-Uebermittlungen und Standardvertragsklauseln oder verwenden Sie [DRITTLAND_TRANSFERS] als Platzhalter.'
|
||||
: 'Consider adding a section about third country transfers and Standard Contractual Clauses or use [DRITTLAND_TRANSFERS] as placeholder.',
|
||||
affectedDataPoints: thirdCountryPoints
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function checkExplicitConsentWarning(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning | null {
|
||||
const explicitConsentPoints = dataPoints.filter(dp => dp.requiresExplicitConsent)
|
||||
|
||||
if (explicitConsentPoints.length === 0) return null
|
||||
|
||||
const hasConsentSection = lang === 'de'
|
||||
? documentContent.includes('Einwilligung') || documentContent.includes('Widerruf') || documentContent.includes('Art. 7')
|
||||
: documentContent.includes('consent') || documentContent.includes('withdraw') || documentContent.includes('Art. 7')
|
||||
|
||||
if (!hasConsentSection) {
|
||||
return {
|
||||
type: 'warning',
|
||||
code: 'MISSING_CONSENT_SECTION',
|
||||
message: lang === 'de'
|
||||
? `${explicitConsentPoints.length} Datenpunkte erfordern ausdrueckliche Einwilligung, aber kein Abschnitt zu Einwilligung/Widerruf gefunden.`
|
||||
: `${explicitConsentPoints.length} data points require explicit consent, but no section about consent/withdrawal found.`,
|
||||
suggestion: lang === 'de'
|
||||
? 'Fuegen Sie einen Abschnitt zum Widerrufsrecht hinzu.'
|
||||
: 'Add a section about the right to withdraw consent.',
|
||||
affectedDataPoints: explicitConsentPoints
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateDocument(
|
||||
dataPoints: DataPoint[],
|
||||
documentContent: string,
|
||||
lang: Language = 'de'
|
||||
): ValidationWarning[] {
|
||||
const warnings: ValidationWarning[] = []
|
||||
|
||||
const specialCatWarning = checkSpecialCategoriesWarning(dataPoints, documentContent, lang)
|
||||
if (specialCatWarning) warnings.push(specialCatWarning)
|
||||
|
||||
const thirdCountryWarning = checkThirdCountryWarning(dataPoints, documentContent, lang)
|
||||
if (thirdCountryWarning) warnings.push(thirdCountryWarning)
|
||||
|
||||
const consentWarning = checkExplicitConsentWarning(dataPoints, documentContent, lang)
|
||||
if (consentWarning) warnings.push(consentWarning)
|
||||
|
||||
return warnings
|
||||
}
|
||||
243
admin-compliance/lib/sdk/dsr/types-api.ts
Normal file
243
admin-compliance/lib/sdk/dsr/types-api.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* DSR Types — API Types, Communication, Audit, Templates, Statistics & Helpers
|
||||
*/
|
||||
|
||||
import type {
|
||||
DSRType,
|
||||
DSRStatus,
|
||||
DSRPriority,
|
||||
DSRSource,
|
||||
DSRRequester,
|
||||
DSRAssignment,
|
||||
DSRRequest,
|
||||
DSRDataExport,
|
||||
IdentityVerificationMethod,
|
||||
CommunicationType,
|
||||
CommunicationChannel,
|
||||
DSRTypeInfo,
|
||||
} from './types-core'
|
||||
|
||||
export { DSR_TYPE_INFO } from './types-core'
|
||||
|
||||
// =============================================================================
|
||||
// COMMUNICATION
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRCommunication {
|
||||
id: string
|
||||
dsrId: string
|
||||
type: CommunicationType
|
||||
channel: CommunicationChannel
|
||||
subject?: string
|
||||
content: string
|
||||
templateUsed?: string
|
||||
attachments?: {
|
||||
name: string
|
||||
url: string
|
||||
size: number
|
||||
type: string
|
||||
}[]
|
||||
sentAt?: string
|
||||
sentBy?: string
|
||||
receivedAt?: string
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUDIT LOG
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRAuditEntry {
|
||||
id: string
|
||||
dsrId: string
|
||||
action: string
|
||||
previousValue?: string
|
||||
newValue?: string
|
||||
performedBy: string
|
||||
performedAt: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMAIL TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSREmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
body: string
|
||||
type: DSRType | 'general'
|
||||
stage: DSRStatus | 'identity_request' | 'deadline_extension' | 'completion'
|
||||
language: 'de' | 'en'
|
||||
variables: string[]
|
||||
}
|
||||
|
||||
export const DSR_EMAIL_TEMPLATES: DSREmailTemplate[] = [
|
||||
{
|
||||
id: 'intake_confirmation',
|
||||
name: 'Eingangsbestaetigung',
|
||||
subject: 'Bestaetigung Ihrer Anfrage - {{referenceNumber}}',
|
||||
body: `Sehr geehrte(r) {{requesterName}},
|
||||
|
||||
wir bestaetigen den Eingang Ihrer Anfrage vom {{receivedDate}}.
|
||||
|
||||
Referenznummer: {{referenceNumber}}
|
||||
Art der Anfrage: {{requestType}}
|
||||
|
||||
Wir werden Ihre Anfrage innerhalb der gesetzlichen Frist von {{deadline}} bearbeiten.
|
||||
|
||||
Mit freundlichen Gruessen
|
||||
{{senderName}}
|
||||
Datenschutzbeauftragter`,
|
||||
type: 'general',
|
||||
stage: 'intake',
|
||||
language: 'de',
|
||||
variables: ['requesterName', 'receivedDate', 'referenceNumber', 'requestType', 'deadline', 'senderName']
|
||||
},
|
||||
{
|
||||
id: 'identity_request',
|
||||
name: 'Identitaetsanfrage',
|
||||
subject: 'Identitaetspruefung erforderlich - {{referenceNumber}}',
|
||||
body: `Sehr geehrte(r) {{requesterName}},
|
||||
|
||||
um Ihre Anfrage bearbeiten zu koennen, benoetigen wir einen Nachweis Ihrer Identitaet.
|
||||
|
||||
Bitte senden Sie uns eines der folgenden Dokumente:
|
||||
- Kopie Ihres Personalausweises (Vorder- und Rueckseite)
|
||||
- Kopie Ihres Reisepasses
|
||||
|
||||
Ihre Daten werden ausschliesslich zur Identitaetspruefung verwendet und anschliessend geloescht.
|
||||
|
||||
Mit freundlichen Gruessen
|
||||
{{senderName}}
|
||||
Datenschutzbeauftragter`,
|
||||
type: 'general',
|
||||
stage: 'identity_request',
|
||||
language: 'de',
|
||||
variables: ['requesterName', 'referenceNumber', 'senderName']
|
||||
}
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// API TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRFilters {
|
||||
status?: DSRStatus | DSRStatus[]
|
||||
type?: DSRType | DSRType[]
|
||||
priority?: DSRPriority
|
||||
assignedTo?: string
|
||||
overdue?: boolean
|
||||
search?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
|
||||
export interface DSRListResponse {
|
||||
requests: DSRRequest[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DSRCreateRequest {
|
||||
type: DSRType
|
||||
requester: DSRRequester
|
||||
source: DSRSource
|
||||
sourceDetails?: string
|
||||
requestText?: string
|
||||
priority?: DSRPriority
|
||||
}
|
||||
|
||||
export interface DSRUpdateRequest {
|
||||
status?: DSRStatus
|
||||
priority?: DSRPriority
|
||||
notes?: string
|
||||
internalNotes?: string
|
||||
assignment?: DSRAssignment
|
||||
}
|
||||
|
||||
export interface DSRVerifyIdentityRequest {
|
||||
method: IdentityVerificationMethod
|
||||
notes?: string
|
||||
documentRef?: string
|
||||
}
|
||||
|
||||
export interface DSRCompleteRequest {
|
||||
completionNotes?: string
|
||||
dataExport?: DSRDataExport
|
||||
}
|
||||
|
||||
export interface DSRRejectRequest {
|
||||
reason: string
|
||||
legalBasis?: string
|
||||
}
|
||||
|
||||
export interface DSRExtendDeadlineRequest {
|
||||
extensionMonths: 1 | 2
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface DSRSendCommunicationRequest {
|
||||
type: CommunicationType
|
||||
channel: CommunicationChannel
|
||||
subject?: string
|
||||
content: string
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRStatistics {
|
||||
total: number
|
||||
byStatus: Record<DSRStatus, number>
|
||||
byType: Record<DSRType, number>
|
||||
overdue: number
|
||||
dueThisWeek: number
|
||||
averageProcessingDays: number
|
||||
completedThisMonth: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function getDaysRemaining(deadline: string): number {
|
||||
const deadlineDate = new Date(deadline)
|
||||
const now = new Date()
|
||||
const diff = deadlineDate.getTime() - now.getTime()
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function isOverdue(request: DSRRequest): boolean {
|
||||
if (request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
return getDaysRemaining(request.deadline.currentDeadline) < 0
|
||||
}
|
||||
|
||||
export function isUrgent(request: DSRRequest, thresholdDays: number = 7): boolean {
|
||||
if (request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
|
||||
return daysRemaining >= 0 && daysRemaining <= thresholdDays
|
||||
}
|
||||
|
||||
export function generateReferenceNumber(year: number, sequence: number): string {
|
||||
return `DSR-${year}-${String(sequence).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
export function getTypeInfo(type: DSRType): DSRTypeInfo {
|
||||
const { DSR_TYPE_INFO } = require('./types-core')
|
||||
return DSR_TYPE_INFO[type]
|
||||
}
|
||||
|
||||
export function getStatusInfo(status: DSRStatus) {
|
||||
const { DSR_STATUS_INFO } = require('./types-core')
|
||||
return DSR_STATUS_INFO[status]
|
||||
}
|
||||
235
admin-compliance/lib/sdk/dsr/types-core.ts
Normal file
235
admin-compliance/lib/sdk/dsr/types-core.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* DSR (Data Subject Request) Types — Core Types & Constants
|
||||
*
|
||||
* Enums, constants, metadata, and main interfaces for GDPR Art. 15-21
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// ENUMS & CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export type DSRType =
|
||||
| 'access' // Art. 15
|
||||
| 'rectification' // Art. 16
|
||||
| 'erasure' // Art. 17
|
||||
| 'restriction' // Art. 18
|
||||
| 'portability' // Art. 20
|
||||
| 'objection' // Art. 21
|
||||
|
||||
export type DSRStatus =
|
||||
| 'intake'
|
||||
| 'identity_verification'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'rejected'
|
||||
| 'cancelled'
|
||||
|
||||
export type DSRPriority = 'low' | 'normal' | 'high' | 'critical'
|
||||
|
||||
export type DSRSource =
|
||||
| 'web_form' | 'email' | 'letter' | 'phone' | 'in_person' | 'other'
|
||||
|
||||
export type IdentityVerificationMethod =
|
||||
| 'id_document' | 'email' | 'phone' | 'postal' | 'existing_account' | 'other'
|
||||
|
||||
export type CommunicationType = 'incoming' | 'outgoing' | 'internal'
|
||||
|
||||
export type CommunicationChannel =
|
||||
| 'email' | 'letter' | 'phone' | 'portal' | 'internal_note'
|
||||
|
||||
// =============================================================================
|
||||
// DSR TYPE METADATA
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRTypeInfo {
|
||||
type: DSRType
|
||||
article: string
|
||||
label: string
|
||||
labelShort: string
|
||||
description: string
|
||||
defaultDeadlineDays: number
|
||||
maxExtensionMonths: number
|
||||
color: string
|
||||
bgColor: string
|
||||
processDocument?: string
|
||||
}
|
||||
|
||||
export const DSR_TYPE_INFO: Record<DSRType, DSRTypeInfo> = {
|
||||
access: {
|
||||
type: 'access', article: 'Art. 15', label: 'Auskunftsrecht', labelShort: 'Auskunft',
|
||||
description: 'Recht auf Auskunft ueber gespeicherte personenbezogene Daten',
|
||||
defaultDeadlineDays: 30, maxExtensionMonths: 2,
|
||||
color: 'text-blue-700', bgColor: 'bg-blue-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 15 DSGVO_v02.pdf'
|
||||
},
|
||||
rectification: {
|
||||
type: 'rectification', article: 'Art. 16', label: 'Berichtigungsrecht', labelShort: 'Berichtigung',
|
||||
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
|
||||
defaultDeadlineDays: 14, maxExtensionMonths: 2,
|
||||
color: 'text-yellow-700', bgColor: 'bg-yellow-100',
|
||||
processDocument: 'Prozessbeschriebung Art. 16 DSGVO_v02.pdf'
|
||||
},
|
||||
erasure: {
|
||||
type: 'erasure', article: 'Art. 17', label: 'Loeschungsrecht', labelShort: 'Loeschung',
|
||||
description: 'Recht auf Loeschung personenbezogener Daten ("Recht auf Vergessenwerden")',
|
||||
defaultDeadlineDays: 14, maxExtensionMonths: 2,
|
||||
color: 'text-red-700', bgColor: 'bg-red-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 17 DSGVO_v03.pdf'
|
||||
},
|
||||
restriction: {
|
||||
type: 'restriction', article: 'Art. 18', label: 'Einschraenkungsrecht', labelShort: 'Einschraenkung',
|
||||
description: 'Recht auf Einschraenkung der Verarbeitung',
|
||||
defaultDeadlineDays: 14, maxExtensionMonths: 2,
|
||||
color: 'text-orange-700', bgColor: 'bg-orange-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 18 DSGVO_v01.pdf'
|
||||
},
|
||||
portability: {
|
||||
type: 'portability', article: 'Art. 20', label: 'Datenuebertragbarkeit', labelShort: 'Uebertragung',
|
||||
description: 'Recht auf Datenuebertragbarkeit in maschinenlesbarem Format',
|
||||
defaultDeadlineDays: 30, maxExtensionMonths: 2,
|
||||
color: 'text-purple-700', bgColor: 'bg-purple-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 20 DSGVO_v02.pdf'
|
||||
},
|
||||
objection: {
|
||||
type: 'objection', article: 'Art. 21', label: 'Widerspruchsrecht', labelShort: 'Widerspruch',
|
||||
description: 'Recht auf Widerspruch gegen die Verarbeitung',
|
||||
defaultDeadlineDays: 30, maxExtensionMonths: 0,
|
||||
color: 'text-gray-700', bgColor: 'bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
export const DSR_STATUS_INFO: Record<DSRStatus, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||
intake: { label: 'Eingang', color: 'text-blue-700', bgColor: 'bg-blue-100', borderColor: 'border-blue-200' },
|
||||
identity_verification: { label: 'ID-Pruefung', color: 'text-yellow-700', bgColor: 'bg-yellow-100', borderColor: 'border-yellow-200' },
|
||||
processing: { label: 'In Bearbeitung', color: 'text-purple-700', bgColor: 'bg-purple-100', borderColor: 'border-purple-200' },
|
||||
completed: { label: 'Abgeschlossen', color: 'text-green-700', bgColor: 'bg-green-100', borderColor: 'border-green-200' },
|
||||
rejected: { label: 'Abgelehnt', color: 'text-red-700', bgColor: 'bg-red-100', borderColor: 'border-red-200' },
|
||||
cancelled: { label: 'Storniert', color: 'text-gray-700', bgColor: 'bg-gray-100', borderColor: 'border-gray-200' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN INTERFACES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRRequester {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
address?: string
|
||||
customerId?: string
|
||||
}
|
||||
|
||||
export interface DSRIdentityVerification {
|
||||
verified: boolean
|
||||
method?: IdentityVerificationMethod
|
||||
verifiedAt?: string
|
||||
verifiedBy?: string
|
||||
notes?: string
|
||||
documentRef?: string
|
||||
}
|
||||
|
||||
export interface DSRAssignment {
|
||||
assignedTo: string | null
|
||||
assignedAt?: string
|
||||
assignedBy?: string
|
||||
}
|
||||
|
||||
export interface DSRDeadline {
|
||||
originalDeadline: string
|
||||
currentDeadline: string
|
||||
extended: boolean
|
||||
extensionReason?: string
|
||||
extensionApprovedBy?: string
|
||||
extensionApprovedAt?: string
|
||||
}
|
||||
|
||||
export interface DSRRequest {
|
||||
id: string
|
||||
referenceNumber: string
|
||||
type: DSRType
|
||||
status: DSRStatus
|
||||
priority: DSRPriority
|
||||
requester: DSRRequester
|
||||
source: DSRSource
|
||||
sourceDetails?: string
|
||||
requestText?: string
|
||||
receivedAt: string
|
||||
deadline: DSRDeadline
|
||||
completedAt?: string
|
||||
identityVerification: DSRIdentityVerification
|
||||
assignment: DSRAssignment
|
||||
notes?: string
|
||||
internalNotes?: string
|
||||
erasureChecklist?: DSRErasureChecklist
|
||||
dataExport?: DSRDataExport
|
||||
rectificationDetails?: DSRRectificationDetails
|
||||
objectionDetails?: DSRObjectionDetails
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
updatedAt: string
|
||||
updatedBy?: string
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPE-SPECIFIC INTERFACES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRErasureChecklistItem {
|
||||
id: string
|
||||
article: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
applies: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface DSRErasureChecklist {
|
||||
items: DSRErasureChecklistItem[]
|
||||
canProceedWithErasure: boolean
|
||||
reviewedBy?: string
|
||||
reviewedAt?: string
|
||||
}
|
||||
|
||||
export const ERASURE_EXCEPTIONS: Omit<DSRErasureChecklistItem, 'checked' | 'applies' | 'notes'>[] = [
|
||||
{ id: 'art17_3_a', article: '17(3)(a)', label: 'Meinungs- und Informationsfreiheit', description: 'Ausuebung des Rechts auf freie Meinungsaeusserung und Information' },
|
||||
{ id: 'art17_3_b', article: '17(3)(b)', label: 'Rechtliche Verpflichtung', description: 'Erfuellung einer rechtlichen Verpflichtung (z.B. Aufbewahrungspflichten)' },
|
||||
{ id: 'art17_3_c', article: '17(3)(c)', label: 'Oeffentliches Interesse', description: 'Gruende des oeffentlichen Interesses im Bereich Gesundheit' },
|
||||
{ id: 'art17_3_d', article: '17(3)(d)', label: 'Archivzwecke', description: 'Archivzwecke, wissenschaftliche/historische Forschung, Statistik' },
|
||||
{ id: 'art17_3_e', article: '17(3)(e)', label: 'Rechtsansprueche', description: 'Geltendmachung, Ausuebung oder Verteidigung von Rechtsanspruechen' },
|
||||
]
|
||||
|
||||
export interface DSRDataExport {
|
||||
format: 'json' | 'csv' | 'xml' | 'pdf'
|
||||
generatedAt?: string
|
||||
generatedBy?: string
|
||||
fileUrl?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
includesThirdPartyData: boolean
|
||||
anonymizedFields?: string[]
|
||||
transferMethod?: 'download' | 'email' | 'third_party'
|
||||
transferRecipient?: string
|
||||
}
|
||||
|
||||
export interface DSRRectificationDetails {
|
||||
fieldsToCorrect: {
|
||||
field: string
|
||||
currentValue: string
|
||||
requestedValue: string
|
||||
corrected: boolean
|
||||
correctedAt?: string
|
||||
correctedBy?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface DSRObjectionDetails {
|
||||
processingPurpose: string
|
||||
legalBasis: string
|
||||
objectionGrounds: string
|
||||
decision: 'accepted' | 'rejected' | 'pending'
|
||||
decisionReason?: string
|
||||
decisionBy?: string
|
||||
decisionAt?: string
|
||||
}
|
||||
@@ -1,581 +1,60 @@
|
||||
/**
|
||||
* DSR (Data Subject Request) Types
|
||||
* DSR (Data Subject Request) Types — barrel re-export
|
||||
*
|
||||
* TypeScript definitions for GDPR Art. 15-21 Data Subject Requests
|
||||
* Based on the Go Consent Service backend API structure
|
||||
* Split into:
|
||||
* - types-core.ts (enums, constants, metadata, main interfaces)
|
||||
* - types-api.ts (API types, communication, audit, templates, helpers)
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// ENUMS & CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export type DSRType =
|
||||
| 'access' // Art. 15 - Auskunftsrecht
|
||||
| 'rectification' // Art. 16 - Berichtigungsrecht
|
||||
| 'erasure' // Art. 17 - Loeschungsrecht
|
||||
| 'restriction' // Art. 18 - Einschraenkungsrecht
|
||||
| 'portability' // Art. 20 - Datenuebertragbarkeit
|
||||
| 'objection' // Art. 21 - Widerspruchsrecht
|
||||
|
||||
export type DSRStatus =
|
||||
| 'intake' // Eingang - Anfrage dokumentiert
|
||||
| 'identity_verification' // Identitaetspruefung
|
||||
| 'processing' // In Bearbeitung
|
||||
| 'completed' // Abgeschlossen
|
||||
| 'rejected' // Abgelehnt
|
||||
| 'cancelled' // Storniert
|
||||
|
||||
export type DSRPriority = 'low' | 'normal' | 'high' | 'critical'
|
||||
|
||||
export type DSRSource =
|
||||
| 'web_form' // Kontaktformular/Portal
|
||||
| 'email' // E-Mail
|
||||
| 'letter' // Brief
|
||||
| 'phone' // Telefon
|
||||
| 'in_person' // Persoenlich
|
||||
| 'other' // Sonstiges
|
||||
|
||||
export type IdentityVerificationMethod =
|
||||
| 'id_document' // Ausweiskopie
|
||||
| 'email' // E-Mail-Bestaetigung
|
||||
| 'phone' // Telefonische Bestaetigung
|
||||
| 'postal' // Postalische Bestaetigung
|
||||
| 'existing_account' // Bestehendes Kundenkonto
|
||||
| 'other' // Sonstiges
|
||||
|
||||
export type CommunicationType =
|
||||
| 'incoming' // Eingehend (vom Betroffenen)
|
||||
| 'outgoing' // Ausgehend (an Betroffenen)
|
||||
| 'internal' // Intern (Notizen)
|
||||
|
||||
export type CommunicationChannel =
|
||||
| 'email'
|
||||
| 'letter'
|
||||
| 'phone'
|
||||
| 'portal'
|
||||
| 'internal_note'
|
||||
|
||||
// =============================================================================
|
||||
// DSR TYPE METADATA
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRTypeInfo {
|
||||
type: DSRType
|
||||
article: string
|
||||
label: string
|
||||
labelShort: string
|
||||
description: string
|
||||
defaultDeadlineDays: number
|
||||
maxExtensionMonths: number
|
||||
color: string
|
||||
bgColor: string
|
||||
processDocument?: string // Reference to process document
|
||||
}
|
||||
|
||||
export const DSR_TYPE_INFO: Record<DSRType, DSRTypeInfo> = {
|
||||
access: {
|
||||
type: 'access',
|
||||
article: 'Art. 15',
|
||||
label: 'Auskunftsrecht',
|
||||
labelShort: 'Auskunft',
|
||||
description: 'Recht auf Auskunft ueber gespeicherte personenbezogene Daten',
|
||||
defaultDeadlineDays: 30,
|
||||
maxExtensionMonths: 2,
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 15 DSGVO_v02.pdf'
|
||||
},
|
||||
rectification: {
|
||||
type: 'rectification',
|
||||
article: 'Art. 16',
|
||||
label: 'Berichtigungsrecht',
|
||||
labelShort: 'Berichtigung',
|
||||
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
|
||||
defaultDeadlineDays: 14,
|
||||
maxExtensionMonths: 2,
|
||||
color: 'text-yellow-700',
|
||||
bgColor: 'bg-yellow-100',
|
||||
processDocument: 'Prozessbeschriebung Art. 16 DSGVO_v02.pdf'
|
||||
},
|
||||
erasure: {
|
||||
type: 'erasure',
|
||||
article: 'Art. 17',
|
||||
label: 'Loeschungsrecht',
|
||||
labelShort: 'Loeschung',
|
||||
description: 'Recht auf Loeschung personenbezogener Daten ("Recht auf Vergessenwerden")',
|
||||
defaultDeadlineDays: 14,
|
||||
maxExtensionMonths: 2,
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 17 DSGVO_v03.pdf'
|
||||
},
|
||||
restriction: {
|
||||
type: 'restriction',
|
||||
article: 'Art. 18',
|
||||
label: 'Einschraenkungsrecht',
|
||||
labelShort: 'Einschraenkung',
|
||||
description: 'Recht auf Einschraenkung der Verarbeitung',
|
||||
defaultDeadlineDays: 14,
|
||||
maxExtensionMonths: 2,
|
||||
color: 'text-orange-700',
|
||||
bgColor: 'bg-orange-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 18 DSGVO_v01.pdf'
|
||||
},
|
||||
portability: {
|
||||
type: 'portability',
|
||||
article: 'Art. 20',
|
||||
label: 'Datenuebertragbarkeit',
|
||||
labelShort: 'Uebertragung',
|
||||
description: 'Recht auf Datenuebertragbarkeit in maschinenlesbarem Format',
|
||||
defaultDeadlineDays: 30,
|
||||
maxExtensionMonths: 2,
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-100',
|
||||
processDocument: 'Prozessbeschreibung Art. 20 DSGVO_v02.pdf'
|
||||
},
|
||||
objection: {
|
||||
type: 'objection',
|
||||
article: 'Art. 21',
|
||||
label: 'Widerspruchsrecht',
|
||||
labelShort: 'Widerspruch',
|
||||
description: 'Recht auf Widerspruch gegen die Verarbeitung',
|
||||
defaultDeadlineDays: 30,
|
||||
maxExtensionMonths: 0, // No extension allowed for objections
|
||||
color: 'text-gray-700',
|
||||
bgColor: 'bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
export const DSR_STATUS_INFO: Record<DSRStatus, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||
intake: {
|
||||
label: 'Eingang',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100',
|
||||
borderColor: 'border-blue-200'
|
||||
},
|
||||
identity_verification: {
|
||||
label: 'ID-Pruefung',
|
||||
color: 'text-yellow-700',
|
||||
bgColor: 'bg-yellow-100',
|
||||
borderColor: 'border-yellow-200'
|
||||
},
|
||||
processing: {
|
||||
label: 'In Bearbeitung',
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-100',
|
||||
borderColor: 'border-purple-200'
|
||||
},
|
||||
completed: {
|
||||
label: 'Abgeschlossen',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-100',
|
||||
borderColor: 'border-green-200'
|
||||
},
|
||||
rejected: {
|
||||
label: 'Abgelehnt',
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-100',
|
||||
borderColor: 'border-red-200'
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Storniert',
|
||||
color: 'text-gray-700',
|
||||
bgColor: 'bg-gray-100',
|
||||
borderColor: 'border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN INTERFACES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRRequester {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
address?: string
|
||||
customerId?: string // If existing customer
|
||||
}
|
||||
|
||||
export interface DSRIdentityVerification {
|
||||
verified: boolean
|
||||
method?: IdentityVerificationMethod
|
||||
verifiedAt?: string
|
||||
verifiedBy?: string
|
||||
notes?: string
|
||||
documentRef?: string // Reference to uploaded ID document
|
||||
}
|
||||
|
||||
export interface DSRAssignment {
|
||||
assignedTo: string | null
|
||||
assignedAt?: string
|
||||
assignedBy?: string
|
||||
}
|
||||
|
||||
export interface DSRDeadline {
|
||||
originalDeadline: string
|
||||
currentDeadline: string
|
||||
extended: boolean
|
||||
extensionReason?: string
|
||||
extensionApprovedBy?: string
|
||||
extensionApprovedAt?: string
|
||||
}
|
||||
|
||||
export interface DSRRequest {
|
||||
id: string
|
||||
referenceNumber: string // e.g., "DSR-2025-000042"
|
||||
type: DSRType
|
||||
status: DSRStatus
|
||||
priority: DSRPriority
|
||||
|
||||
// Requester info
|
||||
requester: DSRRequester
|
||||
|
||||
// Request details
|
||||
source: DSRSource
|
||||
sourceDetails?: string // e.g., "Kontaktformular auf website.de"
|
||||
requestText?: string // Original request text
|
||||
|
||||
// Dates
|
||||
receivedAt: string
|
||||
deadline: DSRDeadline
|
||||
completedAt?: string
|
||||
|
||||
// Verification
|
||||
identityVerification: DSRIdentityVerification
|
||||
|
||||
// Assignment
|
||||
assignment: DSRAssignment
|
||||
|
||||
// Processing
|
||||
notes?: string
|
||||
internalNotes?: string
|
||||
|
||||
// Type-specific data
|
||||
erasureChecklist?: DSRErasureChecklist // For Art. 17
|
||||
dataExport?: DSRDataExport // For Art. 15, 20
|
||||
rectificationDetails?: DSRRectificationDetails // For Art. 16
|
||||
objectionDetails?: DSRObjectionDetails // For Art. 21
|
||||
|
||||
// Audit
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
updatedAt: string
|
||||
updatedBy?: string
|
||||
|
||||
// Metadata
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPE-SPECIFIC INTERFACES
|
||||
// =============================================================================
|
||||
|
||||
// Art. 17(3) Erasure Exceptions Checklist
|
||||
export interface DSRErasureChecklistItem {
|
||||
id: string
|
||||
article: string // e.g., "17(3)(a)"
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
applies: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface DSRErasureChecklist {
|
||||
items: DSRErasureChecklistItem[]
|
||||
canProceedWithErasure: boolean
|
||||
reviewedBy?: string
|
||||
reviewedAt?: string
|
||||
}
|
||||
|
||||
export const ERASURE_EXCEPTIONS: Omit<DSRErasureChecklistItem, 'checked' | 'applies' | 'notes'>[] = [
|
||||
{
|
||||
id: 'art17_3_a',
|
||||
article: '17(3)(a)',
|
||||
label: 'Meinungs- und Informationsfreiheit',
|
||||
description: 'Ausuebung des Rechts auf freie Meinungsaeusserung und Information'
|
||||
},
|
||||
{
|
||||
id: 'art17_3_b',
|
||||
article: '17(3)(b)',
|
||||
label: 'Rechtliche Verpflichtung',
|
||||
description: 'Erfuellung einer rechtlichen Verpflichtung (z.B. Aufbewahrungspflichten)'
|
||||
},
|
||||
{
|
||||
id: 'art17_3_c',
|
||||
article: '17(3)(c)',
|
||||
label: 'Oeffentliches Interesse',
|
||||
description: 'Gruende des oeffentlichen Interesses im Bereich Gesundheit'
|
||||
},
|
||||
{
|
||||
id: 'art17_3_d',
|
||||
article: '17(3)(d)',
|
||||
label: 'Archivzwecke',
|
||||
description: 'Archivzwecke, wissenschaftliche/historische Forschung, Statistik'
|
||||
},
|
||||
{
|
||||
id: 'art17_3_e',
|
||||
article: '17(3)(e)',
|
||||
label: 'Rechtsansprueche',
|
||||
description: 'Geltendmachung, Ausuebung oder Verteidigung von Rechtsanspruechen'
|
||||
}
|
||||
]
|
||||
|
||||
// Data Export for Art. 15, 20
|
||||
export interface DSRDataExport {
|
||||
format: 'json' | 'csv' | 'xml' | 'pdf'
|
||||
generatedAt?: string
|
||||
generatedBy?: string
|
||||
fileUrl?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
includesThirdPartyData: boolean
|
||||
anonymizedFields?: string[]
|
||||
transferMethod?: 'download' | 'email' | 'third_party' // For Art. 20 transfer
|
||||
transferRecipient?: string // For Art. 20 transfer to another controller
|
||||
}
|
||||
|
||||
// Rectification Details for Art. 16
|
||||
export interface DSRRectificationDetails {
|
||||
fieldsToCorrect: {
|
||||
field: string
|
||||
currentValue: string
|
||||
requestedValue: string
|
||||
corrected: boolean
|
||||
correctedAt?: string
|
||||
correctedBy?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// Objection Details for Art. 21
|
||||
export interface DSRObjectionDetails {
|
||||
processingPurpose: string
|
||||
legalBasis: string
|
||||
objectionGrounds: string
|
||||
decision: 'accepted' | 'rejected' | 'pending'
|
||||
decisionReason?: string
|
||||
decisionBy?: string
|
||||
decisionAt?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMMUNICATION
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRCommunication {
|
||||
id: string
|
||||
dsrId: string
|
||||
type: CommunicationType
|
||||
channel: CommunicationChannel
|
||||
subject?: string
|
||||
content: string
|
||||
templateUsed?: string // Reference to email template
|
||||
attachments?: {
|
||||
name: string
|
||||
url: string
|
||||
size: number
|
||||
type: string
|
||||
}[]
|
||||
sentAt?: string
|
||||
sentBy?: string
|
||||
receivedAt?: string
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUDIT LOG
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRAuditEntry {
|
||||
id: string
|
||||
dsrId: string
|
||||
action: string // e.g., "status_changed", "identity_verified", "assigned"
|
||||
previousValue?: string
|
||||
newValue?: string
|
||||
performedBy: string
|
||||
performedAt: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMAIL TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSREmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
body: string
|
||||
type: DSRType | 'general'
|
||||
stage: DSRStatus | 'identity_request' | 'deadline_extension' | 'completion'
|
||||
language: 'de' | 'en'
|
||||
variables: string[] // e.g., ["requesterName", "referenceNumber", "deadline"]
|
||||
}
|
||||
|
||||
export const DSR_EMAIL_TEMPLATES: DSREmailTemplate[] = [
|
||||
{
|
||||
id: 'intake_confirmation',
|
||||
name: 'Eingangsbestaetigung',
|
||||
subject: 'Bestaetigung Ihrer Anfrage - {{referenceNumber}}',
|
||||
body: `Sehr geehrte(r) {{requesterName}},
|
||||
|
||||
wir bestaetigen den Eingang Ihrer Anfrage vom {{receivedDate}}.
|
||||
|
||||
Referenznummer: {{referenceNumber}}
|
||||
Art der Anfrage: {{requestType}}
|
||||
|
||||
Wir werden Ihre Anfrage innerhalb der gesetzlichen Frist von {{deadline}} bearbeiten.
|
||||
|
||||
Mit freundlichen Gruessen
|
||||
{{senderName}}
|
||||
Datenschutzbeauftragter`,
|
||||
type: 'general',
|
||||
stage: 'intake',
|
||||
language: 'de',
|
||||
variables: ['requesterName', 'receivedDate', 'referenceNumber', 'requestType', 'deadline', 'senderName']
|
||||
},
|
||||
{
|
||||
id: 'identity_request',
|
||||
name: 'Identitaetsanfrage',
|
||||
subject: 'Identitaetspruefung erforderlich - {{referenceNumber}}',
|
||||
body: `Sehr geehrte(r) {{requesterName}},
|
||||
|
||||
um Ihre Anfrage bearbeiten zu koennen, benoetigen wir einen Nachweis Ihrer Identitaet.
|
||||
|
||||
Bitte senden Sie uns eines der folgenden Dokumente:
|
||||
- Kopie Ihres Personalausweises (Vorder- und Rueckseite)
|
||||
- Kopie Ihres Reisepasses
|
||||
|
||||
Ihre Daten werden ausschliesslich zur Identitaetspruefung verwendet und anschliessend geloescht.
|
||||
|
||||
Mit freundlichen Gruessen
|
||||
{{senderName}}
|
||||
Datenschutzbeauftragter`,
|
||||
type: 'general',
|
||||
stage: 'identity_request',
|
||||
language: 'de',
|
||||
variables: ['requesterName', 'referenceNumber', 'senderName']
|
||||
}
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// API TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRFilters {
|
||||
status?: DSRStatus | DSRStatus[]
|
||||
type?: DSRType | DSRType[]
|
||||
priority?: DSRPriority
|
||||
assignedTo?: string
|
||||
overdue?: boolean
|
||||
search?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
|
||||
export interface DSRListResponse {
|
||||
requests: DSRRequest[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DSRCreateRequest {
|
||||
type: DSRType
|
||||
requester: DSRRequester
|
||||
source: DSRSource
|
||||
sourceDetails?: string
|
||||
requestText?: string
|
||||
priority?: DSRPriority
|
||||
}
|
||||
|
||||
export interface DSRUpdateRequest {
|
||||
status?: DSRStatus
|
||||
priority?: DSRPriority
|
||||
notes?: string
|
||||
internalNotes?: string
|
||||
assignment?: DSRAssignment
|
||||
}
|
||||
|
||||
export interface DSRVerifyIdentityRequest {
|
||||
method: IdentityVerificationMethod
|
||||
notes?: string
|
||||
documentRef?: string
|
||||
}
|
||||
|
||||
export interface DSRCompleteRequest {
|
||||
completionNotes?: string
|
||||
dataExport?: DSRDataExport
|
||||
}
|
||||
|
||||
export interface DSRRejectRequest {
|
||||
reason: string
|
||||
legalBasis?: string // e.g., Art. 17(3) exception
|
||||
}
|
||||
|
||||
export interface DSRExtendDeadlineRequest {
|
||||
extensionMonths: 1 | 2
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface DSRSendCommunicationRequest {
|
||||
type: CommunicationType
|
||||
channel: CommunicationChannel
|
||||
subject?: string
|
||||
content: string
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
export interface DSRStatistics {
|
||||
total: number
|
||||
byStatus: Record<DSRStatus, number>
|
||||
byType: Record<DSRType, number>
|
||||
overdue: number
|
||||
dueThisWeek: number
|
||||
averageProcessingDays: number
|
||||
completedThisMonth: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function getDaysRemaining(deadline: string): number {
|
||||
const deadlineDate = new Date(deadline)
|
||||
const now = new Date()
|
||||
const diff = deadlineDate.getTime() - now.getTime()
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function isOverdue(request: DSRRequest): boolean {
|
||||
if (request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
return getDaysRemaining(request.deadline.currentDeadline) < 0
|
||||
}
|
||||
|
||||
export function isUrgent(request: DSRRequest, thresholdDays: number = 7): boolean {
|
||||
if (request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
|
||||
return daysRemaining >= 0 && daysRemaining <= thresholdDays
|
||||
}
|
||||
|
||||
export function generateReferenceNumber(year: number, sequence: number): string {
|
||||
return `DSR-${year}-${String(sequence).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
export function getTypeInfo(type: DSRType): DSRTypeInfo {
|
||||
return DSR_TYPE_INFO[type]
|
||||
}
|
||||
|
||||
export function getStatusInfo(status: DSRStatus) {
|
||||
return DSR_STATUS_INFO[status]
|
||||
}
|
||||
export type {
|
||||
DSRType,
|
||||
DSRStatus,
|
||||
DSRPriority,
|
||||
DSRSource,
|
||||
IdentityVerificationMethod,
|
||||
CommunicationType,
|
||||
CommunicationChannel,
|
||||
DSRTypeInfo,
|
||||
DSRRequester,
|
||||
DSRIdentityVerification,
|
||||
DSRAssignment,
|
||||
DSRDeadline,
|
||||
DSRRequest,
|
||||
DSRErasureChecklistItem,
|
||||
DSRErasureChecklist,
|
||||
DSRDataExport,
|
||||
DSRRectificationDetails,
|
||||
DSRObjectionDetails,
|
||||
} from './types-core'
|
||||
|
||||
export {
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
ERASURE_EXCEPTIONS,
|
||||
} from './types-core'
|
||||
|
||||
export type {
|
||||
DSRCommunication,
|
||||
DSRAuditEntry,
|
||||
DSREmailTemplate,
|
||||
DSRFilters,
|
||||
DSRListResponse,
|
||||
DSRCreateRequest,
|
||||
DSRUpdateRequest,
|
||||
DSRVerifyIdentityRequest,
|
||||
DSRCompleteRequest,
|
||||
DSRRejectRequest,
|
||||
DSRExtendDeadlineRequest,
|
||||
DSRSendCommunicationRequest,
|
||||
DSRStatistics,
|
||||
} from './types-api'
|
||||
|
||||
export {
|
||||
DSR_EMAIL_TEMPLATES,
|
||||
getDaysRemaining,
|
||||
isOverdue,
|
||||
isUrgent,
|
||||
generateReferenceNumber,
|
||||
getTypeInfo,
|
||||
getStatusInfo,
|
||||
} from './types-api'
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Cookie Banner — Configuration & Category Generation
|
||||
*
|
||||
* Default texts, styling, and category generation from data points.
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
CookieBannerCategory,
|
||||
CookieBannerConfig,
|
||||
CookieBannerStyling,
|
||||
CookieBannerTexts,
|
||||
CookieInfo,
|
||||
LocalizedText,
|
||||
SupportedLanguage,
|
||||
} from '../types'
|
||||
import { DEFAULT_COOKIE_CATEGORIES } from '../catalog/loader'
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function t(text: LocalizedText, language: SupportedLanguage): string {
|
||||
return text[language]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULT CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
export const DEFAULT_COOKIE_BANNER_TEXTS: CookieBannerTexts = {
|
||||
title: { de: 'Cookie-Einstellungen', en: 'Cookie Settings' },
|
||||
description: {
|
||||
de: 'Wir verwenden Cookies, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen. Einige Cookies sind technisch notwendig, waehrend andere uns helfen, Ihre Nutzererfahrung zu verbessern.',
|
||||
en: 'We use cookies to provide you with the best possible experience on our website. Some cookies are technically necessary, while others help us improve your user experience.',
|
||||
},
|
||||
acceptAll: { de: 'Alle akzeptieren', en: 'Accept All' },
|
||||
rejectAll: { de: 'Nur notwendige', en: 'Essential Only' },
|
||||
customize: { de: 'Einstellungen', en: 'Customize' },
|
||||
save: { de: 'Auswahl speichern', en: 'Save Selection' },
|
||||
privacyPolicyLink: {
|
||||
de: 'Mehr in unserer Datenschutzerklaerung',
|
||||
en: 'More in our Privacy Policy',
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_COOKIE_BANNER_STYLING: CookieBannerStyling = {
|
||||
position: 'BOTTOM',
|
||||
theme: 'LIGHT',
|
||||
primaryColor: '#6366f1',
|
||||
secondaryColor: '#f1f5f9',
|
||||
textColor: '#1e293b',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
maxWidth: 480,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function getExpiryFromRetention(retention: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'24_HOURS': '24 Stunden / 24 hours',
|
||||
'30_DAYS': '30 Tage / 30 days',
|
||||
'90_DAYS': '90 Tage / 90 days',
|
||||
'12_MONTHS': '1 Jahr / 1 year',
|
||||
'24_MONTHS': '2 Jahre / 2 years',
|
||||
'36_MONTHS': '3 Jahre / 3 years',
|
||||
'UNTIL_REVOCATION': 'Bis Widerruf / Until revocation',
|
||||
'UNTIL_PURPOSE_FULFILLED': 'Session',
|
||||
'UNTIL_ACCOUNT_DELETION': 'Bis Kontoschliessung / Until account deletion',
|
||||
}
|
||||
return mapping[retention] || 'Session'
|
||||
}
|
||||
|
||||
export function generateCookieCategories(
|
||||
dataPoints: DataPoint[]
|
||||
): CookieBannerCategory[] {
|
||||
const cookieDataPoints = dataPoints.filter((dp) => dp.cookieCategory !== null)
|
||||
|
||||
return DEFAULT_COOKIE_CATEGORIES.map((defaultCat) => {
|
||||
const categoryDataPoints = cookieDataPoints.filter(
|
||||
(dp) => dp.cookieCategory === defaultCat.id
|
||||
)
|
||||
|
||||
const cookies: CookieInfo[] = categoryDataPoints.map((dp) => ({
|
||||
name: dp.code,
|
||||
provider: 'First Party',
|
||||
purpose: dp.purpose,
|
||||
expiry: getExpiryFromRetention(dp.retentionPeriod),
|
||||
type: 'FIRST_PARTY',
|
||||
}))
|
||||
|
||||
return {
|
||||
...defaultCat,
|
||||
dataPointIds: categoryDataPoints.map((dp) => dp.id),
|
||||
cookies,
|
||||
}
|
||||
}).filter((cat) => cat.dataPointIds.length > 0 || cat.isRequired)
|
||||
}
|
||||
|
||||
export function generateCookieBannerConfig(
|
||||
tenantId: string,
|
||||
dataPoints: DataPoint[],
|
||||
customTexts?: Partial<CookieBannerTexts>,
|
||||
customStyling?: Partial<CookieBannerStyling>
|
||||
): CookieBannerConfig {
|
||||
const categories = generateCookieCategories(dataPoints)
|
||||
|
||||
return {
|
||||
id: `cookie-banner-${tenantId}`,
|
||||
tenantId,
|
||||
categories,
|
||||
styling: { ...DEFAULT_COOKIE_BANNER_STYLING, ...customStyling },
|
||||
texts: { ...DEFAULT_COOKIE_BANNER_TEXTS, ...customTexts },
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Cookie Banner — Embed Code Generation (CSS, HTML, JS)
|
||||
*
|
||||
* Generates the embeddable cookie banner code from configuration.
|
||||
*/
|
||||
|
||||
import {
|
||||
CookieBannerConfig,
|
||||
CookieBannerStyling,
|
||||
CookieBannerEmbedCode,
|
||||
LocalizedText,
|
||||
SupportedLanguage,
|
||||
} from '../types'
|
||||
|
||||
// =============================================================================
|
||||
// MAIN EXPORT
|
||||
// =============================================================================
|
||||
|
||||
export function generateEmbedCode(
|
||||
config: CookieBannerConfig,
|
||||
privacyPolicyUrl: string = '/datenschutz'
|
||||
): CookieBannerEmbedCode {
|
||||
const css = generateCSS(config.styling)
|
||||
const html = generateHTML(config, privacyPolicyUrl)
|
||||
const js = generateJS(config)
|
||||
|
||||
const scriptTag = `<script src="/cookie-banner.js" data-tenant="${config.tenantId}"></script>`
|
||||
|
||||
return { html, css, js, scriptTag }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSS GENERATION
|
||||
// =============================================================================
|
||||
|
||||
function generateCSS(styling: CookieBannerStyling): string {
|
||||
const positionStyles: Record<string, string> = {
|
||||
BOTTOM: 'bottom: 0; left: 0; right: 0;',
|
||||
TOP: 'top: 0; left: 0; right: 0;',
|
||||
CENTER: 'top: 50%; left: 50%; transform: translate(-50%, -50%);',
|
||||
}
|
||||
|
||||
const isDark = styling.theme === 'DARK'
|
||||
const bgColor = isDark ? '#1e293b' : styling.backgroundColor || '#ffffff'
|
||||
const textColor = isDark ? '#f1f5f9' : styling.textColor || '#1e293b'
|
||||
const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
|
||||
|
||||
return `
|
||||
/* Cookie Banner Styles */
|
||||
.cookie-banner-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9998;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cookie-banner-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
${positionStyles[styling.position]}
|
||||
z-index: 9999;
|
||||
background: ${bgColor};
|
||||
color: ${textColor};
|
||||
border-radius: ${styling.borderRadius || 12}px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
max-width: ${styling.maxWidth}px;
|
||||
margin: ${styling.position === 'CENTER' ? '0' : '16px'};
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cookie-banner.active {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cookie-banner-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cookie-banner-description {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cookie-banner-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cookie-banner-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 12px 20px;
|
||||
border-radius: ${(styling.borderRadius || 12) / 2}px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cookie-banner-btn-primary {
|
||||
background: ${styling.primaryColor};
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cookie-banner-btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.cookie-banner-btn-secondary {
|
||||
background: ${styling.secondaryColor || borderColor};
|
||||
color: ${textColor};
|
||||
}
|
||||
|
||||
.cookie-banner-btn-secondary:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.cookie-banner-link {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: ${styling.primaryColor};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cookie-banner-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Category Details */
|
||||
.cookie-banner-details {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid ${borderColor};
|
||||
padding-top: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cookie-banner-details.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cookie-banner-category {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid ${borderColor};
|
||||
}
|
||||
|
||||
.cookie-banner-category:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cookie-banner-category-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cookie-banner-category-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cookie-banner-category-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
background: ${borderColor};
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle.active {
|
||||
background: ${styling.primaryColor};
|
||||
}
|
||||
|
||||
.cookie-banner-toggle.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle.active::after {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cookie-banner {
|
||||
margin: 0;
|
||||
border-radius: ${styling.position === 'CENTER' ? (styling.borderRadius || 12) : 0}px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cookie-banner-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cookie-banner-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML GENERATION
|
||||
// =============================================================================
|
||||
|
||||
function generateHTML(config: CookieBannerConfig, privacyPolicyUrl: string): string {
|
||||
const categoriesHTML = config.categories
|
||||
.map((cat) => {
|
||||
const isRequired = cat.isRequired
|
||||
return `
|
||||
<div class="cookie-banner-category" data-category="${cat.id}">
|
||||
<div class="cookie-banner-category-info">
|
||||
<div class="cookie-banner-category-name">${cat.name.de}</div>
|
||||
<div class="cookie-banner-category-desc">${cat.description.de}</div>
|
||||
</div>
|
||||
<div class="cookie-banner-toggle ${cat.defaultEnabled ? 'active' : ''} ${isRequired ? 'disabled' : ''}"
|
||||
data-category="${cat.id}"
|
||||
data-required="${isRequired}"></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="cookie-banner-overlay" id="cookieBannerOverlay"></div>
|
||||
<div class="cookie-banner" id="cookieBanner" role="dialog" aria-labelledby="cookieBannerTitle" aria-modal="true">
|
||||
<div class="cookie-banner-title" id="cookieBannerTitle">${config.texts.title.de}</div>
|
||||
<div class="cookie-banner-description">${config.texts.description.de}</div>
|
||||
|
||||
<div class="cookie-banner-buttons">
|
||||
<button class="cookie-banner-btn cookie-banner-btn-secondary" id="cookieBannerReject">
|
||||
${config.texts.rejectAll.de}
|
||||
</button>
|
||||
<button class="cookie-banner-btn cookie-banner-btn-secondary" id="cookieBannerCustomize">
|
||||
${config.texts.customize.de}
|
||||
</button>
|
||||
<button class="cookie-banner-btn cookie-banner-btn-primary" id="cookieBannerAccept">
|
||||
${config.texts.acceptAll.de}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cookie-banner-details" id="cookieBannerDetails">
|
||||
${categoriesHTML}
|
||||
<div class="cookie-banner-buttons" style="margin-top: 16px;">
|
||||
<button class="cookie-banner-btn cookie-banner-btn-primary" id="cookieBannerSave">
|
||||
${config.texts.save.de}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="${privacyPolicyUrl}" class="cookie-banner-link" target="_blank">
|
||||
${config.texts.privacyPolicyLink.de}
|
||||
</a>
|
||||
</div>
|
||||
`.trim()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JS GENERATION
|
||||
// =============================================================================
|
||||
|
||||
function generateJS(config: CookieBannerConfig): string {
|
||||
const categoryIds = config.categories.map((c) => c.id)
|
||||
const requiredCategories = config.categories.filter((c) => c.isRequired).map((c) => c.id)
|
||||
|
||||
return `
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const COOKIE_NAME = 'cookie_consent';
|
||||
const COOKIE_EXPIRY_DAYS = 365;
|
||||
const CATEGORIES = ${JSON.stringify(categoryIds)};
|
||||
const REQUIRED_CATEGORIES = ${JSON.stringify(requiredCategories)};
|
||||
|
||||
function getConsent() {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith(COOKIE_NAME + '='));
|
||||
if (!cookie) return null;
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveConsent(consent) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000));
|
||||
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(consent)) +
|
||||
';expires=' + date.toUTCString() +
|
||||
';path=/;SameSite=Lax';
|
||||
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: consent }));
|
||||
}
|
||||
|
||||
function hasConsent(category) {
|
||||
const consent = getConsent();
|
||||
if (!consent) return REQUIRED_CATEGORIES.includes(category);
|
||||
return consent[category] === true;
|
||||
}
|
||||
|
||||
function initBanner() {
|
||||
const banner = document.getElementById('cookieBanner');
|
||||
const overlay = document.getElementById('cookieBannerOverlay');
|
||||
const details = document.getElementById('cookieBannerDetails');
|
||||
|
||||
if (!banner) return;
|
||||
|
||||
const consent = getConsent();
|
||||
if (consent) return;
|
||||
|
||||
setTimeout(() => {
|
||||
banner.classList.add('active');
|
||||
overlay.classList.add('active');
|
||||
}, 500);
|
||||
|
||||
document.getElementById('cookieBannerAccept')?.addEventListener('click', () => {
|
||||
const consent = {};
|
||||
CATEGORIES.forEach(cat => consent[cat] = true);
|
||||
saveConsent(consent);
|
||||
closeBanner();
|
||||
});
|
||||
|
||||
document.getElementById('cookieBannerReject')?.addEventListener('click', () => {
|
||||
const consent = {};
|
||||
CATEGORIES.forEach(cat => consent[cat] = REQUIRED_CATEGORIES.includes(cat));
|
||||
saveConsent(consent);
|
||||
closeBanner();
|
||||
});
|
||||
|
||||
document.getElementById('cookieBannerCustomize')?.addEventListener('click', () => {
|
||||
details.classList.toggle('active');
|
||||
});
|
||||
|
||||
document.getElementById('cookieBannerSave')?.addEventListener('click', () => {
|
||||
const consent = {};
|
||||
CATEGORIES.forEach(cat => {
|
||||
const toggle = document.querySelector('.cookie-banner-toggle[data-category="' + cat + '"]');
|
||||
consent[cat] = toggle?.classList.contains('active') || REQUIRED_CATEGORIES.includes(cat);
|
||||
});
|
||||
saveConsent(consent);
|
||||
closeBanner();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.cookie-banner-toggle').forEach(toggle => {
|
||||
if (toggle.dataset.required === 'true') return;
|
||||
toggle.addEventListener('click', () => {
|
||||
toggle.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
overlay?.addEventListener('click', () => {
|
||||
// Don't close - user must make a choice
|
||||
});
|
||||
}
|
||||
|
||||
function closeBanner() {
|
||||
const banner = document.getElementById('cookieBanner');
|
||||
const overlay = document.getElementById('cookieBannerOverlay');
|
||||
banner?.classList.remove('active');
|
||||
overlay?.classList.remove('active');
|
||||
}
|
||||
|
||||
window.CookieConsent = {
|
||||
getConsent,
|
||||
saveConsent,
|
||||
hasConsent,
|
||||
show: () => {
|
||||
document.getElementById('cookieBanner')?.classList.add('active');
|
||||
document.getElementById('cookieBannerOverlay')?.classList.add('active');
|
||||
},
|
||||
hide: closeBanner
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBanner);
|
||||
} else {
|
||||
initBanner();
|
||||
}
|
||||
})();
|
||||
`.trim()
|
||||
}
|
||||
@@ -1,595 +1,18 @@
|
||||
/**
|
||||
* Cookie Banner Generator
|
||||
* Cookie Banner Generator — barrel re-export
|
||||
*
|
||||
* Generiert Cookie-Banner Konfigurationen und Embed-Code aus dem Datenpunktkatalog.
|
||||
* Die Cookie-Kategorien werden automatisch aus den Datenpunkten abgeleitet.
|
||||
* Split into:
|
||||
* - cookie-banner-config.ts (defaults, category generation, config builder)
|
||||
* - cookie-banner-embed.ts (CSS, HTML, JS embed code generation)
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
CookieCategory,
|
||||
CookieBannerCategory,
|
||||
CookieBannerConfig,
|
||||
CookieBannerStyling,
|
||||
CookieBannerTexts,
|
||||
CookieBannerEmbedCode,
|
||||
CookieInfo,
|
||||
LocalizedText,
|
||||
SupportedLanguage,
|
||||
} from '../types'
|
||||
import { DEFAULT_COOKIE_CATEGORIES } from '../catalog/loader'
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Holt den lokalisierten Text
|
||||
*/
|
||||
function t(text: LocalizedText, language: SupportedLanguage): string {
|
||||
return text[language]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COOKIE BANNER CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Standard Cookie Banner Texte
|
||||
*/
|
||||
export const DEFAULT_COOKIE_BANNER_TEXTS: CookieBannerTexts = {
|
||||
title: {
|
||||
de: 'Cookie-Einstellungen',
|
||||
en: 'Cookie Settings',
|
||||
},
|
||||
description: {
|
||||
de: 'Wir verwenden Cookies, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen. Einige Cookies sind technisch notwendig, waehrend andere uns helfen, Ihre Nutzererfahrung zu verbessern.',
|
||||
en: 'We use cookies to provide you with the best possible experience on our website. Some cookies are technically necessary, while others help us improve your user experience.',
|
||||
},
|
||||
acceptAll: {
|
||||
de: 'Alle akzeptieren',
|
||||
en: 'Accept All',
|
||||
},
|
||||
rejectAll: {
|
||||
de: 'Nur notwendige',
|
||||
en: 'Essential Only',
|
||||
},
|
||||
customize: {
|
||||
de: 'Einstellungen',
|
||||
en: 'Customize',
|
||||
},
|
||||
save: {
|
||||
de: 'Auswahl speichern',
|
||||
en: 'Save Selection',
|
||||
},
|
||||
privacyPolicyLink: {
|
||||
de: 'Mehr in unserer Datenschutzerklaerung',
|
||||
en: 'More in our Privacy Policy',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Styling fuer Cookie Banner
|
||||
*/
|
||||
export const DEFAULT_COOKIE_BANNER_STYLING: CookieBannerStyling = {
|
||||
position: 'BOTTOM',
|
||||
theme: 'LIGHT',
|
||||
primaryColor: '#6366f1', // Indigo
|
||||
secondaryColor: '#f1f5f9', // Slate-100
|
||||
textColor: '#1e293b', // Slate-800
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
maxWidth: 480,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generiert Cookie-Banner Kategorien aus Datenpunkten
|
||||
*/
|
||||
export function generateCookieCategories(
|
||||
dataPoints: DataPoint[]
|
||||
): CookieBannerCategory[] {
|
||||
// Filtere nur Datenpunkte mit Cookie-Kategorie
|
||||
const cookieDataPoints = dataPoints.filter((dp) => dp.cookieCategory !== null)
|
||||
|
||||
// Erstelle die Kategorien basierend auf den Defaults
|
||||
return DEFAULT_COOKIE_CATEGORIES.map((defaultCat) => {
|
||||
// Filtere die Datenpunkte fuer diese Kategorie
|
||||
const categoryDataPoints = cookieDataPoints.filter(
|
||||
(dp) => dp.cookieCategory === defaultCat.id
|
||||
)
|
||||
|
||||
// Erstelle Cookie-Infos aus den Datenpunkten
|
||||
const cookies: CookieInfo[] = categoryDataPoints.map((dp) => ({
|
||||
name: dp.code,
|
||||
provider: 'First Party',
|
||||
purpose: dp.purpose,
|
||||
expiry: getExpiryFromRetention(dp.retentionPeriod),
|
||||
type: 'FIRST_PARTY',
|
||||
}))
|
||||
|
||||
return {
|
||||
...defaultCat,
|
||||
dataPointIds: categoryDataPoints.map((dp) => dp.id),
|
||||
cookies,
|
||||
}
|
||||
}).filter((cat) => cat.dataPointIds.length > 0 || cat.isRequired)
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Retention Period zu Cookie-Expiry String
|
||||
*/
|
||||
function getExpiryFromRetention(retention: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'24_HOURS': '24 Stunden / 24 hours',
|
||||
'30_DAYS': '30 Tage / 30 days',
|
||||
'90_DAYS': '90 Tage / 90 days',
|
||||
'12_MONTHS': '1 Jahr / 1 year',
|
||||
'24_MONTHS': '2 Jahre / 2 years',
|
||||
'36_MONTHS': '3 Jahre / 3 years',
|
||||
'UNTIL_REVOCATION': 'Bis Widerruf / Until revocation',
|
||||
'UNTIL_PURPOSE_FULFILLED': 'Session',
|
||||
'UNTIL_ACCOUNT_DELETION': 'Bis Kontoschliessung / Until account deletion',
|
||||
}
|
||||
return mapping[retention] || 'Session'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert die vollstaendige Cookie Banner Konfiguration
|
||||
*/
|
||||
export function generateCookieBannerConfig(
|
||||
tenantId: string,
|
||||
dataPoints: DataPoint[],
|
||||
customTexts?: Partial<CookieBannerTexts>,
|
||||
customStyling?: Partial<CookieBannerStyling>
|
||||
): CookieBannerConfig {
|
||||
const categories = generateCookieCategories(dataPoints)
|
||||
|
||||
return {
|
||||
id: `cookie-banner-${tenantId}`,
|
||||
tenantId,
|
||||
categories,
|
||||
styling: {
|
||||
...DEFAULT_COOKIE_BANNER_STYLING,
|
||||
...customStyling,
|
||||
},
|
||||
texts: {
|
||||
...DEFAULT_COOKIE_BANNER_TEXTS,
|
||||
...customTexts,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMBED CODE GENERATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generiert den Embed-Code fuer den Cookie Banner
|
||||
*/
|
||||
export function generateEmbedCode(
|
||||
config: CookieBannerConfig,
|
||||
privacyPolicyUrl: string = '/datenschutz'
|
||||
): CookieBannerEmbedCode {
|
||||
const css = generateCSS(config.styling)
|
||||
const html = generateHTML(config, privacyPolicyUrl)
|
||||
const js = generateJS(config)
|
||||
|
||||
const scriptTag = `<script src="/cookie-banner.js" data-tenant="${config.tenantId}"></script>`
|
||||
|
||||
return {
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
scriptTag,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert das CSS fuer den Cookie Banner
|
||||
*/
|
||||
function generateCSS(styling: CookieBannerStyling): string {
|
||||
const positionStyles: Record<string, string> = {
|
||||
BOTTOM: 'bottom: 0; left: 0; right: 0;',
|
||||
TOP: 'top: 0; left: 0; right: 0;',
|
||||
CENTER: 'top: 50%; left: 50%; transform: translate(-50%, -50%);',
|
||||
}
|
||||
|
||||
const isDark = styling.theme === 'DARK'
|
||||
const bgColor = isDark ? '#1e293b' : styling.backgroundColor || '#ffffff'
|
||||
const textColor = isDark ? '#f1f5f9' : styling.textColor || '#1e293b'
|
||||
const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
|
||||
|
||||
return `
|
||||
/* Cookie Banner Styles */
|
||||
.cookie-banner-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9998;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cookie-banner-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
${positionStyles[styling.position]}
|
||||
z-index: 9999;
|
||||
background: ${bgColor};
|
||||
color: ${textColor};
|
||||
border-radius: ${styling.borderRadius || 12}px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
max-width: ${styling.maxWidth}px;
|
||||
margin: ${styling.position === 'CENTER' ? '0' : '16px'};
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cookie-banner.active {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cookie-banner-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cookie-banner-description {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cookie-banner-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cookie-banner-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 12px 20px;
|
||||
border-radius: ${(styling.borderRadius || 12) / 2}px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cookie-banner-btn-primary {
|
||||
background: ${styling.primaryColor};
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cookie-banner-btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.cookie-banner-btn-secondary {
|
||||
background: ${styling.secondaryColor || borderColor};
|
||||
color: ${textColor};
|
||||
}
|
||||
|
||||
.cookie-banner-btn-secondary:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.cookie-banner-link {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: ${styling.primaryColor};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cookie-banner-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Category Details */
|
||||
.cookie-banner-details {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid ${borderColor};
|
||||
padding-top: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cookie-banner-details.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cookie-banner-category {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid ${borderColor};
|
||||
}
|
||||
|
||||
.cookie-banner-category:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cookie-banner-category-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cookie-banner-category-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cookie-banner-category-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
background: ${borderColor};
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle.active {
|
||||
background: ${styling.primaryColor};
|
||||
}
|
||||
|
||||
.cookie-banner-toggle.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cookie-banner-toggle.active::after {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cookie-banner {
|
||||
margin: 0;
|
||||
border-radius: ${styling.position === 'CENTER' ? (styling.borderRadius || 12) : 0}px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cookie-banner-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cookie-banner-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert das HTML fuer den Cookie Banner
|
||||
*/
|
||||
function generateHTML(config: CookieBannerConfig, privacyPolicyUrl: string): string {
|
||||
const categoriesHTML = config.categories
|
||||
.map((cat) => {
|
||||
const isRequired = cat.isRequired
|
||||
return `
|
||||
<div class="cookie-banner-category" data-category="${cat.id}">
|
||||
<div class="cookie-banner-category-info">
|
||||
<div class="cookie-banner-category-name">${cat.name.de}</div>
|
||||
<div class="cookie-banner-category-desc">${cat.description.de}</div>
|
||||
</div>
|
||||
<div class="cookie-banner-toggle ${cat.defaultEnabled ? 'active' : ''} ${isRequired ? 'disabled' : ''}"
|
||||
data-category="${cat.id}"
|
||||
data-required="${isRequired}"></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="cookie-banner-overlay" id="cookieBannerOverlay"></div>
|
||||
<div class="cookie-banner" id="cookieBanner" role="dialog" aria-labelledby="cookieBannerTitle" aria-modal="true">
|
||||
<div class="cookie-banner-title" id="cookieBannerTitle">${config.texts.title.de}</div>
|
||||
<div class="cookie-banner-description">${config.texts.description.de}</div>
|
||||
|
||||
<div class="cookie-banner-buttons">
|
||||
<button class="cookie-banner-btn cookie-banner-btn-secondary" id="cookieBannerReject">
|
||||
${config.texts.rejectAll.de}
|
||||
</button>
|
||||
<button class="cookie-banner-btn cookie-banner-btn-secondary" id="cookieBannerCustomize">
|
||||
${config.texts.customize.de}
|
||||
</button>
|
||||
<button class="cookie-banner-btn cookie-banner-btn-primary" id="cookieBannerAccept">
|
||||
${config.texts.acceptAll.de}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cookie-banner-details" id="cookieBannerDetails">
|
||||
${categoriesHTML}
|
||||
<div class="cookie-banner-buttons" style="margin-top: 16px;">
|
||||
<button class="cookie-banner-btn cookie-banner-btn-primary" id="cookieBannerSave">
|
||||
${config.texts.save.de}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="${privacyPolicyUrl}" class="cookie-banner-link" target="_blank">
|
||||
${config.texts.privacyPolicyLink.de}
|
||||
</a>
|
||||
</div>
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert das JavaScript fuer den Cookie Banner
|
||||
*/
|
||||
function generateJS(config: CookieBannerConfig): string {
|
||||
const categoryIds = config.categories.map((c) => c.id)
|
||||
const requiredCategories = config.categories.filter((c) => c.isRequired).map((c) => c.id)
|
||||
|
||||
return `
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const COOKIE_NAME = 'cookie_consent';
|
||||
const COOKIE_EXPIRY_DAYS = 365;
|
||||
const CATEGORIES = ${JSON.stringify(categoryIds)};
|
||||
const REQUIRED_CATEGORIES = ${JSON.stringify(requiredCategories)};
|
||||
|
||||
// Get consent from cookie
|
||||
function getConsent() {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith(COOKIE_NAME + '='));
|
||||
if (!cookie) return null;
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Save consent to cookie
|
||||
function saveConsent(consent) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000));
|
||||
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(consent)) +
|
||||
';expires=' + date.toUTCString() +
|
||||
';path=/;SameSite=Lax';
|
||||
|
||||
// Dispatch event
|
||||
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: consent }));
|
||||
}
|
||||
|
||||
// Check if category is consented
|
||||
function hasConsent(category) {
|
||||
const consent = getConsent();
|
||||
if (!consent) return REQUIRED_CATEGORIES.includes(category);
|
||||
return consent[category] === true;
|
||||
}
|
||||
|
||||
// Initialize banner
|
||||
function initBanner() {
|
||||
const banner = document.getElementById('cookieBanner');
|
||||
const overlay = document.getElementById('cookieBannerOverlay');
|
||||
const details = document.getElementById('cookieBannerDetails');
|
||||
|
||||
if (!banner) return;
|
||||
|
||||
const consent = getConsent();
|
||||
if (consent) {
|
||||
// User has already consented
|
||||
return;
|
||||
}
|
||||
|
||||
// Show banner
|
||||
setTimeout(() => {
|
||||
banner.classList.add('active');
|
||||
overlay.classList.add('active');
|
||||
}, 500);
|
||||
|
||||
// Accept all
|
||||
document.getElementById('cookieBannerAccept')?.addEventListener('click', () => {
|
||||
const consent = {};
|
||||
CATEGORIES.forEach(cat => consent[cat] = true);
|
||||
saveConsent(consent);
|
||||
closeBanner();
|
||||
});
|
||||
|
||||
// Reject all (only essential)
|
||||
document.getElementById('cookieBannerReject')?.addEventListener('click', () => {
|
||||
const consent = {};
|
||||
CATEGORIES.forEach(cat => consent[cat] = REQUIRED_CATEGORIES.includes(cat));
|
||||
saveConsent(consent);
|
||||
closeBanner();
|
||||
});
|
||||
|
||||
// Customize
|
||||
document.getElementById('cookieBannerCustomize')?.addEventListener('click', () => {
|
||||
details.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Save selection
|
||||
document.getElementById('cookieBannerSave')?.addEventListener('click', () => {
|
||||
const consent = {};
|
||||
CATEGORIES.forEach(cat => {
|
||||
const toggle = document.querySelector('.cookie-banner-toggle[data-category="' + cat + '"]');
|
||||
consent[cat] = toggle?.classList.contains('active') || REQUIRED_CATEGORIES.includes(cat);
|
||||
});
|
||||
saveConsent(consent);
|
||||
closeBanner();
|
||||
});
|
||||
|
||||
// Toggle handlers
|
||||
document.querySelectorAll('.cookie-banner-toggle').forEach(toggle => {
|
||||
if (toggle.dataset.required === 'true') return;
|
||||
toggle.addEventListener('click', () => {
|
||||
toggle.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Close on overlay click
|
||||
overlay?.addEventListener('click', () => {
|
||||
// Don't close - user must make a choice
|
||||
});
|
||||
}
|
||||
|
||||
function closeBanner() {
|
||||
const banner = document.getElementById('cookieBanner');
|
||||
const overlay = document.getElementById('cookieBannerOverlay');
|
||||
banner?.classList.remove('active');
|
||||
overlay?.classList.remove('active');
|
||||
}
|
||||
|
||||
// Expose API
|
||||
window.CookieConsent = {
|
||||
getConsent,
|
||||
saveConsent,
|
||||
hasConsent,
|
||||
show: () => {
|
||||
document.getElementById('cookieBanner')?.classList.add('active');
|
||||
document.getElementById('cookieBannerOverlay')?.classList.add('active');
|
||||
},
|
||||
hide: closeBanner
|
||||
};
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBanner);
|
||||
} else {
|
||||
initBanner();
|
||||
}
|
||||
})();
|
||||
`.trim()
|
||||
}
|
||||
|
||||
// Note: All exports are defined inline with 'export const' and 'export function'
|
||||
export {
|
||||
DEFAULT_COOKIE_BANNER_TEXTS,
|
||||
DEFAULT_COOKIE_BANNER_STYLING,
|
||||
generateCookieCategories,
|
||||
generateCookieBannerConfig,
|
||||
} from './cookie-banner-config'
|
||||
|
||||
export {
|
||||
generateEmbedCode,
|
||||
} from './cookie-banner-embed'
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Privacy Policy Renderers & Main Generator
|
||||
*
|
||||
* Cookies section, changes section, rendering (HTML/Markdown),
|
||||
* and the main generatePrivacyPolicy entry point.
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
CompanyInfo,
|
||||
PrivacyPolicySection,
|
||||
GeneratedPrivacyPolicy,
|
||||
SupportedLanguage,
|
||||
ExportFormat,
|
||||
LocalizedText,
|
||||
} from '../types'
|
||||
import { RETENTION_MATRIX } from '../catalog/loader'
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
generateControllerSection,
|
||||
generateDataCollectionSection,
|
||||
generatePurposesSection,
|
||||
generateLegalBasisSection,
|
||||
generateRecipientsSection,
|
||||
generateRetentionSection,
|
||||
generateSpecialCategoriesSection,
|
||||
generateRightsSection,
|
||||
} from './privacy-policy-sections'
|
||||
|
||||
// =============================================================================
|
||||
// HELPER
|
||||
// =============================================================================
|
||||
|
||||
function t(text: LocalizedText, language: SupportedLanguage): string {
|
||||
return text[language]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECTION GENERATORS (cookies + changes)
|
||||
// =============================================================================
|
||||
|
||||
export function generateCookiesSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '8. Cookies und aehnliche Technologien',
|
||||
en: '8. Cookies and Similar Technologies',
|
||||
}
|
||||
|
||||
const cookieDataPoints = dataPoints.filter((dp) => dp.cookieCategory !== null)
|
||||
|
||||
if (cookieDataPoints.length === 0) {
|
||||
const content: LocalizedText = {
|
||||
de: 'Wir verwenden auf dieser Website keine Cookies.',
|
||||
en: 'We do not use cookies on this website.',
|
||||
}
|
||||
return {
|
||||
id: 'cookies',
|
||||
order: 8,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: false,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
const essential = cookieDataPoints.filter((dp) => dp.cookieCategory === 'ESSENTIAL')
|
||||
const performance = cookieDataPoints.filter((dp) => dp.cookieCategory === 'PERFORMANCE')
|
||||
const personalization = cookieDataPoints.filter((dp) => dp.cookieCategory === 'PERSONALIZATION')
|
||||
const externalMedia = cookieDataPoints.filter((dp) => dp.cookieCategory === 'EXTERNAL_MEDIA')
|
||||
|
||||
const sections: string[] = []
|
||||
|
||||
if (essential.length > 0) {
|
||||
const list = essential.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Technisch notwendige Cookies\n\nDiese Cookies sind fuer den Betrieb der Website erforderlich und koennen nicht deaktiviert werden.\n\n${list}`
|
||||
: `### Essential Cookies\n\nThese cookies are required for the website to function and cannot be disabled.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
if (performance.length > 0) {
|
||||
const list = performance.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Analyse- und Performance-Cookies\n\nDiese Cookies helfen uns, die Nutzung der Website zu verstehen und zu verbessern.\n\n${list}`
|
||||
: `### Analytics and Performance Cookies\n\nThese cookies help us understand and improve website usage.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
if (personalization.length > 0) {
|
||||
const list = personalization.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Personalisierungs-Cookies\n\nDiese Cookies ermoeglichen personalisierte Werbung und Inhalte.\n\n${list}`
|
||||
: `### Personalization Cookies\n\nThese cookies enable personalized advertising and content.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
if (externalMedia.length > 0) {
|
||||
const list = externalMedia.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Cookies fuer externe Medien\n\nDiese Cookies erlauben die Einbindung externer Medien wie Videos und Karten.\n\n${list}`
|
||||
: `### External Media Cookies\n\nThese cookies allow embedding external media like videos and maps.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
const intro: LocalizedText = {
|
||||
de: `Wir verwenden Cookies und aehnliche Technologien, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen. Sie koennen Ihre Cookie-Einstellungen jederzeit ueber unseren Cookie-Banner anpassen.`,
|
||||
en: `We use cookies and similar technologies to provide you with the best possible experience on our website. You can adjust your cookie settings at any time through our cookie banner.`,
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `${intro.de}\n\n${sections.join('\n\n')}`,
|
||||
en: `${intro.en}\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'cookies',
|
||||
order: 8,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: cookieDataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateChangesSection(
|
||||
version: string,
|
||||
date: Date,
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '9. Aenderungen dieser Datenschutzerklaerung',
|
||||
en: '9. Changes to this Privacy Policy',
|
||||
}
|
||||
|
||||
const formattedDate = formatDate(date, language)
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Diese Datenschutzerklaerung ist aktuell gueltig und hat den Stand: **${formattedDate}** (Version ${version}).
|
||||
|
||||
Wir behalten uns vor, diese Datenschutzerklaerung anzupassen, damit sie stets den aktuellen rechtlichen Anforderungen entspricht oder um Aenderungen unserer Leistungen umzusetzen.
|
||||
|
||||
Fuer Ihren erneuten Besuch gilt dann die neue Datenschutzerklaerung.`,
|
||||
en: `This privacy policy is currently valid and was last updated: **${formattedDate}** (Version ${version}).
|
||||
|
||||
We reserve the right to amend this privacy policy to ensure it always complies with current legal requirements or to implement changes to our services.
|
||||
|
||||
The new privacy policy will then apply for your next visit.`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'changes',
|
||||
order: 9,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN GENERATOR FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function generatePrivacyPolicySections(
|
||||
dataPoints: DataPoint[],
|
||||
companyInfo: CompanyInfo,
|
||||
language: SupportedLanguage,
|
||||
version: string = '1.0.0'
|
||||
): PrivacyPolicySection[] {
|
||||
const now = new Date()
|
||||
|
||||
const sections: PrivacyPolicySection[] = [
|
||||
generateControllerSection(companyInfo, language),
|
||||
generateDataCollectionSection(dataPoints, language),
|
||||
generatePurposesSection(dataPoints, language),
|
||||
generateLegalBasisSection(dataPoints, language),
|
||||
generateRecipientsSection(dataPoints, language),
|
||||
generateRetentionSection(dataPoints, RETENTION_MATRIX, language),
|
||||
]
|
||||
|
||||
const specialCategoriesSection = generateSpecialCategoriesSection(dataPoints, language)
|
||||
if (specialCategoriesSection) {
|
||||
sections.push(specialCategoriesSection)
|
||||
}
|
||||
|
||||
sections.push(
|
||||
generateRightsSection(language),
|
||||
generateCookiesSection(dataPoints, language),
|
||||
generateChangesSection(version, now, language)
|
||||
)
|
||||
|
||||
sections.forEach((section, index) => {
|
||||
section.order = index + 1
|
||||
const titleDe = section.title.de
|
||||
const titleEn = section.title.en
|
||||
if (titleDe.match(/^\d+[a-z]?\./)) {
|
||||
section.title.de = titleDe.replace(/^\d+[a-z]?\./, `${index + 1}.`)
|
||||
}
|
||||
if (titleEn.match(/^\d+[a-z]?\./)) {
|
||||
section.title.en = titleEn.replace(/^\d+[a-z]?\./, `${index + 1}.`)
|
||||
}
|
||||
})
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export function generatePrivacyPolicy(
|
||||
tenantId: string,
|
||||
dataPoints: DataPoint[],
|
||||
companyInfo: CompanyInfo,
|
||||
language: SupportedLanguage,
|
||||
format: ExportFormat = 'HTML'
|
||||
): GeneratedPrivacyPolicy {
|
||||
const version = '1.0.0'
|
||||
const sections = generatePrivacyPolicySections(dataPoints, companyInfo, language, version)
|
||||
const content = renderPrivacyPolicy(sections, language, format)
|
||||
|
||||
return {
|
||||
id: `privacy-policy-${tenantId}-${Date.now()}`,
|
||||
tenantId,
|
||||
language,
|
||||
sections,
|
||||
companyInfo,
|
||||
generatedAt: new Date(),
|
||||
version,
|
||||
format,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
function renderPrivacyPolicy(
|
||||
sections: PrivacyPolicySection[],
|
||||
language: SupportedLanguage,
|
||||
format: ExportFormat
|
||||
): string {
|
||||
switch (format) {
|
||||
case 'HTML':
|
||||
return renderAsHTML(sections, language)
|
||||
case 'MARKDOWN':
|
||||
return renderAsMarkdown(sections, language)
|
||||
default:
|
||||
return renderAsMarkdown(sections, language)
|
||||
}
|
||||
}
|
||||
|
||||
export function renderAsHTML(sections: PrivacyPolicySection[], language: SupportedLanguage): string {
|
||||
const title = language === 'de' ? 'Datenschutzerklaerung' : 'Privacy Policy'
|
||||
|
||||
const sectionsHTML = sections
|
||||
.map((section) => {
|
||||
const content = t(section.content, language)
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/### (.+)/g, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/- (.+)(?:<br>|$)/g, '<li>$1</li>')
|
||||
|
||||
return `
|
||||
<section id="${section.id}">
|
||||
<h2>${t(section.title, language)}</h2>
|
||||
<p>${content}</p>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
h1 { font-size: 2rem; margin-bottom: 2rem; }
|
||||
h2 { font-size: 1.5rem; margin-top: 2rem; color: #1a1a1a; }
|
||||
h3 { font-size: 1.25rem; margin-top: 1.5rem; color: #333; }
|
||||
p { margin: 1rem 0; }
|
||||
ul, ol { margin: 1rem 0; padding-left: 2rem; }
|
||||
li { margin: 0.5rem 0; }
|
||||
strong { font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${title}</h1>
|
||||
${sectionsHTML}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export function renderAsMarkdown(sections: PrivacyPolicySection[], language: SupportedLanguage): string {
|
||||
const title = language === 'de' ? 'Datenschutzerklaerung' : 'Privacy Policy'
|
||||
|
||||
const sectionsMarkdown = sections
|
||||
.map((section) => {
|
||||
return `## ${t(section.title, language)}\n\n${t(section.content, language)}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
return `# ${title}\n\n${sectionsMarkdown}`
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* Privacy Policy Section Generators
|
||||
*
|
||||
* Generiert die 9 Abschnitte der Datenschutzerklaerung (DSI)
|
||||
* aus dem Datenpunktkatalog.
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
CompanyInfo,
|
||||
PrivacyPolicySection,
|
||||
SupportedLanguage,
|
||||
LocalizedText,
|
||||
RetentionMatrixEntry,
|
||||
LegalBasis,
|
||||
CATEGORY_METADATA,
|
||||
LEGAL_BASIS_INFO,
|
||||
RETENTION_PERIOD_INFO,
|
||||
} from '../types'
|
||||
|
||||
// =============================================================================
|
||||
// KONSTANTEN
|
||||
// =============================================================================
|
||||
|
||||
const ALL_CATEGORIES: DataPointCategory[] = [
|
||||
'MASTER_DATA', 'CONTACT_DATA', 'AUTHENTICATION', 'CONSENT',
|
||||
'COMMUNICATION', 'PAYMENT', 'USAGE_DATA', 'LOCATION',
|
||||
'DEVICE_DATA', 'MARKETING', 'ANALYTICS', 'SOCIAL_MEDIA',
|
||||
'HEALTH_DATA', 'EMPLOYEE_DATA', 'CONTRACT_DATA', 'LOG_DATA',
|
||||
'AI_DATA', 'SECURITY',
|
||||
]
|
||||
|
||||
const ALL_LEGAL_BASES: LegalBasis[] = [
|
||||
'CONTRACT', 'CONSENT', 'EXPLICIT_CONSENT', 'LEGITIMATE_INTEREST',
|
||||
'LEGAL_OBLIGATION', 'VITAL_INTERESTS', 'PUBLIC_INTEREST',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function t(text: LocalizedText, language: SupportedLanguage): string {
|
||||
return text[language]
|
||||
}
|
||||
|
||||
function groupByCategory(dataPoints: DataPoint[]): Map<DataPointCategory, DataPoint[]> {
|
||||
const grouped = new Map<DataPointCategory, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const existing = grouped.get(dp.category) || []
|
||||
grouped.set(dp.category, [...existing, dp])
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
function groupByLegalBasis(dataPoints: DataPoint[]): Map<LegalBasis, DataPoint[]> {
|
||||
const grouped = new Map<LegalBasis, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const existing = grouped.get(dp.legalBasis) || []
|
||||
grouped.set(dp.legalBasis, [...existing, dp])
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
function extractThirdParties(dataPoints: DataPoint[]): string[] {
|
||||
const thirdParties = new Set<string>()
|
||||
for (const dp of dataPoints) {
|
||||
for (const recipient of dp.thirdPartyRecipients) {
|
||||
thirdParties.add(recipient)
|
||||
}
|
||||
}
|
||||
return Array.from(thirdParties).sort()
|
||||
}
|
||||
|
||||
export function formatDate(date: Date, language: SupportedLanguage): string {
|
||||
return date.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECTION GENERATORS
|
||||
// =============================================================================
|
||||
|
||||
export function generateControllerSection(
|
||||
companyInfo: CompanyInfo,
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '1. Verantwortlicher',
|
||||
en: '1. Data Controller',
|
||||
}
|
||||
|
||||
const dpoSection = companyInfo.dpoName
|
||||
? language === 'de'
|
||||
? `\n\n**Datenschutzbeauftragter:**\n${companyInfo.dpoName}${companyInfo.dpoEmail ? `\nE-Mail: ${companyInfo.dpoEmail}` : ''}${companyInfo.dpoPhone ? `\nTelefon: ${companyInfo.dpoPhone}` : ''}`
|
||||
: `\n\n**Data Protection Officer:**\n${companyInfo.dpoName}${companyInfo.dpoEmail ? `\nEmail: ${companyInfo.dpoEmail}` : ''}${companyInfo.dpoPhone ? `\nPhone: ${companyInfo.dpoPhone}` : ''}`
|
||||
: ''
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Verantwortlich fuer die Datenverarbeitung auf dieser Website ist:
|
||||
|
||||
**${companyInfo.name}**
|
||||
${companyInfo.address}
|
||||
${companyInfo.postalCode} ${companyInfo.city}
|
||||
${companyInfo.country}
|
||||
|
||||
E-Mail: ${companyInfo.email}${companyInfo.phone ? `\nTelefon: ${companyInfo.phone}` : ''}${companyInfo.website ? `\nWebsite: ${companyInfo.website}` : ''}${companyInfo.registrationNumber ? `\n\nHandelsregister: ${companyInfo.registrationNumber}` : ''}${companyInfo.vatId ? `\nUSt-IdNr.: ${companyInfo.vatId}` : ''}${dpoSection}`,
|
||||
en: `The controller responsible for data processing on this website is:
|
||||
|
||||
**${companyInfo.name}**
|
||||
${companyInfo.address}
|
||||
${companyInfo.postalCode} ${companyInfo.city}
|
||||
${companyInfo.country}
|
||||
|
||||
Email: ${companyInfo.email}${companyInfo.phone ? `\nPhone: ${companyInfo.phone}` : ''}${companyInfo.website ? `\nWebsite: ${companyInfo.website}` : ''}${companyInfo.registrationNumber ? `\n\nCommercial Register: ${companyInfo.registrationNumber}` : ''}${companyInfo.vatId ? `\nVAT ID: ${companyInfo.vatId}` : ''}${dpoSection}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'controller',
|
||||
order: 1,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateDataCollectionSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '2. Erhobene personenbezogene Daten',
|
||||
en: '2. Personal Data We Collect',
|
||||
}
|
||||
|
||||
const grouped = groupByCategory(dataPoints)
|
||||
const sections: string[] = []
|
||||
const hasSpecialCategoryData = dataPoints.some(dp => dp.isSpecialCategory || dp.category === 'HEALTH_DATA')
|
||||
|
||||
for (const category of ALL_CATEGORIES) {
|
||||
const categoryData = grouped.get(category)
|
||||
if (!categoryData || categoryData.length === 0) continue
|
||||
|
||||
const categoryMeta = CATEGORY_METADATA[category]
|
||||
if (!categoryMeta) continue
|
||||
|
||||
const categoryTitle = t(categoryMeta.name, language)
|
||||
|
||||
let categoryNote = ''
|
||||
if (category === 'HEALTH_DATA') {
|
||||
categoryNote = language === 'de'
|
||||
? `\n\n> **Hinweis:** Diese Daten gehoeren zu den besonderen Kategorien personenbezogener Daten gemaess Art. 9 DSGVO und erfordern eine ausdrueckliche Einwilligung.`
|
||||
: `\n\n> **Note:** This data belongs to special categories of personal data under Art. 9 GDPR and requires explicit consent.`
|
||||
} else if (category === 'EMPLOYEE_DATA') {
|
||||
categoryNote = language === 'de'
|
||||
? `\n\n> **Hinweis:** Die Verarbeitung von Beschaeftigtendaten erfolgt gemaess § 26 BDSG.`
|
||||
: `\n\n> **Note:** Processing of employee data is carried out in accordance with § 26 BDSG (German Federal Data Protection Act).`
|
||||
} else if (category === 'AI_DATA') {
|
||||
categoryNote = language === 'de'
|
||||
? `\n\n> **Hinweis:** Die Verarbeitung von KI-bezogenen Daten unterliegt den Transparenzpflichten des AI Acts.`
|
||||
: `\n\n> **Note:** Processing of AI-related data is subject to AI Act transparency requirements.`
|
||||
}
|
||||
|
||||
const dataList = categoryData
|
||||
.map((dp) => {
|
||||
const specialTag = dp.isSpecialCategory
|
||||
? (language === 'de' ? ' *(Art. 9 DSGVO)*' : ' *(Art. 9 GDPR)*')
|
||||
: ''
|
||||
return `- **${t(dp.name, language)}**${specialTag}: ${t(dp.description, language)}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
sections.push(`### ${categoryMeta.code}. ${categoryTitle}\n\n${dataList}${categoryNote}`)
|
||||
}
|
||||
|
||||
const intro: LocalizedText = {
|
||||
de: 'Wir erheben und verarbeiten die folgenden personenbezogenen Daten:',
|
||||
en: 'We collect and process the following personal data:',
|
||||
}
|
||||
|
||||
const specialCategoryNote: LocalizedText = hasSpecialCategoryData
|
||||
? {
|
||||
de: '\n\n**Wichtig:** Einige der unten aufgefuehrten Daten gehoeren zu den besonderen Kategorien personenbezogener Daten nach Art. 9 DSGVO und werden nur mit Ihrer ausdruecklichen Einwilligung verarbeitet.',
|
||||
en: '\n\n**Important:** Some of the data listed below belongs to special categories of personal data under Art. 9 GDPR and is only processed with your explicit consent.',
|
||||
}
|
||||
: { de: '', en: '' }
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `${intro.de}${specialCategoryNote.de}\n\n${sections.join('\n\n')}`,
|
||||
en: `${intro.en}${specialCategoryNote.en}\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'data-collection',
|
||||
order: 2,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePurposesSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '3. Zwecke der Datenverarbeitung',
|
||||
en: '3. Purposes of Data Processing',
|
||||
}
|
||||
|
||||
const purposes = new Map<string, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const purpose = t(dp.purpose, language)
|
||||
const existing = purposes.get(purpose) || []
|
||||
purposes.set(purpose, [...existing, dp])
|
||||
}
|
||||
|
||||
const purposeList = Array.from(purposes.entries())
|
||||
.map(([purpose, dps]) => {
|
||||
const dataNames = dps.map((dp) => t(dp.name, language)).join(', ')
|
||||
return `- **${purpose}**\n Betroffene Daten: ${dataNames}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Wir verarbeiten Ihre personenbezogenen Daten fuer folgende Zwecke:\n\n${purposeList}`,
|
||||
en: `We process your personal data for the following purposes:\n\n${purposeList}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'purposes',
|
||||
order: 3,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateLegalBasisSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '4. Rechtsgrundlagen der Verarbeitung',
|
||||
en: '4. Legal Basis for Processing',
|
||||
}
|
||||
|
||||
const grouped = groupByLegalBasis(dataPoints)
|
||||
const sections: string[] = []
|
||||
|
||||
for (const basis of ALL_LEGAL_BASES) {
|
||||
const basisData = grouped.get(basis)
|
||||
if (!basisData || basisData.length === 0) continue
|
||||
|
||||
const basisInfo = LEGAL_BASIS_INFO[basis]
|
||||
if (!basisInfo) continue
|
||||
|
||||
const basisTitle = `${t(basisInfo.name, language)} (${basisInfo.article})`
|
||||
const basisDesc = t(basisInfo.description, language)
|
||||
|
||||
let additionalWarning = ''
|
||||
if (basis === 'EXPLICIT_CONSENT') {
|
||||
additionalWarning = language === 'de'
|
||||
? `\n\n> **Wichtig:** Fuer die Verarbeitung dieser besonderen Kategorien personenbezogener Daten (Art. 9 DSGVO) ist eine separate, ausdrueckliche Einwilligung erforderlich, die Sie jederzeit widerrufen koennen.`
|
||||
: `\n\n> **Important:** Processing of these special categories of personal data (Art. 9 GDPR) requires a separate, explicit consent that you can withdraw at any time.`
|
||||
}
|
||||
|
||||
const dataLabel = language === 'de' ? 'Betroffene Daten' : 'Affected Data'
|
||||
const dataList = basisData
|
||||
.map((dp) => {
|
||||
const specialTag = dp.isSpecialCategory
|
||||
? (language === 'de' ? ' *(Art. 9 DSGVO)*' : ' *(Art. 9 GDPR)*')
|
||||
: ''
|
||||
return `- ${t(dp.name, language)}${specialTag}: ${t(dp.legalBasisJustification, language)}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
sections.push(`### ${basisTitle}\n\n${basisDesc}${additionalWarning}\n\n**${dataLabel}:**\n${dataList}`)
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf Grundlage folgender Rechtsgrundlagen:\n\n${sections.join('\n\n')}`,
|
||||
en: `The processing of your personal data is based on the following legal grounds:\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'legal-basis',
|
||||
order: 4,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRecipientsSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '5. Empfaenger und Datenweitergabe',
|
||||
en: '5. Recipients and Data Sharing',
|
||||
}
|
||||
|
||||
const thirdParties = extractThirdParties(dataPoints)
|
||||
|
||||
if (thirdParties.length === 0) {
|
||||
const content: LocalizedText = {
|
||||
de: 'Wir geben Ihre personenbezogenen Daten grundsaetzlich nicht an Dritte weiter, es sei denn, dies ist zur Vertragserfuellung erforderlich oder Sie haben ausdruecklich eingewilligt.',
|
||||
en: 'We generally do not share your personal data with third parties unless this is necessary for contract performance or you have expressly consented.',
|
||||
}
|
||||
return {
|
||||
id: 'recipients',
|
||||
order: 5,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
const recipientDetails = new Map<string, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
for (const recipient of dp.thirdPartyRecipients) {
|
||||
const existing = recipientDetails.get(recipient) || []
|
||||
recipientDetails.set(recipient, [...existing, dp])
|
||||
}
|
||||
}
|
||||
|
||||
const recipientList = Array.from(recipientDetails.entries())
|
||||
.map(([recipient, dps]) => {
|
||||
const dataNames = dps.map((dp) => t(dp.name, language)).join(', ')
|
||||
return `- **${recipient}**: ${dataNames}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Wir uebermitteln Ihre personenbezogenen Daten an folgende Empfaenger bzw. Kategorien von Empfaengern:\n\n${recipientList}\n\nMit allen Auftragsverarbeitern haben wir Auftragsverarbeitungsvertraege nach Art. 28 DSGVO abgeschlossen.`,
|
||||
en: `We share your personal data with the following recipients or categories of recipients:\n\n${recipientList}\n\nWe have concluded data processing agreements pursuant to Art. 28 GDPR with all processors.`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'recipients',
|
||||
order: 5,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.filter((dp) => dp.thirdPartyRecipients.length > 0).map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRetentionSection(
|
||||
dataPoints: DataPoint[],
|
||||
retentionMatrix: RetentionMatrixEntry[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '6. Speicherdauer',
|
||||
en: '6. Data Retention',
|
||||
}
|
||||
|
||||
const grouped = groupByCategory(dataPoints)
|
||||
const sections: string[] = []
|
||||
|
||||
for (const entry of retentionMatrix) {
|
||||
const categoryData = grouped.get(entry.category)
|
||||
if (!categoryData || categoryData.length === 0) continue
|
||||
|
||||
const categoryName = t(entry.categoryName, language)
|
||||
const standardPeriod = t(RETENTION_PERIOD_INFO[entry.standardPeriod].label, language)
|
||||
|
||||
const dataRetention = categoryData
|
||||
.map((dp) => {
|
||||
const period = t(RETENTION_PERIOD_INFO[dp.retentionPeriod].label, language)
|
||||
return `- ${t(dp.name, language)}: ${period}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
sections.push(`### ${categoryName}\n\n**Standardfrist:** ${standardPeriod}\n\n${dataRetention}`)
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Wir speichern Ihre personenbezogenen Daten nur so lange, wie dies fuer die jeweiligen Zwecke erforderlich ist oder gesetzliche Aufbewahrungsfristen bestehen.\n\n${sections.join('\n\n')}`,
|
||||
en: `We store your personal data only for as long as is necessary for the respective purposes or as required by statutory retention periods.\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'retention',
|
||||
order: 6,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSpecialCategoriesSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection | null {
|
||||
const specialCategoryDataPoints = dataPoints.filter(dp => dp.isSpecialCategory || dp.category === 'HEALTH_DATA')
|
||||
|
||||
if (specialCategoryDataPoints.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title: LocalizedText = {
|
||||
de: '6a. Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO)',
|
||||
en: '6a. Special Categories of Personal Data (Art. 9 GDPR)',
|
||||
}
|
||||
|
||||
const dataList = specialCategoryDataPoints
|
||||
.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.description, language)}`)
|
||||
.join('\n')
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Gemaess Art. 9 DSGVO verarbeiten wir auch besondere Kategorien personenbezogener Daten, die einen erhoehten Schutz geniessen. Diese Daten umfassen:
|
||||
|
||||
${dataList}
|
||||
|
||||
### Ihre ausdrueckliche Einwilligung
|
||||
|
||||
Die Verarbeitung dieser besonderen Kategorien personenbezogener Daten erfolgt nur auf Grundlage Ihrer **ausdruecklichen Einwilligung** gemaess Art. 9 Abs. 2 lit. a DSGVO.
|
||||
|
||||
### Ihre Rechte bei Art. 9 Daten
|
||||
|
||||
- Sie koennen Ihre Einwilligung **jederzeit widerrufen**
|
||||
- Der Widerruf beruehrt nicht die Rechtmaessigkeit der bisherigen Verarbeitung
|
||||
- Bei Widerruf werden Ihre Daten unverzueglich geloescht
|
||||
- Sie haben das Recht auf **Auskunft, Berichtigung und Loeschung**
|
||||
|
||||
### Besondere Schutzmassnahmen
|
||||
|
||||
Fuer diese sensiblen Daten haben wir besondere technische und organisatorische Massnahmen implementiert:
|
||||
- Ende-zu-Ende-Verschluesselung
|
||||
- Strenge Zugriffskontrolle (Need-to-Know-Prinzip)
|
||||
- Audit-Logging aller Zugriffe
|
||||
- Regelmaessige Datenschutz-Folgenabschaetzungen`,
|
||||
en: `In accordance with Art. 9 GDPR, we also process special categories of personal data that enjoy enhanced protection. This data includes:
|
||||
|
||||
${dataList}
|
||||
|
||||
### Your Explicit Consent
|
||||
|
||||
Processing of these special categories of personal data only takes place on the basis of your **explicit consent** pursuant to Art. 9(2)(a) GDPR.
|
||||
|
||||
### Your Rights Regarding Art. 9 Data
|
||||
|
||||
- You can **withdraw your consent at any time**
|
||||
- Withdrawal does not affect the lawfulness of previous processing
|
||||
- Upon withdrawal, your data will be deleted immediately
|
||||
- You have the right to **access, rectification, and erasure**
|
||||
|
||||
### Special Protection Measures
|
||||
|
||||
For this sensitive data, we have implemented special technical and organizational measures:
|
||||
- End-to-end encryption
|
||||
- Strict access control (need-to-know principle)
|
||||
- Audit logging of all access
|
||||
- Regular data protection impact assessments`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'special-categories',
|
||||
order: 6.5,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: specialCategoryDataPoints.map((dp) => dp.id),
|
||||
isRequired: false,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRightsSection(language: SupportedLanguage): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '7. Ihre Rechte als betroffene Person',
|
||||
en: '7. Your Rights as a Data Subject',
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Sie haben gegenueber uns folgende Rechte hinsichtlich der Sie betreffenden personenbezogenen Daten:
|
||||
|
||||
### Auskunftsrecht (Art. 15 DSGVO)
|
||||
Sie haben das Recht, Auskunft ueber die von uns verarbeiteten personenbezogenen Daten zu verlangen.
|
||||
|
||||
### Recht auf Berichtigung (Art. 16 DSGVO)
|
||||
Sie haben das Recht, die Berichtigung unrichtiger oder die Vervollstaendigung unvollstaendiger Daten zu verlangen.
|
||||
|
||||
### Recht auf Loeschung (Art. 17 DSGVO)
|
||||
Sie haben das Recht, die Loeschung Ihrer personenbezogenen Daten zu verlangen, sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
|
||||
|
||||
### Recht auf Einschraenkung der Verarbeitung (Art. 18 DSGVO)
|
||||
Sie haben das Recht, die Einschraenkung der Verarbeitung Ihrer Daten zu verlangen.
|
||||
|
||||
### Recht auf Datenuebertragbarkeit (Art. 20 DSGVO)
|
||||
Sie haben das Recht, Ihre Daten in einem strukturierten, gaengigen und maschinenlesbaren Format zu erhalten.
|
||||
|
||||
### Widerspruchsrecht (Art. 21 DSGVO)
|
||||
Sie haben das Recht, der Verarbeitung Ihrer Daten jederzeit zu widersprechen, soweit die Verarbeitung auf berechtigtem Interesse beruht.
|
||||
|
||||
### Recht auf Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO)
|
||||
Sie haben das Recht, Ihre erteilte Einwilligung jederzeit zu widerrufen. Die Rechtmaessigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung wird dadurch nicht beruehrt.
|
||||
|
||||
### Beschwerderecht bei der Aufsichtsbehoerde (Art. 77 DSGVO)
|
||||
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehoerde ueber die Verarbeitung Ihrer personenbezogenen Daten zu beschweren.
|
||||
|
||||
**Zur Ausuebung Ihrer Rechte wenden Sie sich bitte an die oben angegebenen Kontaktdaten.**`,
|
||||
en: `You have the following rights regarding your personal data:
|
||||
|
||||
### Right of Access (Art. 15 GDPR)
|
||||
You have the right to request information about the personal data we process about you.
|
||||
|
||||
### Right to Rectification (Art. 16 GDPR)
|
||||
You have the right to request the correction of inaccurate data or the completion of incomplete data.
|
||||
|
||||
### Right to Erasure (Art. 17 GDPR)
|
||||
You have the right to request the deletion of your personal data, unless statutory retention obligations apply.
|
||||
|
||||
### Right to Restriction of Processing (Art. 18 GDPR)
|
||||
You have the right to request the restriction of processing of your data.
|
||||
|
||||
### Right to Data Portability (Art. 20 GDPR)
|
||||
You have the right to receive your data in a structured, commonly used, and machine-readable format.
|
||||
|
||||
### Right to Object (Art. 21 GDPR)
|
||||
You have the right to object to the processing of your data at any time, insofar as the processing is based on legitimate interest.
|
||||
|
||||
### Right to Withdraw Consent (Art. 7(3) GDPR)
|
||||
You have the right to withdraw your consent at any time. The lawfulness of processing based on consent before its withdrawal is not affected.
|
||||
|
||||
### Right to Lodge a Complaint with a Supervisory Authority (Art. 77 GDPR)
|
||||
You have the right to lodge a complaint with a data protection supervisory authority about the processing of your personal data.
|
||||
|
||||
**To exercise your rights, please contact us using the contact details provided above.**`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'rights',
|
||||
order: 7,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
@@ -1,954 +1,11 @@
|
||||
/**
|
||||
* Privacy Policy Generator
|
||||
* Privacy Policy Generator — barrel re-export
|
||||
*
|
||||
* Generiert Datenschutzerklaerungen (DSI) aus dem Datenpunktkatalog.
|
||||
* Die DSI wird aus 9 Abschnitten generiert:
|
||||
*
|
||||
* 1. Verantwortlicher (companyInfo)
|
||||
* 2. Erhobene Daten (dataPoints nach Kategorie)
|
||||
* 3. Verarbeitungszwecke (dataPoints.purpose)
|
||||
* 4. Rechtsgrundlagen (dataPoints.legalBasis)
|
||||
* 5. Empfaenger/Dritte (dataPoints.thirdPartyRecipients)
|
||||
* 6. Speicherdauer (retentionMatrix)
|
||||
* 7. Betroffenenrechte (statischer Text + Links)
|
||||
* 8. Cookies (cookieCategory-basiert)
|
||||
* 9. Aenderungen (statischer Text + Versionierung)
|
||||
* Split into:
|
||||
* - privacy-policy-sections.ts (section generators 1-7)
|
||||
* - privacy-policy-renderers.ts (sections 8-9, renderers, main generator)
|
||||
*/
|
||||
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
CompanyInfo,
|
||||
PrivacyPolicySection,
|
||||
GeneratedPrivacyPolicy,
|
||||
SupportedLanguage,
|
||||
ExportFormat,
|
||||
LocalizedText,
|
||||
RetentionMatrixEntry,
|
||||
LegalBasis,
|
||||
CATEGORY_METADATA,
|
||||
LEGAL_BASIS_INFO,
|
||||
RETENTION_PERIOD_INFO,
|
||||
ARTICLE_9_WARNING,
|
||||
} from '../types'
|
||||
import { RETENTION_MATRIX } from '../catalog/loader'
|
||||
|
||||
// =============================================================================
|
||||
// KONSTANTEN - 18 Kategorien in der richtigen Reihenfolge
|
||||
// =============================================================================
|
||||
|
||||
const ALL_CATEGORIES: DataPointCategory[] = [
|
||||
'MASTER_DATA', // A
|
||||
'CONTACT_DATA', // B
|
||||
'AUTHENTICATION', // C
|
||||
'CONSENT', // D
|
||||
'COMMUNICATION', // E
|
||||
'PAYMENT', // F
|
||||
'USAGE_DATA', // G
|
||||
'LOCATION', // H
|
||||
'DEVICE_DATA', // I
|
||||
'MARKETING', // J
|
||||
'ANALYTICS', // K
|
||||
'SOCIAL_MEDIA', // L
|
||||
'HEALTH_DATA', // M - Art. 9 DSGVO
|
||||
'EMPLOYEE_DATA', // N - BDSG § 26
|
||||
'CONTRACT_DATA', // O
|
||||
'LOG_DATA', // P
|
||||
'AI_DATA', // Q - AI Act
|
||||
'SECURITY', // R
|
||||
]
|
||||
|
||||
// Alle Rechtsgrundlagen in der richtigen Reihenfolge
|
||||
const ALL_LEGAL_BASES: LegalBasis[] = [
|
||||
'CONTRACT',
|
||||
'CONSENT',
|
||||
'EXPLICIT_CONSENT',
|
||||
'LEGITIMATE_INTEREST',
|
||||
'LEGAL_OBLIGATION',
|
||||
'VITAL_INTERESTS',
|
||||
'PUBLIC_INTEREST',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Holt den lokalisierten Text
|
||||
*/
|
||||
function t(text: LocalizedText, language: SupportedLanguage): string {
|
||||
return text[language]
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Datenpunkte nach Kategorie
|
||||
*/
|
||||
function groupByCategory(dataPoints: DataPoint[]): Map<DataPointCategory, DataPoint[]> {
|
||||
const grouped = new Map<DataPointCategory, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const existing = grouped.get(dp.category) || []
|
||||
grouped.set(dp.category, [...existing, dp])
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Datenpunkte nach Rechtsgrundlage
|
||||
*/
|
||||
function groupByLegalBasis(dataPoints: DataPoint[]): Map<LegalBasis, DataPoint[]> {
|
||||
const grouped = new Map<LegalBasis, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const existing = grouped.get(dp.legalBasis) || []
|
||||
grouped.set(dp.legalBasis, [...existing, dp])
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert alle einzigartigen Drittanbieter
|
||||
*/
|
||||
function extractThirdParties(dataPoints: DataPoint[]): string[] {
|
||||
const thirdParties = new Set<string>()
|
||||
for (const dp of dataPoints) {
|
||||
for (const recipient of dp.thirdPartyRecipients) {
|
||||
thirdParties.add(recipient)
|
||||
}
|
||||
}
|
||||
return Array.from(thirdParties).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert ein Datum fuer die Anzeige
|
||||
*/
|
||||
function formatDate(date: Date, language: SupportedLanguage): string {
|
||||
return date.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECTION GENERATORS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abschnitt 1: Verantwortlicher
|
||||
*/
|
||||
function generateControllerSection(
|
||||
companyInfo: CompanyInfo,
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '1. Verantwortlicher',
|
||||
en: '1. Data Controller',
|
||||
}
|
||||
|
||||
const dpoSection = companyInfo.dpoName
|
||||
? language === 'de'
|
||||
? `\n\n**Datenschutzbeauftragter:**\n${companyInfo.dpoName}${companyInfo.dpoEmail ? `\nE-Mail: ${companyInfo.dpoEmail}` : ''}${companyInfo.dpoPhone ? `\nTelefon: ${companyInfo.dpoPhone}` : ''}`
|
||||
: `\n\n**Data Protection Officer:**\n${companyInfo.dpoName}${companyInfo.dpoEmail ? `\nEmail: ${companyInfo.dpoEmail}` : ''}${companyInfo.dpoPhone ? `\nPhone: ${companyInfo.dpoPhone}` : ''}`
|
||||
: ''
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Verantwortlich fuer die Datenverarbeitung auf dieser Website ist:
|
||||
|
||||
**${companyInfo.name}**
|
||||
${companyInfo.address}
|
||||
${companyInfo.postalCode} ${companyInfo.city}
|
||||
${companyInfo.country}
|
||||
|
||||
E-Mail: ${companyInfo.email}${companyInfo.phone ? `\nTelefon: ${companyInfo.phone}` : ''}${companyInfo.website ? `\nWebsite: ${companyInfo.website}` : ''}${companyInfo.registrationNumber ? `\n\nHandelsregister: ${companyInfo.registrationNumber}` : ''}${companyInfo.vatId ? `\nUSt-IdNr.: ${companyInfo.vatId}` : ''}${dpoSection}`,
|
||||
en: `The controller responsible for data processing on this website is:
|
||||
|
||||
**${companyInfo.name}**
|
||||
${companyInfo.address}
|
||||
${companyInfo.postalCode} ${companyInfo.city}
|
||||
${companyInfo.country}
|
||||
|
||||
Email: ${companyInfo.email}${companyInfo.phone ? `\nPhone: ${companyInfo.phone}` : ''}${companyInfo.website ? `\nWebsite: ${companyInfo.website}` : ''}${companyInfo.registrationNumber ? `\n\nCommercial Register: ${companyInfo.registrationNumber}` : ''}${companyInfo.vatId ? `\nVAT ID: ${companyInfo.vatId}` : ''}${dpoSection}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'controller',
|
||||
order: 1,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 2: Erhobene Daten (18 Kategorien)
|
||||
*/
|
||||
function generateDataCollectionSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '2. Erhobene personenbezogene Daten',
|
||||
en: '2. Personal Data We Collect',
|
||||
}
|
||||
|
||||
const grouped = groupByCategory(dataPoints)
|
||||
const sections: string[] = []
|
||||
|
||||
// Prüfe ob Art. 9 Daten enthalten sind
|
||||
const hasSpecialCategoryData = dataPoints.some(dp => dp.isSpecialCategory || dp.category === 'HEALTH_DATA')
|
||||
|
||||
for (const category of ALL_CATEGORIES) {
|
||||
const categoryData = grouped.get(category)
|
||||
if (!categoryData || categoryData.length === 0) continue
|
||||
|
||||
const categoryMeta = CATEGORY_METADATA[category]
|
||||
if (!categoryMeta) continue
|
||||
|
||||
const categoryTitle = t(categoryMeta.name, language)
|
||||
|
||||
// Spezielle Warnung für Art. 9 DSGVO Daten (Gesundheitsdaten)
|
||||
let categoryNote = ''
|
||||
if (category === 'HEALTH_DATA') {
|
||||
categoryNote = language === 'de'
|
||||
? `\n\n> **Hinweis:** Diese Daten gehoeren zu den besonderen Kategorien personenbezogener Daten gemaess Art. 9 DSGVO und erfordern eine ausdrueckliche Einwilligung.`
|
||||
: `\n\n> **Note:** This data belongs to special categories of personal data under Art. 9 GDPR and requires explicit consent.`
|
||||
} else if (category === 'EMPLOYEE_DATA') {
|
||||
categoryNote = language === 'de'
|
||||
? `\n\n> **Hinweis:** Die Verarbeitung von Beschaeftigtendaten erfolgt gemaess § 26 BDSG.`
|
||||
: `\n\n> **Note:** Processing of employee data is carried out in accordance with § 26 BDSG (German Federal Data Protection Act).`
|
||||
} else if (category === 'AI_DATA') {
|
||||
categoryNote = language === 'de'
|
||||
? `\n\n> **Hinweis:** Die Verarbeitung von KI-bezogenen Daten unterliegt den Transparenzpflichten des AI Acts.`
|
||||
: `\n\n> **Note:** Processing of AI-related data is subject to AI Act transparency requirements.`
|
||||
}
|
||||
|
||||
const dataList = categoryData
|
||||
.map((dp) => {
|
||||
const specialTag = dp.isSpecialCategory
|
||||
? (language === 'de' ? ' *(Art. 9 DSGVO)*' : ' *(Art. 9 GDPR)*')
|
||||
: ''
|
||||
return `- **${t(dp.name, language)}**${specialTag}: ${t(dp.description, language)}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
sections.push(`### ${categoryMeta.code}. ${categoryTitle}\n\n${dataList}${categoryNote}`)
|
||||
}
|
||||
|
||||
const intro: LocalizedText = {
|
||||
de: 'Wir erheben und verarbeiten die folgenden personenbezogenen Daten:',
|
||||
en: 'We collect and process the following personal data:',
|
||||
}
|
||||
|
||||
// Zusätzlicher Hinweis für Art. 9 Daten
|
||||
const specialCategoryNote: LocalizedText = hasSpecialCategoryData
|
||||
? {
|
||||
de: '\n\n**Wichtig:** Einige der unten aufgefuehrten Daten gehoeren zu den besonderen Kategorien personenbezogener Daten nach Art. 9 DSGVO und werden nur mit Ihrer ausdruecklichen Einwilligung verarbeitet.',
|
||||
en: '\n\n**Important:** Some of the data listed below belongs to special categories of personal data under Art. 9 GDPR and is only processed with your explicit consent.',
|
||||
}
|
||||
: { de: '', en: '' }
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `${intro.de}${specialCategoryNote.de}\n\n${sections.join('\n\n')}`,
|
||||
en: `${intro.en}${specialCategoryNote.en}\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'data-collection',
|
||||
order: 2,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 3: Verarbeitungszwecke
|
||||
*/
|
||||
function generatePurposesSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '3. Zwecke der Datenverarbeitung',
|
||||
en: '3. Purposes of Data Processing',
|
||||
}
|
||||
|
||||
// Gruppiere nach Zweck (unique purposes)
|
||||
const purposes = new Map<string, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const purpose = t(dp.purpose, language)
|
||||
const existing = purposes.get(purpose) || []
|
||||
purposes.set(purpose, [...existing, dp])
|
||||
}
|
||||
|
||||
const purposeList = Array.from(purposes.entries())
|
||||
.map(([purpose, dps]) => {
|
||||
const dataNames = dps.map((dp) => t(dp.name, language)).join(', ')
|
||||
return `- **${purpose}**\n Betroffene Daten: ${dataNames}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Wir verarbeiten Ihre personenbezogenen Daten fuer folgende Zwecke:\n\n${purposeList}`,
|
||||
en: `We process your personal data for the following purposes:\n\n${purposeList}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'purposes',
|
||||
order: 3,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 4: Rechtsgrundlagen (alle 7 Rechtsgrundlagen)
|
||||
*/
|
||||
function generateLegalBasisSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '4. Rechtsgrundlagen der Verarbeitung',
|
||||
en: '4. Legal Basis for Processing',
|
||||
}
|
||||
|
||||
const grouped = groupByLegalBasis(dataPoints)
|
||||
const sections: string[] = []
|
||||
|
||||
// Alle 7 Rechtsgrundlagen in der richtigen Reihenfolge
|
||||
for (const basis of ALL_LEGAL_BASES) {
|
||||
const basisData = grouped.get(basis)
|
||||
if (!basisData || basisData.length === 0) continue
|
||||
|
||||
const basisInfo = LEGAL_BASIS_INFO[basis]
|
||||
if (!basisInfo) continue
|
||||
|
||||
const basisTitle = `${t(basisInfo.name, language)} (${basisInfo.article})`
|
||||
const basisDesc = t(basisInfo.description, language)
|
||||
|
||||
// Für Art. 9 Daten (EXPLICIT_CONSENT) zusätzliche Warnung hinzufügen
|
||||
let additionalWarning = ''
|
||||
if (basis === 'EXPLICIT_CONSENT') {
|
||||
additionalWarning = language === 'de'
|
||||
? `\n\n> **Wichtig:** Fuer die Verarbeitung dieser besonderen Kategorien personenbezogener Daten (Art. 9 DSGVO) ist eine separate, ausdrueckliche Einwilligung erforderlich, die Sie jederzeit widerrufen koennen.`
|
||||
: `\n\n> **Important:** Processing of these special categories of personal data (Art. 9 GDPR) requires a separate, explicit consent that you can withdraw at any time.`
|
||||
}
|
||||
|
||||
const dataLabel = language === 'de' ? 'Betroffene Daten' : 'Affected Data'
|
||||
const dataList = basisData
|
||||
.map((dp) => {
|
||||
const specialTag = dp.isSpecialCategory
|
||||
? (language === 'de' ? ' *(Art. 9 DSGVO)*' : ' *(Art. 9 GDPR)*')
|
||||
: ''
|
||||
return `- ${t(dp.name, language)}${specialTag}: ${t(dp.legalBasisJustification, language)}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
sections.push(`### ${basisTitle}\n\n${basisDesc}${additionalWarning}\n\n**${dataLabel}:**\n${dataList}`)
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf Grundlage folgender Rechtsgrundlagen:\n\n${sections.join('\n\n')}`,
|
||||
en: `The processing of your personal data is based on the following legal grounds:\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'legal-basis',
|
||||
order: 4,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 5: Empfaenger / Dritte
|
||||
*/
|
||||
function generateRecipientsSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '5. Empfaenger und Datenweitergabe',
|
||||
en: '5. Recipients and Data Sharing',
|
||||
}
|
||||
|
||||
const thirdParties = extractThirdParties(dataPoints)
|
||||
|
||||
if (thirdParties.length === 0) {
|
||||
const content: LocalizedText = {
|
||||
de: 'Wir geben Ihre personenbezogenen Daten grundsaetzlich nicht an Dritte weiter, es sei denn, dies ist zur Vertragserfuellung erforderlich oder Sie haben ausdruecklich eingewilligt.',
|
||||
en: 'We generally do not share your personal data with third parties unless this is necessary for contract performance or you have expressly consented.',
|
||||
}
|
||||
return {
|
||||
id: 'recipients',
|
||||
order: 5,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Gruppiere nach Drittanbieter
|
||||
const recipientDetails = new Map<string, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
for (const recipient of dp.thirdPartyRecipients) {
|
||||
const existing = recipientDetails.get(recipient) || []
|
||||
recipientDetails.set(recipient, [...existing, dp])
|
||||
}
|
||||
}
|
||||
|
||||
const recipientList = Array.from(recipientDetails.entries())
|
||||
.map(([recipient, dps]) => {
|
||||
const dataNames = dps.map((dp) => t(dp.name, language)).join(', ')
|
||||
return `- **${recipient}**: ${dataNames}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Wir uebermitteln Ihre personenbezogenen Daten an folgende Empfaenger bzw. Kategorien von Empfaengern:\n\n${recipientList}\n\nMit allen Auftragsverarbeitern haben wir Auftragsverarbeitungsvertraege nach Art. 28 DSGVO abgeschlossen.`,
|
||||
en: `We share your personal data with the following recipients or categories of recipients:\n\n${recipientList}\n\nWe have concluded data processing agreements pursuant to Art. 28 GDPR with all processors.`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'recipients',
|
||||
order: 5,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.filter((dp) => dp.thirdPartyRecipients.length > 0).map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 6: Speicherdauer
|
||||
*/
|
||||
function generateRetentionSection(
|
||||
dataPoints: DataPoint[],
|
||||
retentionMatrix: RetentionMatrixEntry[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '6. Speicherdauer',
|
||||
en: '6. Data Retention',
|
||||
}
|
||||
|
||||
const grouped = groupByCategory(dataPoints)
|
||||
const sections: string[] = []
|
||||
|
||||
for (const entry of retentionMatrix) {
|
||||
const categoryData = grouped.get(entry.category)
|
||||
if (!categoryData || categoryData.length === 0) continue
|
||||
|
||||
const categoryName = t(entry.categoryName, language)
|
||||
const standardPeriod = t(RETENTION_PERIOD_INFO[entry.standardPeriod].label, language)
|
||||
|
||||
const dataRetention = categoryData
|
||||
.map((dp) => {
|
||||
const period = t(RETENTION_PERIOD_INFO[dp.retentionPeriod].label, language)
|
||||
return `- ${t(dp.name, language)}: ${period}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
sections.push(`### ${categoryName}\n\n**Standardfrist:** ${standardPeriod}\n\n${dataRetention}`)
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Wir speichern Ihre personenbezogenen Daten nur so lange, wie dies fuer die jeweiligen Zwecke erforderlich ist oder gesetzliche Aufbewahrungsfristen bestehen.\n\n${sections.join('\n\n')}`,
|
||||
en: `We store your personal data only for as long as is necessary for the respective purposes or as required by statutory retention periods.\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'retention',
|
||||
order: 6,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: dataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 6a: Besondere Kategorien (Art. 9 DSGVO)
|
||||
* Wird nur generiert, wenn Art. 9 Daten vorhanden sind
|
||||
*/
|
||||
function generateSpecialCategoriesSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection | null {
|
||||
// Filtere Art. 9 Datenpunkte
|
||||
const specialCategoryDataPoints = dataPoints.filter(dp => dp.isSpecialCategory || dp.category === 'HEALTH_DATA')
|
||||
|
||||
if (specialCategoryDataPoints.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title: LocalizedText = {
|
||||
de: '6a. Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO)',
|
||||
en: '6a. Special Categories of Personal Data (Art. 9 GDPR)',
|
||||
}
|
||||
|
||||
const dataList = specialCategoryDataPoints
|
||||
.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.description, language)}`)
|
||||
.join('\n')
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Gemaess Art. 9 DSGVO verarbeiten wir auch besondere Kategorien personenbezogener Daten, die einen erhoehten Schutz geniessen. Diese Daten umfassen:
|
||||
|
||||
${dataList}
|
||||
|
||||
### Ihre ausdrueckliche Einwilligung
|
||||
|
||||
Die Verarbeitung dieser besonderen Kategorien personenbezogener Daten erfolgt nur auf Grundlage Ihrer **ausdruecklichen Einwilligung** gemaess Art. 9 Abs. 2 lit. a DSGVO.
|
||||
|
||||
### Ihre Rechte bei Art. 9 Daten
|
||||
|
||||
- Sie koennen Ihre Einwilligung **jederzeit widerrufen**
|
||||
- Der Widerruf beruehrt nicht die Rechtmaessigkeit der bisherigen Verarbeitung
|
||||
- Bei Widerruf werden Ihre Daten unverzueglich geloescht
|
||||
- Sie haben das Recht auf **Auskunft, Berichtigung und Loeschung**
|
||||
|
||||
### Besondere Schutzmassnahmen
|
||||
|
||||
Fuer diese sensiblen Daten haben wir besondere technische und organisatorische Massnahmen implementiert:
|
||||
- Ende-zu-Ende-Verschluesselung
|
||||
- Strenge Zugriffskontrolle (Need-to-Know-Prinzip)
|
||||
- Audit-Logging aller Zugriffe
|
||||
- Regelmaessige Datenschutz-Folgenabschaetzungen`,
|
||||
en: `In accordance with Art. 9 GDPR, we also process special categories of personal data that enjoy enhanced protection. This data includes:
|
||||
|
||||
${dataList}
|
||||
|
||||
### Your Explicit Consent
|
||||
|
||||
Processing of these special categories of personal data only takes place on the basis of your **explicit consent** pursuant to Art. 9(2)(a) GDPR.
|
||||
|
||||
### Your Rights Regarding Art. 9 Data
|
||||
|
||||
- You can **withdraw your consent at any time**
|
||||
- Withdrawal does not affect the lawfulness of previous processing
|
||||
- Upon withdrawal, your data will be deleted immediately
|
||||
- You have the right to **access, rectification, and erasure**
|
||||
|
||||
### Special Protection Measures
|
||||
|
||||
For this sensitive data, we have implemented special technical and organizational measures:
|
||||
- End-to-end encryption
|
||||
- Strict access control (need-to-know principle)
|
||||
- Audit logging of all access
|
||||
- Regular data protection impact assessments`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'special-categories',
|
||||
order: 6.5, // Zwischen Speicherdauer (6) und Rechte (7)
|
||||
title,
|
||||
content,
|
||||
dataPointIds: specialCategoryDataPoints.map((dp) => dp.id),
|
||||
isRequired: false,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 7: Betroffenenrechte
|
||||
*/
|
||||
function generateRightsSection(language: SupportedLanguage): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '7. Ihre Rechte als betroffene Person',
|
||||
en: '7. Your Rights as a Data Subject',
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Sie haben gegenueber uns folgende Rechte hinsichtlich der Sie betreffenden personenbezogenen Daten:
|
||||
|
||||
### Auskunftsrecht (Art. 15 DSGVO)
|
||||
Sie haben das Recht, Auskunft ueber die von uns verarbeiteten personenbezogenen Daten zu verlangen.
|
||||
|
||||
### Recht auf Berichtigung (Art. 16 DSGVO)
|
||||
Sie haben das Recht, die Berichtigung unrichtiger oder die Vervollstaendigung unvollstaendiger Daten zu verlangen.
|
||||
|
||||
### Recht auf Loeschung (Art. 17 DSGVO)
|
||||
Sie haben das Recht, die Loeschung Ihrer personenbezogenen Daten zu verlangen, sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
|
||||
|
||||
### Recht auf Einschraenkung der Verarbeitung (Art. 18 DSGVO)
|
||||
Sie haben das Recht, die Einschraenkung der Verarbeitung Ihrer Daten zu verlangen.
|
||||
|
||||
### Recht auf Datenuebertragbarkeit (Art. 20 DSGVO)
|
||||
Sie haben das Recht, Ihre Daten in einem strukturierten, gaengigen und maschinenlesbaren Format zu erhalten.
|
||||
|
||||
### Widerspruchsrecht (Art. 21 DSGVO)
|
||||
Sie haben das Recht, der Verarbeitung Ihrer Daten jederzeit zu widersprechen, soweit die Verarbeitung auf berechtigtem Interesse beruht.
|
||||
|
||||
### Recht auf Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO)
|
||||
Sie haben das Recht, Ihre erteilte Einwilligung jederzeit zu widerrufen. Die Rechtmaessigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung wird dadurch nicht beruehrt.
|
||||
|
||||
### Beschwerderecht bei der Aufsichtsbehoerde (Art. 77 DSGVO)
|
||||
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehoerde ueber die Verarbeitung Ihrer personenbezogenen Daten zu beschweren.
|
||||
|
||||
**Zur Ausuebung Ihrer Rechte wenden Sie sich bitte an die oben angegebenen Kontaktdaten.**`,
|
||||
en: `You have the following rights regarding your personal data:
|
||||
|
||||
### Right of Access (Art. 15 GDPR)
|
||||
You have the right to request information about the personal data we process about you.
|
||||
|
||||
### Right to Rectification (Art. 16 GDPR)
|
||||
You have the right to request the correction of inaccurate data or the completion of incomplete data.
|
||||
|
||||
### Right to Erasure (Art. 17 GDPR)
|
||||
You have the right to request the deletion of your personal data, unless statutory retention obligations apply.
|
||||
|
||||
### Right to Restriction of Processing (Art. 18 GDPR)
|
||||
You have the right to request the restriction of processing of your data.
|
||||
|
||||
### Right to Data Portability (Art. 20 GDPR)
|
||||
You have the right to receive your data in a structured, commonly used, and machine-readable format.
|
||||
|
||||
### Right to Object (Art. 21 GDPR)
|
||||
You have the right to object to the processing of your data at any time, insofar as the processing is based on legitimate interest.
|
||||
|
||||
### Right to Withdraw Consent (Art. 7(3) GDPR)
|
||||
You have the right to withdraw your consent at any time. The lawfulness of processing based on consent before its withdrawal is not affected.
|
||||
|
||||
### Right to Lodge a Complaint with a Supervisory Authority (Art. 77 GDPR)
|
||||
You have the right to lodge a complaint with a data protection supervisory authority about the processing of your personal data.
|
||||
|
||||
**To exercise your rights, please contact us using the contact details provided above.**`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'rights',
|
||||
order: 7,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 8: Cookies
|
||||
*/
|
||||
function generateCookiesSection(
|
||||
dataPoints: DataPoint[],
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '8. Cookies und aehnliche Technologien',
|
||||
en: '8. Cookies and Similar Technologies',
|
||||
}
|
||||
|
||||
// Filtere Datenpunkte mit Cookie-Kategorie
|
||||
const cookieDataPoints = dataPoints.filter((dp) => dp.cookieCategory !== null)
|
||||
|
||||
if (cookieDataPoints.length === 0) {
|
||||
const content: LocalizedText = {
|
||||
de: 'Wir verwenden auf dieser Website keine Cookies.',
|
||||
en: 'We do not use cookies on this website.',
|
||||
}
|
||||
return {
|
||||
id: 'cookies',
|
||||
order: 8,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: false,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Gruppiere nach Cookie-Kategorie
|
||||
const essential = cookieDataPoints.filter((dp) => dp.cookieCategory === 'ESSENTIAL')
|
||||
const performance = cookieDataPoints.filter((dp) => dp.cookieCategory === 'PERFORMANCE')
|
||||
const personalization = cookieDataPoints.filter((dp) => dp.cookieCategory === 'PERSONALIZATION')
|
||||
const externalMedia = cookieDataPoints.filter((dp) => dp.cookieCategory === 'EXTERNAL_MEDIA')
|
||||
|
||||
const sections: string[] = []
|
||||
|
||||
if (essential.length > 0) {
|
||||
const list = essential.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Technisch notwendige Cookies\n\nDiese Cookies sind fuer den Betrieb der Website erforderlich und koennen nicht deaktiviert werden.\n\n${list}`
|
||||
: `### Essential Cookies\n\nThese cookies are required for the website to function and cannot be disabled.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
if (performance.length > 0) {
|
||||
const list = performance.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Analyse- und Performance-Cookies\n\nDiese Cookies helfen uns, die Nutzung der Website zu verstehen und zu verbessern.\n\n${list}`
|
||||
: `### Analytics and Performance Cookies\n\nThese cookies help us understand and improve website usage.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
if (personalization.length > 0) {
|
||||
const list = personalization.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Personalisierungs-Cookies\n\nDiese Cookies ermoeglichen personalisierte Werbung und Inhalte.\n\n${list}`
|
||||
: `### Personalization Cookies\n\nThese cookies enable personalized advertising and content.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
if (externalMedia.length > 0) {
|
||||
const list = externalMedia.map((dp) => `- **${t(dp.name, language)}**: ${t(dp.purpose, language)}`).join('\n')
|
||||
sections.push(
|
||||
language === 'de'
|
||||
? `### Cookies fuer externe Medien\n\nDiese Cookies erlauben die Einbindung externer Medien wie Videos und Karten.\n\n${list}`
|
||||
: `### External Media Cookies\n\nThese cookies allow embedding external media like videos and maps.\n\n${list}`
|
||||
)
|
||||
}
|
||||
|
||||
const intro: LocalizedText = {
|
||||
de: `Wir verwenden Cookies und aehnliche Technologien, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen. Sie koennen Ihre Cookie-Einstellungen jederzeit ueber unseren Cookie-Banner anpassen.`,
|
||||
en: `We use cookies and similar technologies to provide you with the best possible experience on our website. You can adjust your cookie settings at any time through our cookie banner.`,
|
||||
}
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `${intro.de}\n\n${sections.join('\n\n')}`,
|
||||
en: `${intro.en}\n\n${sections.join('\n\n')}`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'cookies',
|
||||
order: 8,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: cookieDataPoints.map((dp) => dp.id),
|
||||
isRequired: true,
|
||||
isGenerated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt 9: Aenderungen
|
||||
*/
|
||||
function generateChangesSection(
|
||||
version: string,
|
||||
date: Date,
|
||||
language: SupportedLanguage
|
||||
): PrivacyPolicySection {
|
||||
const title: LocalizedText = {
|
||||
de: '9. Aenderungen dieser Datenschutzerklaerung',
|
||||
en: '9. Changes to this Privacy Policy',
|
||||
}
|
||||
|
||||
const formattedDate = formatDate(date, language)
|
||||
|
||||
const content: LocalizedText = {
|
||||
de: `Diese Datenschutzerklaerung ist aktuell gueltig und hat den Stand: **${formattedDate}** (Version ${version}).
|
||||
|
||||
Wir behalten uns vor, diese Datenschutzerklaerung anzupassen, damit sie stets den aktuellen rechtlichen Anforderungen entspricht oder um Aenderungen unserer Leistungen umzusetzen.
|
||||
|
||||
Fuer Ihren erneuten Besuch gilt dann die neue Datenschutzerklaerung.`,
|
||||
en: `This privacy policy is currently valid and was last updated: **${formattedDate}** (Version ${version}).
|
||||
|
||||
We reserve the right to amend this privacy policy to ensure it always complies with current legal requirements or to implement changes to our services.
|
||||
|
||||
The new privacy policy will then apply for your next visit.`,
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'changes',
|
||||
order: 9,
|
||||
title,
|
||||
content,
|
||||
dataPointIds: [],
|
||||
isRequired: true,
|
||||
isGenerated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN GENERATOR FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generiert alle Abschnitte der Privacy Policy (18 Kategorien + Art. 9)
|
||||
*/
|
||||
export function generatePrivacyPolicySections(
|
||||
dataPoints: DataPoint[],
|
||||
companyInfo: CompanyInfo,
|
||||
language: SupportedLanguage,
|
||||
version: string = '1.0.0'
|
||||
): PrivacyPolicySection[] {
|
||||
const now = new Date()
|
||||
|
||||
const sections: PrivacyPolicySection[] = [
|
||||
generateControllerSection(companyInfo, language),
|
||||
generateDataCollectionSection(dataPoints, language),
|
||||
generatePurposesSection(dataPoints, language),
|
||||
generateLegalBasisSection(dataPoints, language),
|
||||
generateRecipientsSection(dataPoints, language),
|
||||
generateRetentionSection(dataPoints, RETENTION_MATRIX, language),
|
||||
]
|
||||
|
||||
// Art. 9 DSGVO Abschnitt nur einfügen, wenn besondere Kategorien vorhanden
|
||||
const specialCategoriesSection = generateSpecialCategoriesSection(dataPoints, language)
|
||||
if (specialCategoriesSection) {
|
||||
sections.push(specialCategoriesSection)
|
||||
}
|
||||
|
||||
sections.push(
|
||||
generateRightsSection(language),
|
||||
generateCookiesSection(dataPoints, language),
|
||||
generateChangesSection(version, now, language)
|
||||
)
|
||||
|
||||
// Abschnittsnummern neu vergeben
|
||||
sections.forEach((section, index) => {
|
||||
section.order = index + 1
|
||||
// Titel-Nummer aktualisieren
|
||||
const titleDe = section.title.de
|
||||
const titleEn = section.title.en
|
||||
if (titleDe.match(/^\d+[a-z]?\./)) {
|
||||
section.title.de = titleDe.replace(/^\d+[a-z]?\./, `${index + 1}.`)
|
||||
}
|
||||
if (titleEn.match(/^\d+[a-z]?\./)) {
|
||||
section.title.en = titleEn.replace(/^\d+[a-z]?\./, `${index + 1}.`)
|
||||
}
|
||||
})
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert die vollstaendige Privacy Policy
|
||||
*/
|
||||
export function generatePrivacyPolicy(
|
||||
tenantId: string,
|
||||
dataPoints: DataPoint[],
|
||||
companyInfo: CompanyInfo,
|
||||
language: SupportedLanguage,
|
||||
format: ExportFormat = 'HTML'
|
||||
): GeneratedPrivacyPolicy {
|
||||
const version = '1.0.0'
|
||||
const sections = generatePrivacyPolicySections(dataPoints, companyInfo, language, version)
|
||||
|
||||
// Generiere den Inhalt
|
||||
const content = renderPrivacyPolicy(sections, language, format)
|
||||
|
||||
return {
|
||||
id: `privacy-policy-${tenantId}-${Date.now()}`,
|
||||
tenantId,
|
||||
language,
|
||||
sections,
|
||||
companyInfo,
|
||||
generatedAt: new Date(),
|
||||
version,
|
||||
format,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Privacy Policy im gewuenschten Format
|
||||
*/
|
||||
function renderPrivacyPolicy(
|
||||
sections: PrivacyPolicySection[],
|
||||
language: SupportedLanguage,
|
||||
format: ExportFormat
|
||||
): string {
|
||||
switch (format) {
|
||||
case 'HTML':
|
||||
return renderAsHTML(sections, language)
|
||||
case 'MARKDOWN':
|
||||
return renderAsMarkdown(sections, language)
|
||||
default:
|
||||
return renderAsMarkdown(sections, language)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert als HTML
|
||||
*/
|
||||
function renderAsHTML(sections: PrivacyPolicySection[], language: SupportedLanguage): string {
|
||||
const title = language === 'de' ? 'Datenschutzerklaerung' : 'Privacy Policy'
|
||||
|
||||
const sectionsHTML = sections
|
||||
.map((section) => {
|
||||
const content = t(section.content, language)
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/### (.+)/g, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/- (.+)(?:<br>|$)/g, '<li>$1</li>')
|
||||
|
||||
return `
|
||||
<section id="${section.id}">
|
||||
<h2>${t(section.title, language)}</h2>
|
||||
<p>${content}</p>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
h1 { font-size: 2rem; margin-bottom: 2rem; }
|
||||
h2 { font-size: 1.5rem; margin-top: 2rem; color: #1a1a1a; }
|
||||
h3 { font-size: 1.25rem; margin-top: 1.5rem; color: #333; }
|
||||
p { margin: 1rem 0; }
|
||||
ul, ol { margin: 1rem 0; padding-left: 2rem; }
|
||||
li { margin: 0.5rem 0; }
|
||||
strong { font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${title}</h1>
|
||||
${sectionsHTML}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert als Markdown
|
||||
*/
|
||||
function renderAsMarkdown(sections: PrivacyPolicySection[], language: SupportedLanguage): string {
|
||||
const title = language === 'de' ? 'Datenschutzerklaerung' : 'Privacy Policy'
|
||||
|
||||
const sectionsMarkdown = sections
|
||||
.map((section) => {
|
||||
return `## ${t(section.title, language)}\n\n${t(section.content, language)}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
return `# ${title}\n\n${sectionsMarkdown}`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
generateControllerSection,
|
||||
generateDataCollectionSection,
|
||||
@@ -958,8 +15,13 @@ export {
|
||||
generateRetentionSection,
|
||||
generateSpecialCategoriesSection,
|
||||
generateRightsSection,
|
||||
} from './privacy-policy-sections'
|
||||
|
||||
export {
|
||||
generateCookiesSection,
|
||||
generateChangesSection,
|
||||
generatePrivacyPolicySections,
|
||||
generatePrivacyPolicy,
|
||||
renderAsHTML,
|
||||
renderAsMarkdown,
|
||||
}
|
||||
} from './privacy-policy-renderers'
|
||||
|
||||
171
admin-compliance/lib/sdk/tom-generator/gap-analysis.ts
Normal file
171
admin-compliance/lib/sdk/tom-generator/gap-analysis.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* TOM Rules Engine — Gap Analysis & Helper Functions
|
||||
*
|
||||
* Singleton instance, convenience functions, and gap analysis logic.
|
||||
*/
|
||||
|
||||
import {
|
||||
DerivedTOM,
|
||||
EvidenceDocument,
|
||||
GapAnalysisResult,
|
||||
MissingControl,
|
||||
PartialControl,
|
||||
MissingEvidence,
|
||||
RulesEngineResult,
|
||||
RulesEngineEvaluationContext,
|
||||
} from './types'
|
||||
import { getControlById } from './controls/loader'
|
||||
import { TOMRulesEngine } from './rules-evaluator'
|
||||
|
||||
// =============================================================================
|
||||
// SINGLETON INSTANCE
|
||||
// =============================================================================
|
||||
|
||||
let rulesEngineInstance: TOMRulesEngine | null = null
|
||||
|
||||
export function getTOMRulesEngine(): TOMRulesEngine {
|
||||
if (!rulesEngineInstance) {
|
||||
rulesEngineInstance = new TOMRulesEngine()
|
||||
}
|
||||
return rulesEngineInstance
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GAP ANALYSIS
|
||||
// =============================================================================
|
||||
|
||||
export function performGapAnalysis(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
documents: EvidenceDocument[]
|
||||
): GapAnalysisResult {
|
||||
const missingControls: MissingControl[] = []
|
||||
const partialControls: PartialControl[] = []
|
||||
const missingEvidence: MissingEvidence[] = []
|
||||
const recommendations: string[] = []
|
||||
|
||||
let totalScore = 0
|
||||
let totalWeight = 0
|
||||
|
||||
const applicableTOMs = derivedTOMs.filter(
|
||||
(tom) => tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
|
||||
for (const tom of applicableTOMs) {
|
||||
const control = getControlById(tom.controlId)
|
||||
if (!control) continue
|
||||
|
||||
const weight = tom.applicability === 'REQUIRED' ? 3 : 1
|
||||
totalWeight += weight
|
||||
|
||||
if (tom.implementationStatus === 'NOT_IMPLEMENTED') {
|
||||
missingControls.push({
|
||||
controlId: tom.controlId,
|
||||
reason: `${control.name.de} ist nicht implementiert`,
|
||||
priority: control.priority,
|
||||
})
|
||||
} else if (tom.implementationStatus === 'PARTIAL') {
|
||||
partialControls.push({
|
||||
controlId: tom.controlId,
|
||||
missingAspects: tom.evidenceGaps,
|
||||
})
|
||||
totalScore += weight * 0.5
|
||||
} else {
|
||||
totalScore += weight
|
||||
}
|
||||
|
||||
const linkedEvidenceIds = tom.linkedEvidence
|
||||
const requiredEvidence = control.evidenceRequirements
|
||||
const providedEvidence = documents.filter((doc) =>
|
||||
linkedEvidenceIds.includes(doc.id)
|
||||
)
|
||||
|
||||
if (providedEvidence.length < requiredEvidence.length) {
|
||||
const missing = requiredEvidence.filter(
|
||||
(req) =>
|
||||
!providedEvidence.some(
|
||||
(doc) =>
|
||||
doc.documentType === 'POLICY' ||
|
||||
doc.documentType === 'CERTIFICATE' ||
|
||||
doc.originalName.toLowerCase().includes(req.toLowerCase())
|
||||
)
|
||||
)
|
||||
|
||||
if (missing.length > 0) {
|
||||
missingEvidence.push({
|
||||
controlId: tom.controlId,
|
||||
requiredEvidence: missing,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overallScore =
|
||||
totalWeight > 0 ? Math.round((totalScore / totalWeight) * 100) : 0
|
||||
|
||||
if (missingControls.length > 0) {
|
||||
const criticalMissing = missingControls.filter((mc) => mc.priority === 'CRITICAL')
|
||||
if (criticalMissing.length > 0) {
|
||||
recommendations.push(
|
||||
`${criticalMissing.length} kritische Kontrollen sind nicht implementiert. Diese sollten priorisiert werden.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (partialControls.length > 0) {
|
||||
recommendations.push(
|
||||
`${partialControls.length} Kontrollen sind nur teilweise implementiert. Vervollstaendigen Sie die Implementierung.`
|
||||
)
|
||||
}
|
||||
|
||||
if (missingEvidence.length > 0) {
|
||||
recommendations.push(
|
||||
`Fuer ${missingEvidence.length} Kontrollen fehlen Nachweisdokumente. Laden Sie die entsprechenden Dokumente hoch.`
|
||||
)
|
||||
}
|
||||
|
||||
if (overallScore >= 80) {
|
||||
recommendations.push(
|
||||
'Ihr TOM-Compliance-Score ist gut. Fuehren Sie regelmaessige Ueberpruefungen durch.'
|
||||
)
|
||||
} else if (overallScore >= 50) {
|
||||
recommendations.push(
|
||||
'Ihr TOM-Compliance-Score erfordert Verbesserungen. Fokussieren Sie sich auf die kritischen Luecken.'
|
||||
)
|
||||
} else {
|
||||
recommendations.push(
|
||||
'Ihr TOM-Compliance-Score ist niedrig. Eine systematische Ueberarbeitung der Massnahmen wird empfohlen.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
overallScore,
|
||||
missingControls,
|
||||
partialControls,
|
||||
missingEvidence,
|
||||
recommendations,
|
||||
generatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function evaluateControlsForContext(
|
||||
context: RulesEngineEvaluationContext
|
||||
): RulesEngineResult[] {
|
||||
return getTOMRulesEngine().evaluateControls(context)
|
||||
}
|
||||
|
||||
export function deriveTOMsForContext(
|
||||
context: RulesEngineEvaluationContext
|
||||
): DerivedTOM[] {
|
||||
return getTOMRulesEngine().deriveAllTOMs(context)
|
||||
}
|
||||
|
||||
export function performQuickGapAnalysis(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
documents: EvidenceDocument[]
|
||||
): GapAnalysisResult {
|
||||
return performGapAnalysis(derivedTOMs, documents)
|
||||
}
|
||||
@@ -1,560 +1,17 @@
|
||||
// =============================================================================
|
||||
// TOM Rules Engine
|
||||
// Evaluates control applicability based on company context
|
||||
// =============================================================================
|
||||
|
||||
import {
|
||||
ControlLibraryEntry,
|
||||
ApplicabilityCondition,
|
||||
ControlApplicability,
|
||||
RulesEngineResult,
|
||||
RulesEngineEvaluationContext,
|
||||
DerivedTOM,
|
||||
EvidenceDocument,
|
||||
GapAnalysisResult,
|
||||
MissingControl,
|
||||
PartialControl,
|
||||
MissingEvidence,
|
||||
ConditionOperator,
|
||||
} from './types'
|
||||
import { getAllControls, getControlById } from './controls/loader'
|
||||
|
||||
// =============================================================================
|
||||
// RULES ENGINE CLASS
|
||||
// =============================================================================
|
||||
|
||||
export class TOMRulesEngine {
|
||||
private controls: ControlLibraryEntry[]
|
||||
|
||||
constructor() {
|
||||
this.controls = getAllControls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all controls against the current context
|
||||
*/
|
||||
evaluateControls(context: RulesEngineEvaluationContext): RulesEngineResult[] {
|
||||
return this.controls.map((control) => this.evaluateControl(control, context))
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single control against the context
|
||||
*/
|
||||
evaluateControl(
|
||||
control: ControlLibraryEntry,
|
||||
context: RulesEngineEvaluationContext
|
||||
): RulesEngineResult {
|
||||
// Sort conditions by priority (highest first)
|
||||
const sortedConditions = [...control.applicabilityConditions].sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
)
|
||||
|
||||
// Evaluate conditions in priority order
|
||||
for (const condition of sortedConditions) {
|
||||
const matches = this.evaluateCondition(condition, context)
|
||||
if (matches) {
|
||||
return {
|
||||
controlId: control.id,
|
||||
applicability: condition.result,
|
||||
reason: this.formatConditionReason(condition, context),
|
||||
matchedCondition: condition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No condition matched, use default applicability
|
||||
return {
|
||||
controlId: control.id,
|
||||
applicability: control.defaultApplicability,
|
||||
reason: 'Standard-Anwendbarkeit (keine spezifische Bedingung erfüllt)',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single condition
|
||||
*/
|
||||
private evaluateCondition(
|
||||
condition: ApplicabilityCondition,
|
||||
context: RulesEngineEvaluationContext
|
||||
): boolean {
|
||||
const value = this.getFieldValue(condition.field, context)
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.evaluateOperator(condition.operator, value, condition.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested field value from the context
|
||||
*/
|
||||
private getFieldValue(
|
||||
fieldPath: string,
|
||||
context: RulesEngineEvaluationContext
|
||||
): unknown {
|
||||
const parts = fieldPath.split('.')
|
||||
let current: unknown = context
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof current === 'object') {
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an operator with given values
|
||||
*/
|
||||
private evaluateOperator(
|
||||
operator: ConditionOperator,
|
||||
actualValue: unknown,
|
||||
expectedValue: unknown
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'EQUALS':
|
||||
return actualValue === expectedValue
|
||||
|
||||
case 'NOT_EQUALS':
|
||||
return actualValue !== expectedValue
|
||||
|
||||
case 'CONTAINS':
|
||||
if (Array.isArray(actualValue)) {
|
||||
return actualValue.includes(expectedValue)
|
||||
}
|
||||
if (typeof actualValue === 'string' && typeof expectedValue === 'string') {
|
||||
return actualValue.includes(expectedValue)
|
||||
}
|
||||
return false
|
||||
|
||||
case 'GREATER_THAN':
|
||||
if (typeof actualValue === 'number' && typeof expectedValue === 'number') {
|
||||
return actualValue > expectedValue
|
||||
}
|
||||
return false
|
||||
|
||||
case 'IN':
|
||||
if (Array.isArray(expectedValue)) {
|
||||
return expectedValue.includes(actualValue)
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a human-readable reason for the condition match
|
||||
*/
|
||||
private formatConditionReason(
|
||||
condition: ApplicabilityCondition,
|
||||
context: RulesEngineEvaluationContext
|
||||
): string {
|
||||
const fieldValue = this.getFieldValue(condition.field, context)
|
||||
const fieldLabel = this.getFieldLabel(condition.field)
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'EQUALS':
|
||||
return `${fieldLabel} ist "${this.formatValue(fieldValue)}"`
|
||||
|
||||
case 'NOT_EQUALS':
|
||||
return `${fieldLabel} ist nicht "${this.formatValue(condition.value)}"`
|
||||
|
||||
case 'CONTAINS':
|
||||
return `${fieldLabel} enthält "${this.formatValue(condition.value)}"`
|
||||
|
||||
case 'GREATER_THAN':
|
||||
return `${fieldLabel} ist größer als ${this.formatValue(condition.value)}`
|
||||
|
||||
case 'IN':
|
||||
return `${fieldLabel} ("${this.formatValue(fieldValue)}") ist in [${Array.isArray(condition.value) ? condition.value.join(', ') : condition.value}]`
|
||||
|
||||
default:
|
||||
return `Bedingung erfüllt: ${condition.field} ${condition.operator} ${this.formatValue(condition.value)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for a field path
|
||||
*/
|
||||
private getFieldLabel(fieldPath: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'companyProfile.role': 'Unternehmensrolle',
|
||||
'companyProfile.size': 'Unternehmensgröße',
|
||||
'dataProfile.hasSpecialCategories': 'Besondere Datenkategorien',
|
||||
'dataProfile.processesMinors': 'Verarbeitung von Minderjährigen-Daten',
|
||||
'dataProfile.dataVolume': 'Datenvolumen',
|
||||
'dataProfile.thirdCountryTransfers': 'Drittlandübermittlungen',
|
||||
'architectureProfile.hostingModel': 'Hosting-Modell',
|
||||
'architectureProfile.hostingLocation': 'Hosting-Standort',
|
||||
'architectureProfile.multiTenancy': 'Mandantentrennung',
|
||||
'architectureProfile.hasSubprocessors': 'Unterauftragsverarbeiter',
|
||||
'architectureProfile.encryptionAtRest': 'Verschlüsselung ruhender Daten',
|
||||
'securityProfile.hasMFA': 'Multi-Faktor-Authentifizierung',
|
||||
'securityProfile.hasSSO': 'Single Sign-On',
|
||||
'securityProfile.hasPAM': 'Privileged Access Management',
|
||||
'riskProfile.protectionLevel': 'Schutzbedarf',
|
||||
'riskProfile.dsfaRequired': 'DSFA erforderlich',
|
||||
'riskProfile.ciaAssessment.confidentiality': 'Vertraulichkeit',
|
||||
'riskProfile.ciaAssessment.integrity': 'Integrität',
|
||||
'riskProfile.ciaAssessment.availability': 'Verfügbarkeit',
|
||||
}
|
||||
|
||||
return labels[fieldPath] || fieldPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display
|
||||
*/
|
||||
private formatValue(value: unknown): string {
|
||||
if (value === true) return 'Ja'
|
||||
if (value === false) return 'Nein'
|
||||
if (value === null || value === undefined) return 'nicht gesetzt'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive all TOMs based on the current context
|
||||
*/
|
||||
deriveAllTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
const results = this.evaluateControls(context)
|
||||
|
||||
return results.map((result) => {
|
||||
const control = getControlById(result.controlId)
|
||||
if (!control) {
|
||||
throw new Error(`Control not found: ${result.controlId}`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: `derived-${result.controlId}`,
|
||||
controlId: result.controlId,
|
||||
name: control.name.de,
|
||||
description: control.description.de,
|
||||
applicability: result.applicability,
|
||||
applicabilityReason: result.reason,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
responsiblePerson: null,
|
||||
responsibleDepartment: null,
|
||||
implementationDate: null,
|
||||
reviewDate: null,
|
||||
linkedEvidence: [],
|
||||
evidenceGaps: [...control.evidenceRequirements],
|
||||
aiGeneratedDescription: null,
|
||||
aiRecommendations: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only required and recommended TOMs
|
||||
*/
|
||||
getApplicableTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
const allTOMs = this.deriveAllTOMs(context)
|
||||
return allTOMs.filter(
|
||||
(tom) =>
|
||||
tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only required TOMs
|
||||
*/
|
||||
getRequiredTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
const allTOMs = this.deriveAllTOMs(context)
|
||||
return allTOMs.filter((tom) => tom.applicability === 'REQUIRED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform gap analysis on derived TOMs and evidence
|
||||
*/
|
||||
performGapAnalysis(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
documents: EvidenceDocument[]
|
||||
): GapAnalysisResult {
|
||||
const missingControls: MissingControl[] = []
|
||||
const partialControls: PartialControl[] = []
|
||||
const missingEvidence: MissingEvidence[] = []
|
||||
const recommendations: string[] = []
|
||||
|
||||
let totalScore = 0
|
||||
let totalWeight = 0
|
||||
|
||||
// Analyze each required/recommended TOM
|
||||
const applicableTOMs = derivedTOMs.filter(
|
||||
(tom) =>
|
||||
tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
|
||||
for (const tom of applicableTOMs) {
|
||||
const control = getControlById(tom.controlId)
|
||||
if (!control) continue
|
||||
|
||||
const weight = tom.applicability === 'REQUIRED' ? 3 : 1
|
||||
totalWeight += weight
|
||||
|
||||
// Check implementation status
|
||||
if (tom.implementationStatus === 'NOT_IMPLEMENTED') {
|
||||
missingControls.push({
|
||||
controlId: tom.controlId,
|
||||
reason: `${control.name.de} ist nicht implementiert`,
|
||||
priority: control.priority,
|
||||
})
|
||||
// Score: 0 for not implemented
|
||||
} else if (tom.implementationStatus === 'PARTIAL') {
|
||||
partialControls.push({
|
||||
controlId: tom.controlId,
|
||||
missingAspects: tom.evidenceGaps,
|
||||
})
|
||||
// Score: 50% for partial
|
||||
totalScore += weight * 0.5
|
||||
} else {
|
||||
// Fully implemented
|
||||
totalScore += weight
|
||||
}
|
||||
|
||||
// Check evidence
|
||||
const linkedEvidenceIds = tom.linkedEvidence
|
||||
const requiredEvidence = control.evidenceRequirements
|
||||
const providedEvidence = documents.filter((doc) =>
|
||||
linkedEvidenceIds.includes(doc.id)
|
||||
)
|
||||
|
||||
if (providedEvidence.length < requiredEvidence.length) {
|
||||
const missing = requiredEvidence.filter(
|
||||
(req) =>
|
||||
!providedEvidence.some(
|
||||
(doc) =>
|
||||
doc.documentType === 'POLICY' ||
|
||||
doc.documentType === 'CERTIFICATE' ||
|
||||
doc.originalName.toLowerCase().includes(req.toLowerCase())
|
||||
)
|
||||
)
|
||||
|
||||
if (missing.length > 0) {
|
||||
missingEvidence.push({
|
||||
controlId: tom.controlId,
|
||||
requiredEvidence: missing,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall score as percentage
|
||||
const overallScore =
|
||||
totalWeight > 0 ? Math.round((totalScore / totalWeight) * 100) : 0
|
||||
|
||||
// Generate recommendations
|
||||
if (missingControls.length > 0) {
|
||||
const criticalMissing = missingControls.filter(
|
||||
(mc) => mc.priority === 'CRITICAL'
|
||||
)
|
||||
if (criticalMissing.length > 0) {
|
||||
recommendations.push(
|
||||
`${criticalMissing.length} kritische Kontrollen sind nicht implementiert. Diese sollten priorisiert werden.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (partialControls.length > 0) {
|
||||
recommendations.push(
|
||||
`${partialControls.length} Kontrollen sind nur teilweise implementiert. Vervollständigen Sie die Implementierung.`
|
||||
)
|
||||
}
|
||||
|
||||
if (missingEvidence.length > 0) {
|
||||
recommendations.push(
|
||||
`Für ${missingEvidence.length} Kontrollen fehlen Nachweisdokumente. Laden Sie die entsprechenden Dokumente hoch.`
|
||||
)
|
||||
}
|
||||
|
||||
if (overallScore >= 80) {
|
||||
recommendations.push(
|
||||
'Ihr TOM-Compliance-Score ist gut. Führen Sie regelmäßige Überprüfungen durch.'
|
||||
)
|
||||
} else if (overallScore >= 50) {
|
||||
recommendations.push(
|
||||
'Ihr TOM-Compliance-Score erfordert Verbesserungen. Fokussieren Sie sich auf die kritischen Lücken.'
|
||||
)
|
||||
} else {
|
||||
recommendations.push(
|
||||
'Ihr TOM-Compliance-Score ist niedrig. Eine systematische Überarbeitung der Maßnahmen wird empfohlen.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
overallScore,
|
||||
missingControls,
|
||||
partialControls,
|
||||
missingEvidence,
|
||||
recommendations,
|
||||
generatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controls by applicability level
|
||||
*/
|
||||
getControlsByApplicability(
|
||||
context: RulesEngineEvaluationContext,
|
||||
applicability: ControlApplicability
|
||||
): ControlLibraryEntry[] {
|
||||
const results = this.evaluateControls(context)
|
||||
return results
|
||||
.filter((r) => r.applicability === applicability)
|
||||
.map((r) => getControlById(r.controlId))
|
||||
.filter((c): c is ControlLibraryEntry => c !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for the evaluation
|
||||
*/
|
||||
getSummaryStatistics(context: RulesEngineEvaluationContext): {
|
||||
total: number
|
||||
required: number
|
||||
recommended: number
|
||||
optional: number
|
||||
notApplicable: number
|
||||
byCategory: Map<string, { required: number; recommended: number }>
|
||||
} {
|
||||
const results = this.evaluateControls(context)
|
||||
|
||||
const stats = {
|
||||
total: results.length,
|
||||
required: 0,
|
||||
recommended: 0,
|
||||
optional: 0,
|
||||
notApplicable: 0,
|
||||
byCategory: new Map<string, { required: number; recommended: number }>(),
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
switch (result.applicability) {
|
||||
case 'REQUIRED':
|
||||
stats.required++
|
||||
break
|
||||
case 'RECOMMENDED':
|
||||
stats.recommended++
|
||||
break
|
||||
case 'OPTIONAL':
|
||||
stats.optional++
|
||||
break
|
||||
case 'NOT_APPLICABLE':
|
||||
stats.notApplicable++
|
||||
break
|
||||
}
|
||||
|
||||
// Count by category
|
||||
const control = getControlById(result.controlId)
|
||||
if (control) {
|
||||
const category = control.category
|
||||
const existing = stats.byCategory.get(category) || {
|
||||
required: 0,
|
||||
recommended: 0,
|
||||
}
|
||||
|
||||
if (result.applicability === 'REQUIRED') {
|
||||
existing.required++
|
||||
} else if (result.applicability === 'RECOMMENDED') {
|
||||
existing.recommended++
|
||||
}
|
||||
|
||||
stats.byCategory.set(category, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific control is applicable
|
||||
*/
|
||||
isControlApplicable(
|
||||
controlId: string,
|
||||
context: RulesEngineEvaluationContext
|
||||
): boolean {
|
||||
const control = getControlById(controlId)
|
||||
if (!control) return false
|
||||
|
||||
const result = this.evaluateControl(control, context)
|
||||
return (
|
||||
result.applicability === 'REQUIRED' ||
|
||||
result.applicability === 'RECOMMENDED'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all controls that match a specific tag
|
||||
*/
|
||||
getControlsByTagWithApplicability(
|
||||
tag: string,
|
||||
context: RulesEngineEvaluationContext
|
||||
): Array<{ control: ControlLibraryEntry; result: RulesEngineResult }> {
|
||||
return this.controls
|
||||
.filter((control) => control.tags.includes(tag))
|
||||
.map((control) => ({
|
||||
control,
|
||||
result: this.evaluateControl(control, context),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload controls (useful if the control library is updated)
|
||||
*/
|
||||
reloadControls(): void {
|
||||
this.controls = getAllControls()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SINGLETON INSTANCE
|
||||
// =============================================================================
|
||||
|
||||
let rulesEngineInstance: TOMRulesEngine | null = null
|
||||
|
||||
export function getTOMRulesEngine(): TOMRulesEngine {
|
||||
if (!rulesEngineInstance) {
|
||||
rulesEngineInstance = new TOMRulesEngine()
|
||||
}
|
||||
return rulesEngineInstance
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Quick evaluation of controls for a context
|
||||
* TOM Rules Engine — barrel re-export
|
||||
*
|
||||
* Split into:
|
||||
* - rules-evaluator.ts (TOMRulesEngine class with condition evaluation)
|
||||
* - gap-analysis.ts (gap analysis, singleton, helper functions)
|
||||
*/
|
||||
export function evaluateControlsForContext(
|
||||
context: RulesEngineEvaluationContext
|
||||
): RulesEngineResult[] {
|
||||
return getTOMRulesEngine().evaluateControls(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick derivation of TOMs for a context
|
||||
*/
|
||||
export function deriveTOMsForContext(
|
||||
context: RulesEngineEvaluationContext
|
||||
): DerivedTOM[] {
|
||||
return getTOMRulesEngine().deriveAllTOMs(context)
|
||||
}
|
||||
export { TOMRulesEngine } from './rules-evaluator'
|
||||
|
||||
/**
|
||||
* Quick gap analysis
|
||||
*/
|
||||
export function performQuickGapAnalysis(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
documents: EvidenceDocument[]
|
||||
): GapAnalysisResult {
|
||||
return getTOMRulesEngine().performGapAnalysis(derivedTOMs, documents)
|
||||
}
|
||||
export {
|
||||
getTOMRulesEngine,
|
||||
performGapAnalysis,
|
||||
evaluateControlsForContext,
|
||||
deriveTOMsForContext,
|
||||
performQuickGapAnalysis,
|
||||
} from './gap-analysis'
|
||||
|
||||
276
admin-compliance/lib/sdk/tom-generator/rules-evaluator.ts
Normal file
276
admin-compliance/lib/sdk/tom-generator/rules-evaluator.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* TOM Rules Engine — Control Evaluation
|
||||
*
|
||||
* Evaluates control applicability based on company context.
|
||||
* Core engine class with condition evaluation and TOM derivation.
|
||||
*/
|
||||
|
||||
import {
|
||||
ControlLibraryEntry,
|
||||
ApplicabilityCondition,
|
||||
ControlApplicability,
|
||||
RulesEngineResult,
|
||||
RulesEngineEvaluationContext,
|
||||
DerivedTOM,
|
||||
EvidenceDocument,
|
||||
GapAnalysisResult,
|
||||
ConditionOperator,
|
||||
} from './types'
|
||||
import { getAllControls, getControlById } from './controls/loader'
|
||||
|
||||
// =============================================================================
|
||||
// RULES ENGINE CLASS — Evaluation Methods
|
||||
// =============================================================================
|
||||
|
||||
export class TOMRulesEngine {
|
||||
private controls: ControlLibraryEntry[]
|
||||
|
||||
constructor() {
|
||||
this.controls = getAllControls()
|
||||
}
|
||||
|
||||
evaluateControls(context: RulesEngineEvaluationContext): RulesEngineResult[] {
|
||||
return this.controls.map((control) => this.evaluateControl(control, context))
|
||||
}
|
||||
|
||||
evaluateControl(
|
||||
control: ControlLibraryEntry,
|
||||
context: RulesEngineEvaluationContext
|
||||
): RulesEngineResult {
|
||||
const sortedConditions = [...control.applicabilityConditions].sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
)
|
||||
|
||||
for (const condition of sortedConditions) {
|
||||
const matches = this.evaluateCondition(condition, context)
|
||||
if (matches) {
|
||||
return {
|
||||
controlId: control.id,
|
||||
applicability: condition.result,
|
||||
reason: this.formatConditionReason(condition, context),
|
||||
matchedCondition: condition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
controlId: control.id,
|
||||
applicability: control.defaultApplicability,
|
||||
reason: 'Standard-Anwendbarkeit (keine spezifische Bedingung erfuellt)',
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateCondition(
|
||||
condition: ApplicabilityCondition,
|
||||
context: RulesEngineEvaluationContext
|
||||
): boolean {
|
||||
const value = this.getFieldValue(condition.field, context)
|
||||
if (value === undefined || value === null) return false
|
||||
return this.evaluateOperator(condition.operator, value, condition.value)
|
||||
}
|
||||
|
||||
private getFieldValue(fieldPath: string, context: RulesEngineEvaluationContext): unknown {
|
||||
const parts = fieldPath.split('.')
|
||||
let current: unknown = context
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined
|
||||
if (typeof current === 'object') {
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
private evaluateOperator(
|
||||
operator: ConditionOperator,
|
||||
actualValue: unknown,
|
||||
expectedValue: unknown
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'EQUALS':
|
||||
return actualValue === expectedValue
|
||||
case 'NOT_EQUALS':
|
||||
return actualValue !== expectedValue
|
||||
case 'CONTAINS':
|
||||
if (Array.isArray(actualValue)) return actualValue.includes(expectedValue)
|
||||
if (typeof actualValue === 'string' && typeof expectedValue === 'string')
|
||||
return actualValue.includes(expectedValue)
|
||||
return false
|
||||
case 'GREATER_THAN':
|
||||
if (typeof actualValue === 'number' && typeof expectedValue === 'number')
|
||||
return actualValue > expectedValue
|
||||
return false
|
||||
case 'IN':
|
||||
if (Array.isArray(expectedValue)) return expectedValue.includes(actualValue)
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private formatConditionReason(
|
||||
condition: ApplicabilityCondition,
|
||||
context: RulesEngineEvaluationContext
|
||||
): string {
|
||||
const fieldValue = this.getFieldValue(condition.field, context)
|
||||
const fieldLabel = this.getFieldLabel(condition.field)
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'EQUALS':
|
||||
return `${fieldLabel} ist "${this.formatValue(fieldValue)}"`
|
||||
case 'NOT_EQUALS':
|
||||
return `${fieldLabel} ist nicht "${this.formatValue(condition.value)}"`
|
||||
case 'CONTAINS':
|
||||
return `${fieldLabel} enthaelt "${this.formatValue(condition.value)}"`
|
||||
case 'GREATER_THAN':
|
||||
return `${fieldLabel} ist groesser als ${this.formatValue(condition.value)}`
|
||||
case 'IN':
|
||||
return `${fieldLabel} ("${this.formatValue(fieldValue)}") ist in [${Array.isArray(condition.value) ? condition.value.join(', ') : condition.value}]`
|
||||
default:
|
||||
return `Bedingung erfuellt: ${condition.field} ${condition.operator} ${this.formatValue(condition.value)}`
|
||||
}
|
||||
}
|
||||
|
||||
private getFieldLabel(fieldPath: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'companyProfile.role': 'Unternehmensrolle',
|
||||
'companyProfile.size': 'Unternehmensgroesse',
|
||||
'dataProfile.hasSpecialCategories': 'Besondere Datenkategorien',
|
||||
'dataProfile.processesMinors': 'Verarbeitung von Minderjaehrigen-Daten',
|
||||
'dataProfile.dataVolume': 'Datenvolumen',
|
||||
'dataProfile.thirdCountryTransfers': 'Drittlanduebermittlungen',
|
||||
'architectureProfile.hostingModel': 'Hosting-Modell',
|
||||
'architectureProfile.hostingLocation': 'Hosting-Standort',
|
||||
'architectureProfile.multiTenancy': 'Mandantentrennung',
|
||||
'architectureProfile.hasSubprocessors': 'Unterauftragsverarbeiter',
|
||||
'architectureProfile.encryptionAtRest': 'Verschluesselung ruhender Daten',
|
||||
'securityProfile.hasMFA': 'Multi-Faktor-Authentifizierung',
|
||||
'securityProfile.hasSSO': 'Single Sign-On',
|
||||
'securityProfile.hasPAM': 'Privileged Access Management',
|
||||
'riskProfile.protectionLevel': 'Schutzbedarf',
|
||||
'riskProfile.dsfaRequired': 'DSFA erforderlich',
|
||||
'riskProfile.ciaAssessment.confidentiality': 'Vertraulichkeit',
|
||||
'riskProfile.ciaAssessment.integrity': 'Integritaet',
|
||||
'riskProfile.ciaAssessment.availability': 'Verfuegbarkeit',
|
||||
}
|
||||
return labels[fieldPath] || fieldPath
|
||||
}
|
||||
|
||||
private formatValue(value: unknown): string {
|
||||
if (value === true) return 'Ja'
|
||||
if (value === false) return 'Nein'
|
||||
if (value === null || value === undefined) return 'nicht gesetzt'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
deriveAllTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
const results = this.evaluateControls(context)
|
||||
|
||||
return results.map((result) => {
|
||||
const control = getControlById(result.controlId)
|
||||
if (!control) throw new Error(`Control not found: ${result.controlId}`)
|
||||
|
||||
return {
|
||||
id: `derived-${result.controlId}`,
|
||||
controlId: result.controlId,
|
||||
name: control.name.de,
|
||||
description: control.description.de,
|
||||
applicability: result.applicability,
|
||||
applicabilityReason: result.reason,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
responsiblePerson: null,
|
||||
responsibleDepartment: null,
|
||||
implementationDate: null,
|
||||
reviewDate: null,
|
||||
linkedEvidence: [],
|
||||
evidenceGaps: [...control.evidenceRequirements],
|
||||
aiGeneratedDescription: null,
|
||||
aiRecommendations: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getApplicableTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
return this.deriveAllTOMs(context).filter(
|
||||
(tom) => tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
}
|
||||
|
||||
getRequiredTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
return this.deriveAllTOMs(context).filter((tom) => tom.applicability === 'REQUIRED')
|
||||
}
|
||||
|
||||
getControlsByApplicability(
|
||||
context: RulesEngineEvaluationContext,
|
||||
applicability: ControlApplicability
|
||||
): ControlLibraryEntry[] {
|
||||
return this.evaluateControls(context)
|
||||
.filter((r) => r.applicability === applicability)
|
||||
.map((r) => getControlById(r.controlId))
|
||||
.filter((c): c is ControlLibraryEntry => c !== undefined)
|
||||
}
|
||||
|
||||
getSummaryStatistics(context: RulesEngineEvaluationContext): {
|
||||
total: number; required: number; recommended: number; optional: number; notApplicable: number
|
||||
byCategory: Map<string, { required: number; recommended: number }>
|
||||
} {
|
||||
const results = this.evaluateControls(context)
|
||||
const stats = {
|
||||
total: results.length, required: 0, recommended: 0, optional: 0, notApplicable: 0,
|
||||
byCategory: new Map<string, { required: number; recommended: number }>(),
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
switch (result.applicability) {
|
||||
case 'REQUIRED': stats.required++; break
|
||||
case 'RECOMMENDED': stats.recommended++; break
|
||||
case 'OPTIONAL': stats.optional++; break
|
||||
case 'NOT_APPLICABLE': stats.notApplicable++; break
|
||||
}
|
||||
|
||||
const control = getControlById(result.controlId)
|
||||
if (control) {
|
||||
const existing = stats.byCategory.get(control.category) || { required: 0, recommended: 0 }
|
||||
if (result.applicability === 'REQUIRED') existing.required++
|
||||
else if (result.applicability === 'RECOMMENDED') existing.recommended++
|
||||
stats.byCategory.set(control.category, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
isControlApplicable(controlId: string, context: RulesEngineEvaluationContext): boolean {
|
||||
const control = getControlById(controlId)
|
||||
if (!control) return false
|
||||
const result = this.evaluateControl(control, context)
|
||||
return result.applicability === 'REQUIRED' || result.applicability === 'RECOMMENDED'
|
||||
}
|
||||
|
||||
getControlsByTagWithApplicability(
|
||||
tag: string,
|
||||
context: RulesEngineEvaluationContext
|
||||
): Array<{ control: ControlLibraryEntry; result: RulesEngineResult }> {
|
||||
return this.controls
|
||||
.filter((control) => control.tags.includes(tag))
|
||||
.map((control) => ({ control, result: this.evaluateControl(control, context) }))
|
||||
}
|
||||
|
||||
performGapAnalysis(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
documents: EvidenceDocument[]
|
||||
): GapAnalysisResult {
|
||||
// Delegate to standalone function to keep this class focused on evaluation
|
||||
const { performGapAnalysis: doGapAnalysis } = require('./gap-analysis')
|
||||
return doGapAnalysis(derivedTOMs, documents)
|
||||
}
|
||||
|
||||
reloadControls(): void {
|
||||
this.controls = getAllControls()
|
||||
}
|
||||
}
|
||||
286
admin-compliance/lib/sdk/vvt-profiling-data.ts
Normal file
286
admin-compliance/lib/sdk/vvt-profiling-data.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* VVT Profiling — Questions, Steps & Department Data Categories
|
||||
*
|
||||
* Static data for the profiling questionnaire (~25 questions in 6 steps).
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ProfilingQuestion {
|
||||
id: string
|
||||
step: number
|
||||
question: string
|
||||
type: 'single_choice' | 'multi_choice' | 'number' | 'text' | 'boolean'
|
||||
options?: { value: string; label: string }[]
|
||||
helpText?: string
|
||||
triggersTemplates: string[]
|
||||
}
|
||||
|
||||
export interface ProfilingStep {
|
||||
step: number
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ProfilingAnswers {
|
||||
[questionId: string]: string | string[] | number | boolean
|
||||
}
|
||||
|
||||
export interface ProfilingResult {
|
||||
answers: ProfilingAnswers
|
||||
generatedActivities: import('./vvt-types').VVTActivity[]
|
||||
coverageScore: number
|
||||
art30Abs5Exempt: boolean
|
||||
}
|
||||
|
||||
export interface DepartmentCategory {
|
||||
id: string
|
||||
label: string
|
||||
info: string
|
||||
isArt9?: boolean
|
||||
isTypical?: boolean
|
||||
}
|
||||
|
||||
export interface DepartmentDataConfig {
|
||||
label: string
|
||||
icon: string
|
||||
categories: DepartmentCategory[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEPS
|
||||
// =============================================================================
|
||||
|
||||
export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
{ step: 1, title: 'Organisation', description: 'Grunddaten zu Ihrem Unternehmen' },
|
||||
{ step: 2, title: 'Geschaeftsbereiche', description: 'Welche Bereiche sind aktiv?' },
|
||||
{ step: 3, title: 'Systeme & Tools', description: 'Welche IT-Systeme nutzen Sie?' },
|
||||
{ step: 4, title: 'Datenkategorien', description: 'Welche besonderen Daten verarbeiten Sie?' },
|
||||
{ step: 5, title: 'Drittlandtransfers', description: 'Transfers ausserhalb der EU/EWR' },
|
||||
{ step: 6, title: 'Besondere Verarbeitungen', description: 'KI, Scoring, Ueberwachung' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// QUESTIONS
|
||||
// =============================================================================
|
||||
|
||||
export const PROFILING_QUESTIONS: ProfilingQuestion[] = [
|
||||
// === STEP 1: Organisation ===
|
||||
{
|
||||
id: 'org_industry', step: 1,
|
||||
question: 'In welcher Branche ist Ihr Unternehmen taetig?',
|
||||
type: 'single_choice',
|
||||
options: [
|
||||
{ value: 'it_software', label: 'IT & Software' },
|
||||
{ value: 'healthcare', label: 'Gesundheitswesen' },
|
||||
{ value: 'education', label: 'Bildung & Erziehung' },
|
||||
{ value: 'finance', label: 'Finanzdienstleistungen' },
|
||||
{ value: 'retail', label: 'Handel & E-Commerce' },
|
||||
{ value: 'manufacturing', label: 'Produktion & Industrie' },
|
||||
{ value: 'consulting', label: 'Beratung & Dienstleistung' },
|
||||
{ value: 'public', label: 'Oeffentlicher Sektor' },
|
||||
{ value: 'other', label: 'Sonstige' },
|
||||
],
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{ id: 'org_employees', step: 1, question: 'Wie viele Mitarbeiter hat Ihr Unternehmen?', type: 'number', helpText: 'Relevant fuer Art. 30 Abs. 5 DSGVO (Ausnahme < 250 Mitarbeiter)', triggersTemplates: [] },
|
||||
{
|
||||
id: 'org_locations', step: 1,
|
||||
question: 'An wie vielen Standorten ist Ihr Unternehmen taetig?',
|
||||
type: 'single_choice',
|
||||
options: [
|
||||
{ value: '1', label: '1 Standort' },
|
||||
{ value: '2-5', label: '2-5 Standorte' },
|
||||
{ value: '6-20', label: '6-20 Standorte' },
|
||||
{ value: '20+', label: 'Mehr als 20 Standorte' },
|
||||
],
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'org_b2b_b2c', step: 1,
|
||||
question: 'Welches Geschaeftsmodell betreiben Sie?',
|
||||
type: 'single_choice',
|
||||
options: [
|
||||
{ value: 'b2b', label: 'B2B (Geschaeftskunden)' },
|
||||
{ value: 'b2c', label: 'B2C (Endkunden)' },
|
||||
{ value: 'both', label: 'Beides (B2B + B2C)' },
|
||||
{ value: 'b2g', label: 'B2G (Oeffentlicher Sektor)' },
|
||||
],
|
||||
triggersTemplates: [],
|
||||
},
|
||||
|
||||
// === STEP 2: Geschaeftsbereiche ===
|
||||
{ id: 'dept_hr', step: 2, question: 'Haben Sie eine Personalabteilung / HR?', type: 'boolean', triggersTemplates: ['hr-mitarbeiterverwaltung', 'hr-gehaltsabrechnung', 'hr-zeiterfassung'] },
|
||||
{ id: 'dept_recruiting', step: 2, question: 'Betreiben Sie aktives Recruiting / Bewerbermanagement?', type: 'boolean', triggersTemplates: ['hr-bewerbermanagement'] },
|
||||
{ id: 'dept_finance', step: 2, question: 'Haben Sie eine Finanz-/Buchhaltungsabteilung?', type: 'boolean', triggersTemplates: ['finance-buchhaltung', 'finance-zahlungsverkehr'] },
|
||||
{ id: 'dept_sales', step: 2, question: 'Haben Sie einen Vertrieb / Kundenverwaltung?', type: 'boolean', triggersTemplates: ['sales-kundenverwaltung', 'sales-vertriebssteuerung'] },
|
||||
{ id: 'dept_marketing', step: 2, question: 'Betreiben Sie Marketing-Aktivitaeten?', type: 'boolean', triggersTemplates: ['marketing-social-media'] },
|
||||
{ id: 'dept_support', step: 2, question: 'Haben Sie einen Kundenservice / Support?', type: 'boolean', triggersTemplates: ['support-ticketsystem'] },
|
||||
|
||||
// === STEP 3: Systeme & Tools ===
|
||||
{ id: 'sys_crm', step: 3, question: 'Nutzen Sie ein CRM-System (z.B. Salesforce, HubSpot, Pipedrive)?', type: 'boolean', triggersTemplates: ['sales-kundenverwaltung'] },
|
||||
{ id: 'sys_website_analytics', step: 3, question: 'Nutzen Sie Website-Analytics (z.B. Matomo, Google Analytics)?', type: 'boolean', triggersTemplates: ['marketing-website-analytics'] },
|
||||
{ id: 'sys_newsletter', step: 3, question: 'Versenden Sie Newsletter (z.B. Mailchimp, CleverReach)?', type: 'boolean', triggersTemplates: ['marketing-newsletter'] },
|
||||
{ id: 'sys_video', step: 3, question: 'Nutzen Sie Videokonferenz-Tools (z.B. Zoom, Teams, Jitsi)?', type: 'boolean', triggersTemplates: ['other-videokonferenz'] },
|
||||
{ id: 'sys_erp', step: 3, question: 'Nutzen Sie ein ERP-System?', type: 'boolean', helpText: 'z.B. SAP, ERPNext, Microsoft Dynamics', triggersTemplates: ['finance-buchhaltung'] },
|
||||
{ id: 'sys_visitor', step: 3, question: 'Haben Sie ein Besuchermanagement-System?', type: 'boolean', triggersTemplates: ['other-besuchermanagement'] },
|
||||
|
||||
// === STEP 4: Datenkategorien ===
|
||||
{ id: 'data_health', step: 4, question: 'Verarbeiten Sie Gesundheitsdaten (Art. 9 DSGVO)?', type: 'boolean', helpText: 'z.B. Krankmeldungen, Arbeitsmedizin, Gesundheitsversorgung', triggersTemplates: [] },
|
||||
{ id: 'data_minors', step: 4, question: 'Verarbeiten Sie Daten von Minderjaehrigen?', type: 'boolean', helpText: 'z.B. Schueler, Kinder unter 16 Jahren', triggersTemplates: [] },
|
||||
{ id: 'data_biometric', step: 4, question: 'Verarbeiten Sie biometrische Daten zur Identifizierung?', type: 'boolean', helpText: 'z.B. Fingerabdruck, Gesichtserkennung, Stimmerkennung', triggersTemplates: [] },
|
||||
{ id: 'data_criminal', step: 4, question: 'Verarbeiten Sie Daten ueber strafrechtliche Verurteilungen (Art. 10 DSGVO)?', type: 'boolean', helpText: 'z.B. Fuehrungszeugnisse', triggersTemplates: [] },
|
||||
|
||||
// === STEP 5: Drittlandtransfers ===
|
||||
{ id: 'transfer_cloud_us', step: 5, question: 'Nutzen Sie Cloud-Dienste mit Sitz in den USA?', type: 'boolean', helpText: 'z.B. AWS, Azure, Google Cloud, Microsoft 365', triggersTemplates: [] },
|
||||
{ id: 'transfer_support_non_eu', step: 5, question: 'Haben Sie Support-Mitarbeiter oder Dienstleister ausserhalb der EU?', type: 'boolean', triggersTemplates: [] },
|
||||
{ id: 'transfer_subprocessor', step: 5, question: 'Nutzen Sie Auftragsverarbeiter mit Unteraufragnehmern in Drittlaendern?', type: 'boolean', triggersTemplates: [] },
|
||||
|
||||
// === STEP 6: Besondere Verarbeitungen ===
|
||||
{ id: 'special_ai', step: 6, question: 'Setzen Sie KI oder automatisierte Entscheidungsfindung ein?', type: 'boolean', helpText: 'z.B. Chatbots, Scoring, Profiling, automatische Bewertungen', triggersTemplates: [] },
|
||||
{ id: 'special_video_surveillance', step: 6, question: 'Betreiben Sie Videoueberwachung?', type: 'boolean', triggersTemplates: [] },
|
||||
{ id: 'special_tracking', step: 6, question: 'Betreiben Sie umfangreiches Nutzer-Tracking oder Profiling?', type: 'boolean', helpText: 'z.B. Verhaltensprofiling, Cross-Device-Tracking', triggersTemplates: [] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// DEPARTMENT DATA CATEGORIES
|
||||
// =============================================================================
|
||||
|
||||
export const DEPARTMENT_DATA_CATEGORIES: Record<string, DepartmentDataConfig> = {
|
||||
dept_hr: {
|
||||
label: 'Personal (HR)', icon: '\u{1F465}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Stammdaten', info: 'Vor-/Nachname, Titel, Geschlecht, Geburtsdatum', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Adressdaten', info: 'Wohn-/Melde-/Lieferadresse, Telefon, E-Mail', isTypical: true },
|
||||
{ id: 'SOCIAL_SECURITY', label: 'Sozialversicherungsnr.', info: 'SV-Nummer fuer Meldungen an DRV, Krankenkasse', isTypical: true },
|
||||
{ id: 'TAX_ID', label: 'Steuer-ID', info: 'Steueridentifikationsnummer, Steuerklasse, Freibetraege', isTypical: true },
|
||||
{ id: 'BANK_ACCOUNT', label: 'Bankverbindung', info: 'IBAN, BIC fuer Gehaltsueberweisungen', isTypical: true },
|
||||
{ id: 'SALARY_DATA', label: 'Gehaltsdaten', info: 'Bruttogehalt, Zulagen, Praemien, VWL, Abzuege', isTypical: true },
|
||||
{ id: 'EMPLOYMENT_DATA', label: 'Beschaeftigungsdaten', info: 'Vertrag, Eintrittsdatum, Abteilung, Position, Arbeitszeitmodell', isTypical: true },
|
||||
{ id: 'HEALTH_DATA', label: 'Gesundheitsdaten', info: 'Krankheitstage (AU-Bescheinigungen), BEM-Daten, Schwerbehinderung', isArt9: true },
|
||||
{ id: 'RELIGIOUS_BELIEFS', label: 'Religionszugehoerigkeit', info: 'Konfession fuer Kirchensteuer-Abfuehrung', isArt9: true },
|
||||
{ id: 'EDUCATION_DATA', label: 'Qualifikationen', info: 'Abschluesse, Zertifikate, Weiterbildungen, Schulungsnachweise' },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Mitarbeiterfotos', info: 'Passbilder fuer Ausweise, Intranet-Profilbilder' },
|
||||
]
|
||||
},
|
||||
dept_recruiting: {
|
||||
label: 'Recruiting / Bewerbermanagement', icon: '\u{1F4CB}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Bewerberstammdaten', info: 'Name, Anschrift, Kontaktdaten der Bewerber', isTypical: true },
|
||||
{ id: 'APPLICATION_DATA', label: 'Bewerbungsunterlagen', info: 'Lebenslauf, Anschreiben, Zeugnisse, Zertifikate', isTypical: true },
|
||||
{ id: 'EDUCATION_DATA', label: 'Qualifikationen', info: 'Abschluesse, Berufserfahrung, Sprachkenntnisse', isTypical: true },
|
||||
{ id: 'ASSESSMENT_DATA', label: 'Bewertungsdaten', info: 'Interviewnotizen, Assessment-Ergebnisse, Eignungstests' },
|
||||
{ id: 'HEALTH_DATA', label: 'Gesundheitsdaten', info: 'Schwerbehinderung (freiwillige Angabe), Eignungsuntersuchung', isArt9: true },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Bewerbungsfotos', info: 'Bewerbungsfoto (freiwillig), Video-Interview-Aufnahmen' },
|
||||
]
|
||||
},
|
||||
dept_finance: {
|
||||
label: 'Finanzen & Buchhaltung', icon: '\u{1F4B0}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Kunden-/Lieferantenstammdaten', info: 'Firmenname, Ansprechpartner, Kontaktdaten', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Rechnungsadressen', info: 'Rechnungs-/Lieferadressen, USt-IdNr.', isTypical: true },
|
||||
{ id: 'BANK_ACCOUNT', label: 'Bankverbindungen', info: 'IBAN, BIC, SEPA-Mandate, Zahlungsbedingungen', isTypical: true },
|
||||
{ id: 'TAX_ID', label: 'Steuer-IDs', info: 'Steuernummer, USt-IdNr., Steueridentifikationsnr.', isTypical: true },
|
||||
{ id: 'INVOICE_DATA', label: 'Rechnungsdaten', info: 'Rechnungen, Gutschriften, Mahnungen, Zahlungshistorie', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Vertragskonditionen, Laufzeiten, Kuendigungsfristen' },
|
||||
]
|
||||
},
|
||||
dept_sales: {
|
||||
label: 'Vertrieb & CRM', icon: '\u{1F91D}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Kontaktdaten', info: 'Name, E-Mail, Telefon, Position der Ansprechpartner', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Firmenadresse', info: 'Firmenanschrift, Standorte', isTypical: true },
|
||||
{ id: 'CRM_DATA', label: 'CRM-Daten', info: 'Lead-Status, Opportunities, Sales-Pipeline, Umsatzhistorie', isTypical: true },
|
||||
{ id: 'COMMUNICATION_DATA', label: 'Kommunikation', info: 'E-Mail-Verlauf, Gespraechsnotizen, Meeting-Protokolle', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Angebote, Bestellungen, Rahmenvertraege' },
|
||||
{ id: 'PREFERENCE_DATA', label: 'Praeferenzen', info: 'Produktinteressen, Kaufhistorie, Kundensegmentierung' },
|
||||
]
|
||||
},
|
||||
dept_marketing: {
|
||||
label: 'Marketing', icon: '\u{1F4E2}',
|
||||
categories: [
|
||||
{ id: 'EMAIL', label: 'E-Mail-Adressen', info: 'Newsletter-Abonnenten, Kampagnen-Empfaenger', isTypical: true },
|
||||
{ id: 'TRACKING_DATA', label: 'Tracking-/Analytics-Daten', info: 'IP-Adressen, Cookies, Seitenaufrufe, Klickpfade', isTypical: true },
|
||||
{ id: 'CONSENT_DATA', label: 'Einwilligungsdaten', info: 'Cookie-Consent, Newsletter-Opt-in, Widerrufe', isTypical: true },
|
||||
{ id: 'SOCIAL_MEDIA_DATA', label: 'Social-Media-Daten', info: 'Follower-Interaktionen, Kommentare, Reichweitendaten' },
|
||||
{ id: 'PREFERENCE_DATA', label: 'Interessenprofil', info: 'Produktinteressen, Segmentierung, A/B-Test-Zuordnungen' },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Bild-/Videomaterial', info: 'Kundenfotos (Testimonials), Event-Aufnahmen, UGC' },
|
||||
]
|
||||
},
|
||||
dept_support: {
|
||||
label: 'Kundenservice / Support', icon: '\u{1F3A7}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Kundenstammdaten', info: 'Name, E-Mail, Telefon, Kundennummer', isTypical: true },
|
||||
{ id: 'TICKET_DATA', label: 'Ticket-/Anfragedaten', info: 'Ticketnummer, Betreff, Beschreibung, Status, Prioritaet', isTypical: true },
|
||||
{ id: 'COMMUNICATION_DATA', label: 'Kommunikationsverlauf', info: 'E-Mails, Chat-Protokolle, Anrufnotizen', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Produktversion, Lizenz, SLA-Status', isTypical: true },
|
||||
{ id: 'TECHNICAL_DATA', label: 'Technische Daten', info: 'Systeminfos, Logdateien, Screenshots bei Fehlermeldungen' },
|
||||
]
|
||||
},
|
||||
dept_it: {
|
||||
label: 'IT / Administration', icon: '\u{1F4BB}',
|
||||
categories: [
|
||||
{ id: 'USER_ACCOUNTS', label: 'Benutzerkonten', info: 'Benutzernamen, Passwort-Hashes, Rollen, Berechtigungen', isTypical: true },
|
||||
{ id: 'LOG_DATA', label: 'Log-/Protokolldaten', info: 'System-Logs, Zugriffsprotokolle, Fehlerprotokolle, IP-Adressen', isTypical: true },
|
||||
{ id: 'DEVICE_DATA', label: 'Geraetedaten', info: 'Inventar, Seriennummern, MAC-Adressen, zugewiesene Geraete', isTypical: true },
|
||||
{ id: 'NETWORK_DATA', label: 'Netzwerkdaten', info: 'IP-Adressen, VPN-Verbindungen, Firewall-Logs', isTypical: true },
|
||||
{ id: 'EMAIL_DATA', label: 'E-Mail-/Kommunikation', info: 'E-Mail-Konten, Verteiler, Archivierung', isTypical: true },
|
||||
{ id: 'BACKUP_DATA', label: 'Backup-Daten', info: 'Sicherungskopien mit personenbezogenen Inhalten' },
|
||||
{ id: 'MONITORING_DATA', label: 'Monitoring-Daten', info: 'Systemueberwachung, Performance-Metriken mit Nutzerbezug' },
|
||||
]
|
||||
},
|
||||
dept_recht: {
|
||||
label: 'Recht / Compliance', icon: '\u{2696}\u{FE0F}',
|
||||
categories: [
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Vertraege, NDAs, AVVs, Rahmenvereinbarungen', isTypical: true },
|
||||
{ id: 'NAME', label: 'Ansprechpartner', info: 'Namen, Kontaktdaten von Vertragspartnern und Anwaelten', isTypical: true },
|
||||
{ id: 'COMPLIANCE_DATA', label: 'Compliance-Daten', info: 'Datenschutzanfragen, Meldungen, Audit-Ergebnisse', isTypical: true },
|
||||
{ id: 'INCIDENT_DATA', label: 'Vorfallsdaten', info: 'Datenschutzvorfaelle, Beschwerden, Meldungen an Aufsichtsbehoerden' },
|
||||
{ id: 'CONSENT_DATA', label: 'Einwilligungsdaten', info: 'Consent-Nachweise, Widerrufe, Opt-in/Opt-out-Protokolle' },
|
||||
{ id: 'CRIMINAL_DATA', label: 'Strafrechtliche Daten', info: 'Fuehrungszeugnisse, Compliance-Pruefungen (Art. 10 DSGVO)', isArt9: true },
|
||||
]
|
||||
},
|
||||
dept_produktion: {
|
||||
label: 'Produktion / Fertigung', icon: '\u{1F3ED}',
|
||||
categories: [
|
||||
{ id: 'EMPLOYMENT_DATA', label: 'Schichtplaene', info: 'Schichtzuordnung, Arbeitszeiten, Anwesenheitslisten', isTypical: true },
|
||||
{ id: 'NAME', label: 'Mitarbeiterstammdaten', info: 'Name, Personalnummer, Qualifikation, Maschinenberechtigungen', isTypical: true },
|
||||
{ id: 'HEALTH_DATA', label: 'Arbeitsschutzdaten', info: 'Arbeitsmedizinische Vorsorge, Unfallmeldungen, Gefahrstoff-Expositionen', isArt9: true },
|
||||
{ id: 'ACCESS_DATA', label: 'Zugangsdaten', info: 'Zutrittskontrolle, Badge-Protokolle, Bereichsberechtigungen', isTypical: true },
|
||||
{ id: 'QUALITY_DATA', label: 'Qualitaetsdaten', info: 'Pruefprotokolle mit Pruefernamen, Fehlerberichte' },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Bild-/Videodaten', info: 'Kameraueberwachung in Produktionsbereichen' },
|
||||
]
|
||||
},
|
||||
dept_logistik: {
|
||||
label: 'Logistik / Versand', icon: '\u{1F69A}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Empfaengerdaten', info: 'Name, Lieferadresse, Telefon fuer Zustellung', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Versandadressen', info: 'Liefer-/Abholadressen, Paketshop-Zuordnung', isTypical: true },
|
||||
{ id: 'TRACKING_DATA', label: 'Sendungsverfolgung', info: 'Tracking-Nummern, Zustellstatus, Lieferzeitfenster', isTypical: true },
|
||||
{ id: 'DRIVER_DATA', label: 'Fahrerdaten', info: 'Fahrerlaubnis, Touren, GPS-Standortdaten', isTypical: true },
|
||||
{ id: 'CUSTOMS_DATA', label: 'Zolldaten', info: 'Zollerklaerungen, EORI-Nummern bei internationalem Versand' },
|
||||
]
|
||||
},
|
||||
dept_einkauf: {
|
||||
label: 'Einkauf / Beschaffung', icon: '\u{1F6D2}',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Lieferantenkontakte', info: 'Ansprechpartner, E-Mail, Telefon der Lieferanten', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Rahmenvertraege, Bestellungen, Konditionen, Laufzeiten', isTypical: true },
|
||||
{ id: 'BANK_ACCOUNT', label: 'Bankverbindungen', info: 'IBAN, BIC der Lieferanten fuer Zahlungsabwicklung', isTypical: true },
|
||||
{ id: 'TAX_ID', label: 'Steuer-IDs', info: 'USt-IdNr., Steuernummer der Lieferanten', isTypical: true },
|
||||
{ id: 'COMPLIANCE_DATA', label: 'Lieferantenbewertung', info: 'Qualitaetsbewertungen, Audit-Ergebnisse, Zertifizierungen' },
|
||||
]
|
||||
},
|
||||
dept_facility: {
|
||||
label: 'Facility Management', icon: '\u{1F3E2}',
|
||||
categories: [
|
||||
{ id: 'ACCESS_DATA', label: 'Zutrittsdaten', info: 'Schluesselausgaben, Badge-Protokolle, Zutrittslisten', isTypical: true },
|
||||
{ id: 'NAME', label: 'Dienstleisterkontakte', info: 'Reinigung, Wartung, Sicherheitsdienst — Namen und Kontaktdaten', isTypical: true },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Videoueberwachung', info: 'Kameraaufnahmen in/an Gebaeuden, Parkplaetzen', isTypical: true },
|
||||
{ id: 'VISITOR_DATA', label: 'Besucherdaten', info: 'Name, Firma, Besuchsgrund, Ein-/Austrittszeiten', isTypical: true },
|
||||
{ id: 'HEALTH_DATA', label: 'Gesundheits-/Sicherheitsdaten', info: 'Unfallmeldungen, Evakuierungslisten, Ersthelfer-Register', isArt9: true },
|
||||
]
|
||||
},
|
||||
}
|
||||
187
admin-compliance/lib/sdk/vvt-profiling-logic.ts
Normal file
187
admin-compliance/lib/sdk/vvt-profiling-logic.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* VVT Profiling — Generator Logic, Enrichment & Helpers
|
||||
*
|
||||
* Generates baseline VVT activities from profiling answers.
|
||||
*/
|
||||
|
||||
import { VVT_BASELINE_CATALOG, templateToActivity } from './vvt-baseline-catalog'
|
||||
import { generateVVTId } from './vvt-types'
|
||||
import type { VVTActivity } from './vvt-types'
|
||||
import {
|
||||
PROFILING_QUESTIONS,
|
||||
type ProfilingAnswers,
|
||||
type ProfilingResult,
|
||||
} from './vvt-profiling-data'
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR LOGIC
|
||||
// =============================================================================
|
||||
|
||||
export function generateActivities(answers: ProfilingAnswers): ProfilingResult {
|
||||
const triggeredIds = new Set<string>()
|
||||
|
||||
for (const question of PROFILING_QUESTIONS) {
|
||||
const answer = answers[question.id]
|
||||
if (!answer) continue
|
||||
|
||||
if (question.type === 'boolean' && answer === true) {
|
||||
question.triggersTemplates.forEach(id => triggeredIds.add(id))
|
||||
}
|
||||
}
|
||||
|
||||
// Always add IT baseline templates
|
||||
triggeredIds.add('it-systemadministration')
|
||||
triggeredIds.add('it-backup')
|
||||
triggeredIds.add('it-logging')
|
||||
triggeredIds.add('it-iam')
|
||||
|
||||
const existingIds: string[] = []
|
||||
const activities: VVTActivity[] = []
|
||||
|
||||
for (const templateId of triggeredIds) {
|
||||
const template = VVT_BASELINE_CATALOG.find(t => t.templateId === templateId)
|
||||
if (!template) continue
|
||||
|
||||
const vvtId = generateVVTId(existingIds)
|
||||
existingIds.push(vvtId)
|
||||
|
||||
const activity = templateToActivity(template, vvtId)
|
||||
enrichActivityFromAnswers(activity, answers)
|
||||
activities.push(activity)
|
||||
}
|
||||
|
||||
// Calculate coverage score
|
||||
const totalFields = activities.length * 12
|
||||
let filledFields = 0
|
||||
for (const a of activities) {
|
||||
if (a.name) filledFields++
|
||||
if (a.description) filledFields++
|
||||
if (a.purposes.length > 0) filledFields++
|
||||
if (a.legalBases.length > 0) filledFields++
|
||||
if (a.dataSubjectCategories.length > 0) filledFields++
|
||||
if (a.personalDataCategories.length > 0) filledFields++
|
||||
if (a.recipientCategories.length > 0) filledFields++
|
||||
if (a.retentionPeriod.description) filledFields++
|
||||
if (a.tomDescription) filledFields++
|
||||
if (a.businessFunction !== 'other') filledFields++
|
||||
if (a.structuredToms.accessControl.length > 0) filledFields++
|
||||
if (a.responsible || a.owner) filledFields++
|
||||
}
|
||||
|
||||
const coverageScore = totalFields > 0 ? Math.round((filledFields / totalFields) * 100) : 0
|
||||
|
||||
const employeeCount = typeof answers.org_employees === 'number' ? answers.org_employees : 0
|
||||
const hasSpecialCategories = answers.data_health === true || answers.data_biometric === true || answers.data_criminal === true
|
||||
const art30Abs5Exempt = employeeCount < 250 && !hasSpecialCategories
|
||||
|
||||
return {
|
||||
answers,
|
||||
generatedActivities: activities,
|
||||
coverageScore,
|
||||
art30Abs5Exempt,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENRICHMENT
|
||||
// =============================================================================
|
||||
|
||||
function enrichActivityFromAnswers(activity: VVTActivity, answers: ProfilingAnswers): void {
|
||||
if (answers.transfer_cloud_us === true) {
|
||||
activity.thirdCountryTransfers.push({
|
||||
country: 'US',
|
||||
recipient: 'Cloud-Dienstleister (USA)',
|
||||
transferMechanism: 'SCC_PROCESSOR',
|
||||
additionalMeasures: ['Verschluesselung at-rest', 'Transfer Impact Assessment'],
|
||||
})
|
||||
}
|
||||
|
||||
if (answers.data_health === true) {
|
||||
if (!activity.personalDataCategories.includes('HEALTH_DATA')) {
|
||||
if (activity.businessFunction === 'hr') {
|
||||
activity.personalDataCategories.push('HEALTH_DATA')
|
||||
if (!activity.legalBases.some(lb => lb.type.startsWith('ART9_'))) {
|
||||
activity.legalBases.push({
|
||||
type: 'ART9_EMPLOYMENT',
|
||||
description: 'Arbeitsrechtliche Verarbeitung',
|
||||
reference: 'Art. 9 Abs. 2 lit. b DSGVO',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.data_minors === true) {
|
||||
if (!activity.dataSubjectCategories.includes('MINORS')) {
|
||||
if (activity.businessFunction === 'support' || activity.businessFunction === 'product_engineering') {
|
||||
activity.dataSubjectCategories.push('MINORS')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.special_ai === true || answers.special_video_surveillance === true || answers.special_tracking === true) {
|
||||
if (answers.special_ai === true && activity.businessFunction === 'product_engineering') {
|
||||
activity.dpiaRequired = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function getQuestionsForStep(step: number) {
|
||||
return PROFILING_QUESTIONS.filter(q => q.step === step)
|
||||
}
|
||||
|
||||
export function getStepProgress(answers: ProfilingAnswers, step: number): number {
|
||||
const questions = getQuestionsForStep(step)
|
||||
if (questions.length === 0) return 100
|
||||
|
||||
const answered = questions.filter(q => {
|
||||
const a = answers[q.id]
|
||||
return a !== undefined && a !== null && a !== ''
|
||||
}).length
|
||||
|
||||
return Math.round((answered / questions.length) * 100)
|
||||
}
|
||||
|
||||
export function getTotalProgress(answers: ProfilingAnswers): number {
|
||||
const total = PROFILING_QUESTIONS.length
|
||||
if (total === 0) return 100
|
||||
|
||||
const answered = PROFILING_QUESTIONS.filter(q => {
|
||||
const a = answers[q.id]
|
||||
return a !== undefined && a !== null && a !== ''
|
||||
}).length
|
||||
|
||||
return Math.round((answered / total) * 100)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPLIANCE SCOPE INTEGRATION
|
||||
// =============================================================================
|
||||
|
||||
export function prefillFromScopeAnswers(
|
||||
scopeAnswers: import('./compliance-scope-types').ScopeProfilingAnswer[]
|
||||
): ProfilingAnswers {
|
||||
const { exportToVVTAnswers } = require('./compliance-scope-profiling')
|
||||
const exported = exportToVVTAnswers(scopeAnswers) as Record<string, unknown>
|
||||
const prefilled: ProfilingAnswers = {}
|
||||
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
prefilled[key] = value as string | string[] | number | boolean
|
||||
}
|
||||
}
|
||||
|
||||
return prefilled
|
||||
}
|
||||
|
||||
export const SCOPE_PREFILLED_VVT_QUESTIONS = [
|
||||
'org_industry', 'org_employees', 'org_b2b_b2c',
|
||||
'dept_hr', 'dept_finance', 'dept_marketing',
|
||||
'data_health', 'data_minors', 'data_biometric', 'data_criminal',
|
||||
'special_ai', 'special_video_surveillance', 'special_tracking',
|
||||
'transfer_cloud_us', 'transfer_subprocessor', 'transfer_support_non_eu',
|
||||
]
|
||||
@@ -1,659 +1,31 @@
|
||||
/**
|
||||
* VVT Profiling — Generator-Fragebogen
|
||||
* VVT Profiling — barrel re-export
|
||||
*
|
||||
* ~25 Fragen in 6 Schritten, die auf Basis der Antworten
|
||||
* Baseline-Verarbeitungstaetigkeiten generieren.
|
||||
* Split into:
|
||||
* - vvt-profiling-data.ts (types, steps, questions, department categories)
|
||||
* - vvt-profiling-logic.ts (generator, enrichment, helpers, scope integration)
|
||||
*/
|
||||
|
||||
import { VVT_BASELINE_CATALOG, templateToActivity } from './vvt-baseline-catalog'
|
||||
import { generateVVTId } from './vvt-types'
|
||||
import type { VVTActivity, BusinessFunction } from './vvt-types'
|
||||
export type {
|
||||
ProfilingQuestion,
|
||||
ProfilingStep,
|
||||
ProfilingAnswers,
|
||||
ProfilingResult,
|
||||
DepartmentCategory,
|
||||
DepartmentDataConfig,
|
||||
} from './vvt-profiling-data'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
export {
|
||||
PROFILING_STEPS,
|
||||
PROFILING_QUESTIONS,
|
||||
DEPARTMENT_DATA_CATEGORIES,
|
||||
} from './vvt-profiling-data'
|
||||
|
||||
export interface ProfilingQuestion {
|
||||
id: string
|
||||
step: number
|
||||
question: string
|
||||
type: 'single_choice' | 'multi_choice' | 'number' | 'text' | 'boolean'
|
||||
options?: { value: string; label: string }[]
|
||||
helpText?: string
|
||||
triggersTemplates: string[] // Template-IDs that get activated when answered positively
|
||||
}
|
||||
|
||||
export interface ProfilingStep {
|
||||
step: number
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ProfilingAnswers {
|
||||
[questionId: string]: string | string[] | number | boolean
|
||||
}
|
||||
|
||||
export interface ProfilingResult {
|
||||
answers: ProfilingAnswers
|
||||
generatedActivities: VVTActivity[]
|
||||
coverageScore: number
|
||||
art30Abs5Exempt: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEPS
|
||||
// =============================================================================
|
||||
|
||||
export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
{ step: 1, title: 'Organisation', description: 'Grunddaten zu Ihrem Unternehmen' },
|
||||
{ step: 2, title: 'Geschaeftsbereiche', description: 'Welche Bereiche sind aktiv?' },
|
||||
{ step: 3, title: 'Systeme & Tools', description: 'Welche IT-Systeme nutzen Sie?' },
|
||||
{ step: 4, title: 'Datenkategorien', description: 'Welche besonderen Daten verarbeiten Sie?' },
|
||||
{ step: 5, title: 'Drittlandtransfers', description: 'Transfers ausserhalb der EU/EWR' },
|
||||
{ step: 6, title: 'Besondere Verarbeitungen', description: 'KI, Scoring, Ueberwachung' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// QUESTIONS
|
||||
// =============================================================================
|
||||
|
||||
export const PROFILING_QUESTIONS: ProfilingQuestion[] = [
|
||||
// === STEP 1: Organisation ===
|
||||
{
|
||||
id: 'org_industry',
|
||||
step: 1,
|
||||
question: 'In welcher Branche ist Ihr Unternehmen taetig?',
|
||||
type: 'single_choice',
|
||||
options: [
|
||||
{ value: 'it_software', label: 'IT & Software' },
|
||||
{ value: 'healthcare', label: 'Gesundheitswesen' },
|
||||
{ value: 'education', label: 'Bildung & Erziehung' },
|
||||
{ value: 'finance', label: 'Finanzdienstleistungen' },
|
||||
{ value: 'retail', label: 'Handel & E-Commerce' },
|
||||
{ value: 'manufacturing', label: 'Produktion & Industrie' },
|
||||
{ value: 'consulting', label: 'Beratung & Dienstleistung' },
|
||||
{ value: 'public', label: 'Oeffentlicher Sektor' },
|
||||
{ value: 'other', label: 'Sonstige' },
|
||||
],
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'org_employees',
|
||||
step: 1,
|
||||
question: 'Wie viele Mitarbeiter hat Ihr Unternehmen?',
|
||||
type: 'number',
|
||||
helpText: 'Relevant fuer Art. 30 Abs. 5 DSGVO (Ausnahme < 250 Mitarbeiter)',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'org_locations',
|
||||
step: 1,
|
||||
question: 'An wie vielen Standorten ist Ihr Unternehmen taetig?',
|
||||
type: 'single_choice',
|
||||
options: [
|
||||
{ value: '1', label: '1 Standort' },
|
||||
{ value: '2-5', label: '2-5 Standorte' },
|
||||
{ value: '6-20', label: '6-20 Standorte' },
|
||||
{ value: '20+', label: 'Mehr als 20 Standorte' },
|
||||
],
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'org_b2b_b2c',
|
||||
step: 1,
|
||||
question: 'Welches Geschaeftsmodell betreiben Sie?',
|
||||
type: 'single_choice',
|
||||
options: [
|
||||
{ value: 'b2b', label: 'B2B (Geschaeftskunden)' },
|
||||
{ value: 'b2c', label: 'B2C (Endkunden)' },
|
||||
{ value: 'both', label: 'Beides (B2B + B2C)' },
|
||||
{ value: 'b2g', label: 'B2G (Oeffentlicher Sektor)' },
|
||||
],
|
||||
triggersTemplates: [],
|
||||
},
|
||||
|
||||
// === STEP 2: Geschaeftsbereiche ===
|
||||
{
|
||||
id: 'dept_hr',
|
||||
step: 2,
|
||||
question: 'Haben Sie eine Personalabteilung / HR?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['hr-mitarbeiterverwaltung', 'hr-gehaltsabrechnung', 'hr-zeiterfassung'],
|
||||
},
|
||||
{
|
||||
id: 'dept_recruiting',
|
||||
step: 2,
|
||||
question: 'Betreiben Sie aktives Recruiting / Bewerbermanagement?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['hr-bewerbermanagement'],
|
||||
},
|
||||
{
|
||||
id: 'dept_finance',
|
||||
step: 2,
|
||||
question: 'Haben Sie eine Finanz-/Buchhaltungsabteilung?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['finance-buchhaltung', 'finance-zahlungsverkehr'],
|
||||
},
|
||||
{
|
||||
id: 'dept_sales',
|
||||
step: 2,
|
||||
question: 'Haben Sie einen Vertrieb / Kundenverwaltung?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['sales-kundenverwaltung', 'sales-vertriebssteuerung'],
|
||||
},
|
||||
{
|
||||
id: 'dept_marketing',
|
||||
step: 2,
|
||||
question: 'Betreiben Sie Marketing-Aktivitaeten?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['marketing-social-media'],
|
||||
},
|
||||
{
|
||||
id: 'dept_support',
|
||||
step: 2,
|
||||
question: 'Haben Sie einen Kundenservice / Support?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['support-ticketsystem'],
|
||||
},
|
||||
|
||||
// === STEP 3: Systeme & Tools ===
|
||||
{
|
||||
id: 'sys_crm',
|
||||
step: 3,
|
||||
question: 'Nutzen Sie ein CRM-System (z.B. Salesforce, HubSpot, Pipedrive)?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['sales-kundenverwaltung'],
|
||||
},
|
||||
{
|
||||
id: 'sys_website_analytics',
|
||||
step: 3,
|
||||
question: 'Nutzen Sie Website-Analytics (z.B. Matomo, Google Analytics)?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['marketing-website-analytics'],
|
||||
},
|
||||
{
|
||||
id: 'sys_newsletter',
|
||||
step: 3,
|
||||
question: 'Versenden Sie Newsletter (z.B. Mailchimp, CleverReach)?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['marketing-newsletter'],
|
||||
},
|
||||
{
|
||||
id: 'sys_video',
|
||||
step: 3,
|
||||
question: 'Nutzen Sie Videokonferenz-Tools (z.B. Zoom, Teams, Jitsi)?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['other-videokonferenz'],
|
||||
},
|
||||
{
|
||||
id: 'sys_erp',
|
||||
step: 3,
|
||||
question: 'Nutzen Sie ein ERP-System?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. SAP, ERPNext, Microsoft Dynamics',
|
||||
triggersTemplates: ['finance-buchhaltung'],
|
||||
},
|
||||
{
|
||||
id: 'sys_visitor',
|
||||
step: 3,
|
||||
question: 'Haben Sie ein Besuchermanagement-System?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: ['other-besuchermanagement'],
|
||||
},
|
||||
|
||||
// === STEP 4: Datenkategorien ===
|
||||
{
|
||||
id: 'data_health',
|
||||
step: 4,
|
||||
question: 'Verarbeiten Sie Gesundheitsdaten (Art. 9 DSGVO)?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. Krankmeldungen, Arbeitsmedizin, Gesundheitsversorgung',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'data_minors',
|
||||
step: 4,
|
||||
question: 'Verarbeiten Sie Daten von Minderjaehrigen?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. Schueler, Kinder unter 16 Jahren',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'data_biometric',
|
||||
step: 4,
|
||||
question: 'Verarbeiten Sie biometrische Daten zur Identifizierung?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. Fingerabdruck, Gesichtserkennung, Stimmerkennung',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'data_criminal',
|
||||
step: 4,
|
||||
question: 'Verarbeiten Sie Daten ueber strafrechtliche Verurteilungen (Art. 10 DSGVO)?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. Fuehrungszeugnisse',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
|
||||
// === STEP 5: Drittlandtransfers ===
|
||||
{
|
||||
id: 'transfer_cloud_us',
|
||||
step: 5,
|
||||
question: 'Nutzen Sie Cloud-Dienste mit Sitz in den USA?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. AWS, Azure, Google Cloud, Microsoft 365',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'transfer_support_non_eu',
|
||||
step: 5,
|
||||
question: 'Haben Sie Support-Mitarbeiter oder Dienstleister ausserhalb der EU?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'transfer_subprocessor',
|
||||
step: 5,
|
||||
question: 'Nutzen Sie Auftragsverarbeiter mit Unteraufragnehmern in Drittlaendern?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
|
||||
// === STEP 6: Besondere Verarbeitungen ===
|
||||
{
|
||||
id: 'special_ai',
|
||||
step: 6,
|
||||
question: 'Setzen Sie KI oder automatisierte Entscheidungsfindung ein?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. Chatbots, Scoring, Profiling, automatische Bewertungen',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'special_video_surveillance',
|
||||
step: 6,
|
||||
question: 'Betreiben Sie Videoueberwachung?',
|
||||
type: 'boolean',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
{
|
||||
id: 'special_tracking',
|
||||
step: 6,
|
||||
question: 'Betreiben Sie umfangreiches Nutzer-Tracking oder Profiling?',
|
||||
type: 'boolean',
|
||||
helpText: 'z.B. Verhaltensprofiling, Cross-Device-Tracking',
|
||||
triggersTemplates: [],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// DEPARTMENT DATA CATEGORIES (Aufklappbare Kacheln Step 2)
|
||||
// =============================================================================
|
||||
|
||||
export interface DepartmentCategory {
|
||||
id: string
|
||||
label: string
|
||||
info: string
|
||||
isArt9?: boolean
|
||||
isTypical?: boolean
|
||||
}
|
||||
|
||||
export interface DepartmentDataConfig {
|
||||
label: string
|
||||
icon: string
|
||||
categories: DepartmentCategory[]
|
||||
}
|
||||
|
||||
export const DEPARTMENT_DATA_CATEGORIES: Record<string, DepartmentDataConfig> = {
|
||||
dept_hr: {
|
||||
label: 'Personal (HR)',
|
||||
icon: '👥',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Stammdaten', info: 'Vor-/Nachname, Titel, Geschlecht, Geburtsdatum', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Adressdaten', info: 'Wohn-/Melde-/Lieferadresse, Telefon, E-Mail', isTypical: true },
|
||||
{ id: 'SOCIAL_SECURITY', label: 'Sozialversicherungsnr.', info: 'SV-Nummer fuer Meldungen an DRV, Krankenkasse', isTypical: true },
|
||||
{ id: 'TAX_ID', label: 'Steuer-ID', info: 'Steueridentifikationsnummer, Steuerklasse, Freibetraege', isTypical: true },
|
||||
{ id: 'BANK_ACCOUNT', label: 'Bankverbindung', info: 'IBAN, BIC fuer Gehaltsueberweisungen', isTypical: true },
|
||||
{ id: 'SALARY_DATA', label: 'Gehaltsdaten', info: 'Bruttogehalt, Zulagen, Praemien, VWL, Abzuege', isTypical: true },
|
||||
{ id: 'EMPLOYMENT_DATA', label: 'Beschaeftigungsdaten', info: 'Vertrag, Eintrittsdatum, Abteilung, Position, Arbeitszeitmodell', isTypical: true },
|
||||
{ id: 'HEALTH_DATA', label: 'Gesundheitsdaten', info: 'Krankheitstage (AU-Bescheinigungen), BEM-Daten, Schwerbehinderung', isArt9: true },
|
||||
{ id: 'RELIGIOUS_BELIEFS', label: 'Religionszugehoerigkeit', info: 'Konfession fuer Kirchensteuer-Abfuehrung', isArt9: true },
|
||||
{ id: 'EDUCATION_DATA', label: 'Qualifikationen', info: 'Abschluesse, Zertifikate, Weiterbildungen, Schulungsnachweise' },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Mitarbeiterfotos', info: 'Passbilder fuer Ausweise, Intranet-Profilbilder' },
|
||||
]
|
||||
},
|
||||
dept_recruiting: {
|
||||
label: 'Recruiting / Bewerbermanagement',
|
||||
icon: '📋',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Bewerberstammdaten', info: 'Name, Anschrift, Kontaktdaten der Bewerber', isTypical: true },
|
||||
{ id: 'APPLICATION_DATA', label: 'Bewerbungsunterlagen', info: 'Lebenslauf, Anschreiben, Zeugnisse, Zertifikate', isTypical: true },
|
||||
{ id: 'EDUCATION_DATA', label: 'Qualifikationen', info: 'Abschluesse, Berufserfahrung, Sprachkenntnisse', isTypical: true },
|
||||
{ id: 'ASSESSMENT_DATA', label: 'Bewertungsdaten', info: 'Interviewnotizen, Assessment-Ergebnisse, Eignungstests' },
|
||||
{ id: 'HEALTH_DATA', label: 'Gesundheitsdaten', info: 'Schwerbehinderung (freiwillige Angabe), Eignungsuntersuchung', isArt9: true },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Bewerbungsfotos', info: 'Bewerbungsfoto (freiwillig), Video-Interview-Aufnahmen' },
|
||||
]
|
||||
},
|
||||
dept_finance: {
|
||||
label: 'Finanzen & Buchhaltung',
|
||||
icon: '💰',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Kunden-/Lieferantenstammdaten', info: 'Firmenname, Ansprechpartner, Kontaktdaten', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Rechnungsadressen', info: 'Rechnungs-/Lieferadressen, USt-IdNr.', isTypical: true },
|
||||
{ id: 'BANK_ACCOUNT', label: 'Bankverbindungen', info: 'IBAN, BIC, SEPA-Mandate, Zahlungsbedingungen', isTypical: true },
|
||||
{ id: 'TAX_ID', label: 'Steuer-IDs', info: 'Steuernummer, USt-IdNr., Steueridentifikationsnr.', isTypical: true },
|
||||
{ id: 'INVOICE_DATA', label: 'Rechnungsdaten', info: 'Rechnungen, Gutschriften, Mahnungen, Zahlungshistorie', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Vertragskonditionen, Laufzeiten, Kuendigungsfristen' },
|
||||
]
|
||||
},
|
||||
dept_sales: {
|
||||
label: 'Vertrieb & CRM',
|
||||
icon: '🤝',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Kontaktdaten', info: 'Name, E-Mail, Telefon, Position der Ansprechpartner', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Firmenadresse', info: 'Firmenanschrift, Standorte', isTypical: true },
|
||||
{ id: 'CRM_DATA', label: 'CRM-Daten', info: 'Lead-Status, Opportunities, Sales-Pipeline, Umsatzhistorie', isTypical: true },
|
||||
{ id: 'COMMUNICATION_DATA', label: 'Kommunikation', info: 'E-Mail-Verlauf, Gespraechsnotizen, Meeting-Protokolle', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Angebote, Bestellungen, Rahmenvertraege' },
|
||||
{ id: 'PREFERENCE_DATA', label: 'Praeferenzen', info: 'Produktinteressen, Kaufhistorie, Kundensegmentierung' },
|
||||
]
|
||||
},
|
||||
dept_marketing: {
|
||||
label: 'Marketing',
|
||||
icon: '📢',
|
||||
categories: [
|
||||
{ id: 'EMAIL', label: 'E-Mail-Adressen', info: 'Newsletter-Abonnenten, Kampagnen-Empfaenger', isTypical: true },
|
||||
{ id: 'TRACKING_DATA', label: 'Tracking-/Analytics-Daten', info: 'IP-Adressen, Cookies, Seitenaufrufe, Klickpfade', isTypical: true },
|
||||
{ id: 'CONSENT_DATA', label: 'Einwilligungsdaten', info: 'Cookie-Consent, Newsletter-Opt-in, Widerrufe', isTypical: true },
|
||||
{ id: 'SOCIAL_MEDIA_DATA', label: 'Social-Media-Daten', info: 'Follower-Interaktionen, Kommentare, Reichweitendaten' },
|
||||
{ id: 'PREFERENCE_DATA', label: 'Interessenprofil', info: 'Produktinteressen, Segmentierung, A/B-Test-Zuordnungen' },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Bild-/Videomaterial', info: 'Kundenfotos (Testimonials), Event-Aufnahmen, UGC' },
|
||||
]
|
||||
},
|
||||
dept_support: {
|
||||
label: 'Kundenservice / Support',
|
||||
icon: '🎧',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Kundenstammdaten', info: 'Name, E-Mail, Telefon, Kundennummer', isTypical: true },
|
||||
{ id: 'TICKET_DATA', label: 'Ticket-/Anfragedaten', info: 'Ticketnummer, Betreff, Beschreibung, Status, Prioritaet', isTypical: true },
|
||||
{ id: 'COMMUNICATION_DATA', label: 'Kommunikationsverlauf', info: 'E-Mails, Chat-Protokolle, Anrufnotizen', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Produktversion, Lizenz, SLA-Status', isTypical: true },
|
||||
{ id: 'TECHNICAL_DATA', label: 'Technische Daten', info: 'Systeminfos, Logdateien, Screenshots bei Fehlermeldungen' },
|
||||
]
|
||||
},
|
||||
dept_it: {
|
||||
label: 'IT / Administration',
|
||||
icon: '💻',
|
||||
categories: [
|
||||
{ id: 'USER_ACCOUNTS', label: 'Benutzerkonten', info: 'Benutzernamen, Passwort-Hashes, Rollen, Berechtigungen', isTypical: true },
|
||||
{ id: 'LOG_DATA', label: 'Log-/Protokolldaten', info: 'System-Logs, Zugriffsprotokolle, Fehlerprotokolle, IP-Adressen', isTypical: true },
|
||||
{ id: 'DEVICE_DATA', label: 'Geraetedaten', info: 'Inventar, Seriennummern, MAC-Adressen, zugewiesene Geraete', isTypical: true },
|
||||
{ id: 'NETWORK_DATA', label: 'Netzwerkdaten', info: 'IP-Adressen, VPN-Verbindungen, Firewall-Logs', isTypical: true },
|
||||
{ id: 'EMAIL_DATA', label: 'E-Mail-/Kommunikation', info: 'E-Mail-Konten, Verteiler, Archivierung', isTypical: true },
|
||||
{ id: 'BACKUP_DATA', label: 'Backup-Daten', info: 'Sicherungskopien mit personenbezogenen Inhalten' },
|
||||
{ id: 'MONITORING_DATA', label: 'Monitoring-Daten', info: 'Systemueberwachung, Performance-Metriken mit Nutzerbezug' },
|
||||
]
|
||||
},
|
||||
dept_recht: {
|
||||
label: 'Recht / Compliance',
|
||||
icon: '⚖️',
|
||||
categories: [
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Vertraege, NDAs, AVVs, Rahmenvereinbarungen', isTypical: true },
|
||||
{ id: 'NAME', label: 'Ansprechpartner', info: 'Namen, Kontaktdaten von Vertragspartnern und Anwaelten', isTypical: true },
|
||||
{ id: 'COMPLIANCE_DATA', label: 'Compliance-Daten', info: 'Datenschutzanfragen, Meldungen, Audit-Ergebnisse', isTypical: true },
|
||||
{ id: 'INCIDENT_DATA', label: 'Vorfallsdaten', info: 'Datenschutzvorfaelle, Beschwerden, Meldungen an Aufsichtsbehoerden' },
|
||||
{ id: 'CONSENT_DATA', label: 'Einwilligungsdaten', info: 'Consent-Nachweise, Widerrufe, Opt-in/Opt-out-Protokolle' },
|
||||
{ id: 'CRIMINAL_DATA', label: 'Strafrechtliche Daten', info: 'Fuehrungszeugnisse, Compliance-Pruefungen (Art. 10 DSGVO)', isArt9: true },
|
||||
]
|
||||
},
|
||||
dept_produktion: {
|
||||
label: 'Produktion / Fertigung',
|
||||
icon: '🏭',
|
||||
categories: [
|
||||
{ id: 'EMPLOYMENT_DATA', label: 'Schichtplaene', info: 'Schichtzuordnung, Arbeitszeiten, Anwesenheitslisten', isTypical: true },
|
||||
{ id: 'NAME', label: 'Mitarbeiterstammdaten', info: 'Name, Personalnummer, Qualifikation, Maschinenberechtigungen', isTypical: true },
|
||||
{ id: 'HEALTH_DATA', label: 'Arbeitsschutzdaten', info: 'Arbeitsmedizinische Vorsorge, Unfallmeldungen, Gefahrstoff-Expositionen', isArt9: true },
|
||||
{ id: 'ACCESS_DATA', label: 'Zugangsdaten', info: 'Zutrittskontrolle, Badge-Protokolle, Bereichsberechtigungen', isTypical: true },
|
||||
{ id: 'QUALITY_DATA', label: 'Qualitaetsdaten', info: 'Pruefprotokolle mit Pruefernamen, Fehlerberichte' },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Bild-/Videodaten', info: 'Kameraueberwachung in Produktionsbereichen' },
|
||||
]
|
||||
},
|
||||
dept_logistik: {
|
||||
label: 'Logistik / Versand',
|
||||
icon: '🚚',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Empfaengerdaten', info: 'Name, Lieferadresse, Telefon fuer Zustellung', isTypical: true },
|
||||
{ id: 'ADDRESS', label: 'Versandadressen', info: 'Liefer-/Abholadressen, Paketshop-Zuordnung', isTypical: true },
|
||||
{ id: 'TRACKING_DATA', label: 'Sendungsverfolgung', info: 'Tracking-Nummern, Zustellstatus, Lieferzeitfenster', isTypical: true },
|
||||
{ id: 'DRIVER_DATA', label: 'Fahrerdaten', info: 'Fahrerlaubnis, Touren, GPS-Standortdaten', isTypical: true },
|
||||
{ id: 'CUSTOMS_DATA', label: 'Zolldaten', info: 'Zollerklaerungen, EORI-Nummern bei internationalem Versand' },
|
||||
]
|
||||
},
|
||||
dept_einkauf: {
|
||||
label: 'Einkauf / Beschaffung',
|
||||
icon: '🛒',
|
||||
categories: [
|
||||
{ id: 'NAME', label: 'Lieferantenkontakte', info: 'Ansprechpartner, E-Mail, Telefon der Lieferanten', isTypical: true },
|
||||
{ id: 'CONTRACT_DATA', label: 'Vertragsdaten', info: 'Rahmenvertraege, Bestellungen, Konditionen, Laufzeiten', isTypical: true },
|
||||
{ id: 'BANK_ACCOUNT', label: 'Bankverbindungen', info: 'IBAN, BIC der Lieferanten fuer Zahlungsabwicklung', isTypical: true },
|
||||
{ id: 'TAX_ID', label: 'Steuer-IDs', info: 'USt-IdNr., Steuernummer der Lieferanten', isTypical: true },
|
||||
{ id: 'COMPLIANCE_DATA', label: 'Lieferantenbewertung', info: 'Qualitaetsbewertungen, Audit-Ergebnisse, Zertifizierungen' },
|
||||
]
|
||||
},
|
||||
dept_facility: {
|
||||
label: 'Facility Management',
|
||||
icon: '🏢',
|
||||
categories: [
|
||||
{ id: 'ACCESS_DATA', label: 'Zutrittsdaten', info: 'Schluesselausgaben, Badge-Protokolle, Zutrittslisten', isTypical: true },
|
||||
{ id: 'NAME', label: 'Dienstleisterkontakte', info: 'Reinigung, Wartung, Sicherheitsdienst — Namen und Kontaktdaten', isTypical: true },
|
||||
{ id: 'PHOTO_VIDEO', label: 'Videoueberwachung', info: 'Kameraaufnahmen in/an Gebaeuden, Parkplaetzen', isTypical: true },
|
||||
{ id: 'VISITOR_DATA', label: 'Besucherdaten', info: 'Name, Firma, Besuchsgrund, Ein-/Austrittszeiten', isTypical: true },
|
||||
{ id: 'HEALTH_DATA', label: 'Gesundheits-/Sicherheitsdaten', info: 'Unfallmeldungen, Evakuierungslisten, Ersthelfer-Register', isArt9: true },
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR LOGIC
|
||||
// =============================================================================
|
||||
|
||||
export function generateActivities(answers: ProfilingAnswers): ProfilingResult {
|
||||
// Collect all triggered template IDs
|
||||
const triggeredIds = new Set<string>()
|
||||
|
||||
for (const question of PROFILING_QUESTIONS) {
|
||||
const answer = answers[question.id]
|
||||
if (!answer) continue
|
||||
|
||||
// Boolean questions: if true, trigger templates
|
||||
if (question.type === 'boolean' && answer === true) {
|
||||
question.triggersTemplates.forEach(id => triggeredIds.add(id))
|
||||
}
|
||||
}
|
||||
|
||||
// Always add IT baseline templates (every company needs these)
|
||||
triggeredIds.add('it-systemadministration')
|
||||
triggeredIds.add('it-backup')
|
||||
triggeredIds.add('it-logging')
|
||||
triggeredIds.add('it-iam')
|
||||
|
||||
// Generate activities from triggered templates
|
||||
const existingIds: string[] = []
|
||||
const activities: VVTActivity[] = []
|
||||
|
||||
for (const templateId of triggeredIds) {
|
||||
const template = VVT_BASELINE_CATALOG.find(t => t.templateId === templateId)
|
||||
if (!template) continue
|
||||
|
||||
const vvtId = generateVVTId(existingIds)
|
||||
existingIds.push(vvtId)
|
||||
|
||||
const activity = templateToActivity(template, vvtId)
|
||||
|
||||
// Enrich with profiling answers
|
||||
enrichActivityFromAnswers(activity, answers)
|
||||
|
||||
activities.push(activity)
|
||||
}
|
||||
|
||||
// Calculate coverage score
|
||||
const totalFields = activities.length * 12 // 12 key fields per activity
|
||||
let filledFields = 0
|
||||
for (const a of activities) {
|
||||
if (a.name) filledFields++
|
||||
if (a.description) filledFields++
|
||||
if (a.purposes.length > 0) filledFields++
|
||||
if (a.legalBases.length > 0) filledFields++
|
||||
if (a.dataSubjectCategories.length > 0) filledFields++
|
||||
if (a.personalDataCategories.length > 0) filledFields++
|
||||
if (a.recipientCategories.length > 0) filledFields++
|
||||
if (a.retentionPeriod.description) filledFields++
|
||||
if (a.tomDescription) filledFields++
|
||||
if (a.businessFunction !== 'other') filledFields++
|
||||
if (a.structuredToms.accessControl.length > 0) filledFields++
|
||||
if (a.responsible || a.owner) filledFields++
|
||||
}
|
||||
|
||||
const coverageScore = totalFields > 0 ? Math.round((filledFields / totalFields) * 100) : 0
|
||||
|
||||
// Art. 30 Abs. 5 check
|
||||
const employeeCount = typeof answers.org_employees === 'number' ? answers.org_employees : 0
|
||||
const hasSpecialCategories = answers.data_health === true || answers.data_biometric === true || answers.data_criminal === true
|
||||
const art30Abs5Exempt = employeeCount < 250 && !hasSpecialCategories
|
||||
|
||||
return {
|
||||
answers,
|
||||
generatedActivities: activities,
|
||||
coverageScore,
|
||||
art30Abs5Exempt,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENRICHMENT
|
||||
// =============================================================================
|
||||
|
||||
function enrichActivityFromAnswers(activity: VVTActivity, answers: ProfilingAnswers): void {
|
||||
// Add third-country transfers if US cloud is used
|
||||
if (answers.transfer_cloud_us === true) {
|
||||
activity.thirdCountryTransfers.push({
|
||||
country: 'US',
|
||||
recipient: 'Cloud-Dienstleister (USA)',
|
||||
transferMechanism: 'SCC_PROCESSOR',
|
||||
additionalMeasures: ['Verschluesselung at-rest', 'Transfer Impact Assessment'],
|
||||
})
|
||||
}
|
||||
|
||||
// Add special data categories if applicable
|
||||
if (answers.data_health === true) {
|
||||
if (!activity.personalDataCategories.includes('HEALTH_DATA')) {
|
||||
// Only add to HR activities
|
||||
if (activity.businessFunction === 'hr') {
|
||||
activity.personalDataCategories.push('HEALTH_DATA')
|
||||
// Ensure Art. 9 legal basis
|
||||
if (!activity.legalBases.some(lb => lb.type.startsWith('ART9_'))) {
|
||||
activity.legalBases.push({
|
||||
type: 'ART9_EMPLOYMENT',
|
||||
description: 'Arbeitsrechtliche Verarbeitung',
|
||||
reference: 'Art. 9 Abs. 2 lit. b DSGVO',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.data_minors === true) {
|
||||
if (!activity.dataSubjectCategories.includes('MINORS')) {
|
||||
// Add to relevant activities (education, app users)
|
||||
if (activity.businessFunction === 'support' || activity.businessFunction === 'product_engineering') {
|
||||
activity.dataSubjectCategories.push('MINORS')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set DPIA required for special processing
|
||||
if (answers.special_ai === true || answers.special_video_surveillance === true || answers.special_tracking === true) {
|
||||
if (answers.special_ai === true && activity.businessFunction === 'product_engineering') {
|
||||
activity.dpiaRequired = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function getQuestionsForStep(step: number): ProfilingQuestion[] {
|
||||
return PROFILING_QUESTIONS.filter(q => q.step === step)
|
||||
}
|
||||
|
||||
export function getStepProgress(answers: ProfilingAnswers, step: number): number {
|
||||
const questions = getQuestionsForStep(step)
|
||||
if (questions.length === 0) return 100
|
||||
|
||||
const answered = questions.filter(q => {
|
||||
const a = answers[q.id]
|
||||
return a !== undefined && a !== null && a !== ''
|
||||
}).length
|
||||
|
||||
return Math.round((answered / questions.length) * 100)
|
||||
}
|
||||
|
||||
export function getTotalProgress(answers: ProfilingAnswers): number {
|
||||
const total = PROFILING_QUESTIONS.length
|
||||
if (total === 0) return 100
|
||||
|
||||
const answered = PROFILING_QUESTIONS.filter(q => {
|
||||
const a = answers[q.id]
|
||||
return a !== undefined && a !== null && a !== ''
|
||||
}).length
|
||||
|
||||
return Math.round((answered / total) * 100)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPLIANCE SCOPE INTEGRATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Prefill VVT profiling answers from Compliance Scope Engine answers.
|
||||
* The Scope Engine acts as the "Single Source of Truth" for organizational questions.
|
||||
* Redundant questions are auto-filled with a "prefilled" marker.
|
||||
*/
|
||||
export function prefillFromScopeAnswers(
|
||||
scopeAnswers: import('./compliance-scope-types').ScopeProfilingAnswer[]
|
||||
): ProfilingAnswers {
|
||||
const { exportToVVTAnswers } = require('./compliance-scope-profiling')
|
||||
const exported = exportToVVTAnswers(scopeAnswers) as Record<string, unknown>
|
||||
const prefilled: ProfilingAnswers = {}
|
||||
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
prefilled[key] = value as string | string[] | number | boolean
|
||||
}
|
||||
}
|
||||
|
||||
return prefilled
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of VVT question IDs that are prefilled from Scope answers.
|
||||
* These questions should show "Aus Scope-Analyse uebernommen" hint.
|
||||
*/
|
||||
export const SCOPE_PREFILLED_VVT_QUESTIONS = [
|
||||
'org_industry',
|
||||
'org_employees',
|
||||
'org_b2b_b2c',
|
||||
'dept_hr',
|
||||
'dept_finance',
|
||||
'dept_marketing',
|
||||
'data_health',
|
||||
'data_minors',
|
||||
'data_biometric',
|
||||
'data_criminal',
|
||||
'special_ai',
|
||||
'special_video_surveillance',
|
||||
'special_tracking',
|
||||
'transfer_cloud_us',
|
||||
'transfer_subprocessor',
|
||||
'transfer_support_non_eu',
|
||||
]
|
||||
export {
|
||||
generateActivities,
|
||||
getQuestionsForStep,
|
||||
getStepProgress,
|
||||
getTotalProgress,
|
||||
prefillFromScopeAnswers,
|
||||
SCOPE_PREFILLED_VVT_QUESTIONS,
|
||||
} from './vvt-profiling-logic'
|
||||
|
||||
187
admin-compliance/lib/sdk/whistleblower/api-mock-data.ts
Normal file
187
admin-compliance/lib/sdk/whistleblower/api-mock-data.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Whistleblower API — Mock Data & SDK Proxy
|
||||
*
|
||||
* Fallback mock data for development and SDK proxy function
|
||||
*/
|
||||
|
||||
import {
|
||||
WhistleblowerReport,
|
||||
WhistleblowerStatistics,
|
||||
ReportCategory,
|
||||
ReportStatus,
|
||||
generateAccessKey,
|
||||
} from './types'
|
||||
import {
|
||||
fetchReports,
|
||||
fetchWhistleblowerStatistics,
|
||||
} from './api-operations'
|
||||
|
||||
// =============================================================================
|
||||
// SDK PROXY FUNCTION
|
||||
// =============================================================================
|
||||
|
||||
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)
|
||||
const reports = createMockReports()
|
||||
const statistics = createMockStatistics()
|
||||
return { reports, statistics }
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
export function createMockReports(): WhistleblowerReport[] {
|
||||
const now = new Date()
|
||||
|
||||
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 [
|
||||
{
|
||||
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() }]
|
||||
},
|
||||
{
|
||||
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() },
|
||||
]
|
||||
},
|
||||
{
|
||||
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() },
|
||||
]
|
||||
},
|
||||
{
|
||||
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() },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function createMockStatistics(): WhistleblowerStatistics {
|
||||
const reports = createMockReports()
|
||||
const now = new Date()
|
||||
|
||||
const byStatus: Record<ReportStatus, number> = {
|
||||
new: 0, acknowledged: 0, under_review: 0, investigation: 0,
|
||||
measures_taken: 0, closed: 0, rejected: 0
|
||||
}
|
||||
|
||||
const byCategory: Record<ReportCategory, number> = {
|
||||
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']
|
||||
|
||||
const overdueAcknowledgment = reports.filter(r => {
|
||||
if (r.status !== 'new') return false
|
||||
return now > new Date(r.deadlineAcknowledgment)
|
||||
}).length
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
306
admin-compliance/lib/sdk/whistleblower/api-operations.ts
Normal file
306
admin-compliance/lib/sdk/whistleblower/api-operations.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Whistleblower API Client — CRUD, Workflow, Messaging, Attachments, Statistics
|
||||
*
|
||||
* API client for Hinweisgeberschutzgesetz (HinSchG) compliant
|
||||
* Whistleblower/Hinweisgebersystem management
|
||||
*/
|
||||
|
||||
import {
|
||||
WhistleblowerReport,
|
||||
WhistleblowerStatistics,
|
||||
ReportListResponse,
|
||||
ReportFilters,
|
||||
PublicReportSubmission,
|
||||
ReportUpdateRequest,
|
||||
AnonymousMessage,
|
||||
WhistleblowerMeasure,
|
||||
FileAttachment,
|
||||
} from './types'
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
const WB_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
|
||||
const API_TIMEOUT = 30000
|
||||
|
||||
// =============================================================================
|
||||
// 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<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = API_TIMEOUT
|
||||
): Promise<T> {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchReports(filters?: ReportFilters): Promise<ReportListResponse> {
|
||||
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<ReportListResponse>(url)
|
||||
}
|
||||
|
||||
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${WB_API_BASE}/api/v1/admin/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}`,
|
||||
{ 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}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// =============================================================================
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
export async function fetchReportByAccessKey(
|
||||
accessKey: string
|
||||
): Promise<WhistleblowerReport> {
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
export async function acknowledgeReport(id: string): Promise<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${WB_API_BASE}/api/v1/admin/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`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
}
|
||||
|
||||
export async function addMeasure(
|
||||
id: string,
|
||||
measure: Omit<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
|
||||
): Promise<WhistleblowerMeasure> {
|
||||
return fetchWithTimeout<WhistleblowerMeasure>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/measures`,
|
||||
{ method: 'POST', body: JSON.stringify(measure) }
|
||||
)
|
||||
}
|
||||
|
||||
export async function closeReport(
|
||||
id: string,
|
||||
resolution: { reason: string; notes: string }
|
||||
): Promise<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/close`,
|
||||
{ method: 'POST', body: JSON.stringify(resolution) }
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANONYMOUS MESSAGING
|
||||
// =============================================================================
|
||||
|
||||
export async function sendMessage(
|
||||
reportId: string,
|
||||
message: string,
|
||||
role: 'reporter' | 'ombudsperson'
|
||||
): Promise<AnonymousMessage> {
|
||||
return fetchWithTimeout<AnonymousMessage>(
|
||||
`${WB_API_BASE}/api/v1/admin/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`
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ATTACHMENTS
|
||||
// =============================================================================
|
||||
|
||||
export async function uploadAttachment(
|
||||
reportId: string,
|
||||
file: File
|
||||
): Promise<FileAttachment> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string): Promise<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
|
||||
return fetchWithTimeout<WhistleblowerStatistics>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/statistics`
|
||||
)
|
||||
}
|
||||
@@ -1,755 +1,31 @@
|
||||
/**
|
||||
* Whistleblower System API Client
|
||||
* Whistleblower System API Client — barrel re-export
|
||||
*
|
||||
* API client for Hinweisgeberschutzgesetz (HinSchG) compliant
|
||||
* Whistleblower/Hinweisgebersystem management
|
||||
* Connects to the ai-compliance-sdk backend
|
||||
* Split into:
|
||||
* - api-operations.ts (CRUD, workflow, messaging, attachments, statistics)
|
||||
* - api-mock-data.ts (mock data + SDK proxy)
|
||||
*/
|
||||
|
||||
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<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = API_TIMEOUT
|
||||
): Promise<T> {
|
||||
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<ReportListResponse> {
|
||||
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<ReportListResponse>(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne Meldung abrufen (Admin)
|
||||
*/
|
||||
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung aktualisieren (Status, Prioritaet, Kategorie, Zuweisung)
|
||||
*/
|
||||
export async function updateReport(id: string, update: ReportUpdateRequest): Promise<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${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<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${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<WhistleblowerReport> {
|
||||
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<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Untersuchung starten
|
||||
*/
|
||||
export async function startInvestigation(id: string): Promise<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${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<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
|
||||
): Promise<WhistleblowerMeasure> {
|
||||
return fetchWithTimeout<WhistleblowerMeasure>(
|
||||
`${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<WhistleblowerReport> {
|
||||
return fetchWithTimeout<WhistleblowerReport>(
|
||||
`${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<AnonymousMessage> {
|
||||
return fetchWithTimeout<AnonymousMessage>(
|
||||
`${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<AnonymousMessage[]> {
|
||||
return fetchWithTimeout<AnonymousMessage[]>(
|
||||
`${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<FileAttachment> {
|
||||
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<void> {
|
||||
await fetchWithTimeout<void>(
|
||||
`${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATISTICS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Statistiken fuer das Whistleblower-Dashboard abrufen
|
||||
*/
|
||||
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
|
||||
return fetchWithTimeout<WhistleblowerStatistics>(
|
||||
`${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<ReportStatus, number> = {
|
||||
new: 0,
|
||||
acknowledged: 0,
|
||||
under_review: 0,
|
||||
investigation: 0,
|
||||
measures_taken: 0,
|
||||
closed: 0,
|
||||
rejected: 0
|
||||
}
|
||||
|
||||
const byCategory: Record<ReportCategory, number> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
export {
|
||||
fetchReports,
|
||||
fetchReport,
|
||||
updateReport,
|
||||
deleteReport,
|
||||
submitPublicReport,
|
||||
fetchReportByAccessKey,
|
||||
acknowledgeReport,
|
||||
startInvestigation,
|
||||
addMeasure,
|
||||
closeReport,
|
||||
sendMessage,
|
||||
fetchMessages,
|
||||
uploadAttachment,
|
||||
deleteAttachment,
|
||||
fetchWhistleblowerStatistics,
|
||||
} from './api-operations'
|
||||
|
||||
export {
|
||||
fetchSDKWhistleblowerList,
|
||||
createMockReports,
|
||||
createMockStatistics,
|
||||
} from './api-mock-data'
|
||||
|
||||
Reference in New Issue
Block a user