feat: Add Compliance Wiki as internal admin knowledge base
Migration 040 with wiki_categories + wiki_articles tables, 10 seed articles across 8 categories (DSGVO, Art. 9, AVV, HinSchG etc.). Read-only FastAPI API, Next.js proxy, and two-column frontend with full-text search. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: GET /api/sdk/v1/wiki?endpoint=...
|
||||||
|
*
|
||||||
|
* Routes to backend wiki endpoints:
|
||||||
|
* endpoint=categories → GET /api/compliance/v1/wiki/categories
|
||||||
|
* endpoint=articles → GET /api/compliance/v1/wiki/articles(?category_id=...)
|
||||||
|
* endpoint=search → GET /api/compliance/v1/wiki/search?q=...
|
||||||
|
* endpoint=article&id= → GET /api/compliance/v1/wiki/articles/{id}
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const endpoint = searchParams.get('endpoint') || 'categories'
|
||||||
|
|
||||||
|
let backendPath: string
|
||||||
|
|
||||||
|
switch (endpoint) {
|
||||||
|
case 'categories':
|
||||||
|
backendPath = '/api/compliance/v1/wiki/categories'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'articles': {
|
||||||
|
const categoryId = searchParams.get('category_id')
|
||||||
|
backendPath = '/api/compliance/v1/wiki/articles'
|
||||||
|
if (categoryId) {
|
||||||
|
backendPath += `?category_id=${encodeURIComponent(categoryId)}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'article': {
|
||||||
|
const articleId = searchParams.get('id')
|
||||||
|
if (!articleId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing article id' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/wiki/articles/${encodeURIComponent(articleId)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'search': {
|
||||||
|
const query = searchParams.get('q')
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing search query' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/wiki/search?q=${encodeURIComponent(query)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown endpoint: ${endpoint}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(null, { status: 404 })
|
||||||
|
}
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wiki proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
451
admin-compliance/app/sdk/wiki/page.tsx
Normal file
451
admin-compliance/app/sdk/wiki/page.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Search, BookOpen, AlertTriangle, Shield, Scale, Handshake,
|
||||||
|
Briefcase, MessageCircle, Building2, Database, ChevronRight,
|
||||||
|
ArrowLeft, ExternalLink, Tag,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { WikiCategory, WikiArticle, WikiSearchResult } from '@/lib/sdk/types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SIMPLE MARKDOWN RENDERER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
let html = md
|
||||||
|
// Escape HTML
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Tables (must be before other block elements)
|
||||||
|
html = html.replace(
|
||||||
|
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
||||||
|
(_match, header: string, _sep: string, body: string) => {
|
||||||
|
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) => `<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`).join('')
|
||||||
|
const rows = body.trim().split('\n').map((row: string) => {
|
||||||
|
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) => `<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`).join('')
|
||||||
|
return `<tr>${tds}</tr>`
|
||||||
|
}).join('')
|
||||||
|
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
||||||
|
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
||||||
|
|
||||||
|
// Paragraphs (lines that aren't already HTML)
|
||||||
|
html = html.replace(/^(?!<[hultd]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ICON MAP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
Database,
|
||||||
|
Shield,
|
||||||
|
AlertTriangle,
|
||||||
|
Scale,
|
||||||
|
Handshake,
|
||||||
|
Briefcase,
|
||||||
|
MessageCircle,
|
||||||
|
Building2,
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryIcon({ icon, className }: { icon: string; className?: string }) {
|
||||||
|
const Icon = ICON_MAP[icon] || BookOpen
|
||||||
|
return <Icon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RELEVANCE BADGE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function RelevanceBadge({ relevance }: { relevance: string }) {
|
||||||
|
const config = {
|
||||||
|
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch' },
|
||||||
|
important: { bg: 'bg-amber-100 text-amber-800', label: 'Wichtig' },
|
||||||
|
info: { bg: 'bg-blue-100 text-blue-800', label: 'Info' },
|
||||||
|
}[relevance] || { bg: 'bg-gray-100 text-gray-600', label: relevance }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// WIKI PAGE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export default function WikiPage() {
|
||||||
|
const [categories, setCategories] = useState<WikiCategory[]>([])
|
||||||
|
const [articles, setArticles] = useState<WikiArticle[]>([])
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||||
|
const [selectedArticle, setSelectedArticle] = useState<WikiArticle | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<WikiSearchResult[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load categories on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/wiki?endpoint=categories')
|
||||||
|
if (!res.ok) throw new Error('Failed to load categories')
|
||||||
|
const data = await res.json()
|
||||||
|
const cats: WikiCategory[] = (data.categories || []).map((c: Record<string, unknown>) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
description: c.description || '',
|
||||||
|
icon: c.icon || '',
|
||||||
|
sortOrder: c.sort_order ?? 0,
|
||||||
|
articleCount: c.article_count ?? 0,
|
||||||
|
}))
|
||||||
|
setCategories(cats)
|
||||||
|
|
||||||
|
// Load all articles
|
||||||
|
const artRes = await fetch('/api/sdk/v1/wiki?endpoint=articles')
|
||||||
|
if (artRes.ok) {
|
||||||
|
const artData = await artRes.json()
|
||||||
|
setArticles((artData.articles || []).map((a: Record<string, unknown>) => ({
|
||||||
|
id: a.id,
|
||||||
|
categoryId: a.category_id,
|
||||||
|
categoryName: a.category_name,
|
||||||
|
title: a.title,
|
||||||
|
summary: a.summary,
|
||||||
|
content: a.content,
|
||||||
|
legalRefs: a.legal_refs || [],
|
||||||
|
tags: a.tags || [],
|
||||||
|
relevance: a.relevance || 'info',
|
||||||
|
sourceUrls: a.source_urls || [],
|
||||||
|
version: a.version || 1,
|
||||||
|
updatedAt: a.updated_at || '',
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Search handler
|
||||||
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
|
setSearchQuery(query)
|
||||||
|
if (query.length < 2) {
|
||||||
|
setSearchResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/wiki?endpoint=search&q=${encodeURIComponent(query)}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSearchResults((data.results || []).map((r: Record<string, unknown>) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
summary: r.summary,
|
||||||
|
categoryName: r.category_name,
|
||||||
|
relevance: r.relevance || 'info',
|
||||||
|
highlight: r.highlight || '',
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail search
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (searchQuery.length >= 2) handleSearch(searchQuery)
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [searchQuery, handleSearch])
|
||||||
|
|
||||||
|
// Filtered articles for selected category
|
||||||
|
const filteredArticles = useMemo(() => {
|
||||||
|
if (!selectedCategory) return articles
|
||||||
|
return articles.filter(a => a.categoryId === selectedCategory)
|
||||||
|
}, [articles, selectedCategory])
|
||||||
|
|
||||||
|
// Select article from search result
|
||||||
|
const selectFromSearch = (id: string) => {
|
||||||
|
const article = articles.find(a => a.id === id)
|
||||||
|
if (article) {
|
||||||
|
setSelectedArticle(article)
|
||||||
|
setSelectedCategory(article.categoryId)
|
||||||
|
setSearchResults(null)
|
||||||
|
setSearchQuery('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BookOpen className="w-6 h-6 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">Compliance Wiki</h1>
|
||||||
|
<p className="text-xs text-gray-500">Interne Wissensbasis — {articles.length} Artikel in {categories.length} Kategorien</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-80">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Wiki durchsuchen..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{/* Search results dropdown */}
|
||||||
|
{searchResults && searchResults.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto">
|
||||||
|
{searchResults.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => selectFromSearch(r.id)}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{r.title}</span>
|
||||||
|
<RelevanceBadge relevance={r.relevance} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{r.categoryName}</p>
|
||||||
|
{r.highlight && (
|
||||||
|
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{r.highlight.replace(/\*\*/g, '')}</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchResults && searchResults.length === 0 && searchQuery.length >= 2 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 px-4 py-3 text-sm text-gray-500">
|
||||||
|
Keine Ergebnisse fuer "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content: Two columns */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left: Categories */}
|
||||||
|
<div className="w-72 border-r border-gray-200 bg-gray-50 overflow-y-auto flex-shrink-0">
|
||||||
|
<div className="p-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedCategory(null); setSelectedArticle(null) }}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
|
||||||
|
!selectedCategory ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
<span>Alle Artikel</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400">{articles.length}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-0.5">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => { setSelectedCategory(cat.id); setSelectedArticle(null) }}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
selectedCategory === cat.id ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CategoryIcon icon={cat.icon} className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate text-left">{cat.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400 flex-shrink-0">{cat.articleCount}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Article list or detail */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{selectedArticle ? (
|
||||||
|
/* Article detail view */
|
||||||
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedArticle(null)}
|
||||||
|
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Zurueck zur Uebersicht
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<RelevanceBadge relevance={selectedArticle.relevance} />
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{selectedArticle.categoryName} · v{selectedArticle.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">
|
||||||
|
{selectedArticle.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">{selectedArticle.summary}</p>
|
||||||
|
|
||||||
|
{/* Content (rendered markdown) */}
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedArticle.content) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legal References */}
|
||||||
|
{selectedArticle.legalRefs.length > 0 && (
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Rechtsreferenzen</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedArticle.legalRefs.map(ref => (
|
||||||
|
<span key={ref} className="inline-flex items-center gap-1 px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs">
|
||||||
|
<Scale className="w-3 h-3" />
|
||||||
|
{ref}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{selectedArticle.tags.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Tags</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedArticle.tags.map(tag => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source URLs */}
|
||||||
|
{selectedArticle.sourceUrls.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Quellen</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedArticle.sourceUrls.map(url => (
|
||||||
|
<div key={url} className="flex items-center gap-1 text-xs text-blue-600">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{url.startsWith('http') ? (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className="hover:underline">{url}</a>
|
||||||
|
) : (
|
||||||
|
<span>{url}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Article list view */
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 mb-4">
|
||||||
|
{selectedCategory
|
||||||
|
? categories.find(c => c.id === selectedCategory)?.name || 'Artikel'
|
||||||
|
: 'Alle Artikel'
|
||||||
|
}
|
||||||
|
<span className="ml-2 text-sm font-normal text-gray-400">
|
||||||
|
({filteredArticles.length})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{selectedCategory && (
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
{categories.find(c => c.id === selectedCategory)?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredArticles.map(article => (
|
||||||
|
<button
|
||||||
|
key={article.id}
|
||||||
|
onClick={() => setSelectedArticle(article)}
|
||||||
|
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<RelevanceBadge relevance={article.relevance} />
|
||||||
|
{!selectedCategory && (
|
||||||
|
<span className="text-xs text-gray-400">{article.categoryName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{article.summary}</p>
|
||||||
|
{article.legalRefs.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{article.legalRefs.slice(0, 3).map(ref => (
|
||||||
|
<span key={ref} className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
|
||||||
|
{ref}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{article.legalRefs.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-400">+{article.legalRefs.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredArticles.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">
|
||||||
|
Keine Artikel in dieser Kategorie.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -760,6 +760,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
|
<AdditionalModuleItem
|
||||||
|
href="/sdk/wiki"
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label="Compliance Wiki"
|
||||||
|
isActive={pathname?.startsWith('/sdk/wiki')}
|
||||||
|
collapsed={collapsed}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
<AdditionalModuleItem
|
<AdditionalModuleItem
|
||||||
href="/sdk/api-docs"
|
href="/sdk/api-docs"
|
||||||
icon={
|
icon={
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* retry logic, and optimistic locking support.
|
* retry logic, and optimistic locking support.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SDKState, CheckpointStatus, ProjectInfo } from './types'
|
import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } from './types'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -730,6 +730,110 @@ export class SDKApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// WIKI (read-only knowledge base)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all wiki categories with article counts
|
||||||
|
*/
|
||||||
|
async listWikiCategories(): Promise<WikiCategory[]> {
|
||||||
|
const data = await this.fetchWithRetry<{ categories: Array<{
|
||||||
|
id: string; name: string; description: string; icon: string;
|
||||||
|
sort_order: number; article_count: number
|
||||||
|
}> }>(
|
||||||
|
`${this.baseUrl}/wiki?endpoint=categories`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
return (data.categories || []).map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
description: c.description,
|
||||||
|
icon: c.icon,
|
||||||
|
sortOrder: c.sort_order,
|
||||||
|
articleCount: c.article_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List wiki articles, optionally filtered by category
|
||||||
|
*/
|
||||||
|
async listWikiArticles(categoryId?: string): Promise<WikiArticle[]> {
|
||||||
|
const params = new URLSearchParams({ endpoint: 'articles' })
|
||||||
|
if (categoryId) params.set('category_id', categoryId)
|
||||||
|
const data = await this.fetchWithRetry<{ articles: Array<{
|
||||||
|
id: string; category_id: string; category_name: string; title: string;
|
||||||
|
summary: string; content: string; legal_refs: string[]; tags: string[];
|
||||||
|
relevance: string; source_urls: string[]; version: number; updated_at: string
|
||||||
|
}> }>(
|
||||||
|
`${this.baseUrl}/wiki?${params.toString()}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
return (data.articles || []).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
categoryId: a.category_id,
|
||||||
|
categoryName: a.category_name,
|
||||||
|
title: a.title,
|
||||||
|
summary: a.summary,
|
||||||
|
content: a.content,
|
||||||
|
legalRefs: a.legal_refs || [],
|
||||||
|
tags: a.tags || [],
|
||||||
|
relevance: a.relevance as WikiArticle['relevance'],
|
||||||
|
sourceUrls: a.source_urls || [],
|
||||||
|
version: a.version,
|
||||||
|
updatedAt: a.updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single wiki article by ID
|
||||||
|
*/
|
||||||
|
async getWikiArticle(id: string): Promise<WikiArticle> {
|
||||||
|
const data = await this.fetchWithRetry<{
|
||||||
|
id: string; category_id: string; category_name: string; title: string;
|
||||||
|
summary: string; content: string; legal_refs: string[]; tags: string[];
|
||||||
|
relevance: string; source_urls: string[]; version: number; updated_at: string
|
||||||
|
}>(
|
||||||
|
`${this.baseUrl}/wiki?endpoint=article&id=${encodeURIComponent(id)}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
categoryId: data.category_id,
|
||||||
|
categoryName: data.category_name,
|
||||||
|
title: data.title,
|
||||||
|
summary: data.summary,
|
||||||
|
content: data.content,
|
||||||
|
legalRefs: data.legal_refs || [],
|
||||||
|
tags: data.tags || [],
|
||||||
|
relevance: data.relevance as WikiArticle['relevance'],
|
||||||
|
sourceUrls: data.source_urls || [],
|
||||||
|
version: data.version,
|
||||||
|
updatedAt: data.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search across wiki articles
|
||||||
|
*/
|
||||||
|
async searchWiki(query: string): Promise<WikiSearchResult[]> {
|
||||||
|
const data = await this.fetchWithRetry<{ results: Array<{
|
||||||
|
id: string; title: string; summary: string; category_name: string;
|
||||||
|
relevance: string; highlight: string
|
||||||
|
}> }>(
|
||||||
|
`${this.baseUrl}/wiki?endpoint=search&q=${encodeURIComponent(query)}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
return (data.results || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
summary: r.summary,
|
||||||
|
categoryName: r.category_name,
|
||||||
|
relevance: r.relevance,
|
||||||
|
highlight: r.highlight,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health check
|
* Health check
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2398,3 +2398,40 @@ export const DSFA_CATEGORY_LABELS: Record<DSFACategory, string> = {
|
|||||||
process: 'Prozessschritte',
|
process: 'Prozessschritte',
|
||||||
criteria: 'Kriterien',
|
criteria: 'Kriterien',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMPLIANCE WIKI
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface WikiCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
sortOrder: number
|
||||||
|
articleCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiArticle {
|
||||||
|
id: string
|
||||||
|
categoryId: string
|
||||||
|
categoryName: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
content: string
|
||||||
|
legalRefs: string[]
|
||||||
|
tags: string[]
|
||||||
|
relevance: 'critical' | 'important' | 'info'
|
||||||
|
sourceUrls: string[]
|
||||||
|
version: number
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiSearchResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
categoryName: string
|
||||||
|
relevance: string
|
||||||
|
highlight: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from .incident_routes import router as incident_router
|
|||||||
from .change_request_routes import router as change_request_router
|
from .change_request_routes import router as change_request_router
|
||||||
from .generation_routes import router as generation_router
|
from .generation_routes import router as generation_router
|
||||||
from .project_routes import router as project_router
|
from .project_routes import router as project_router
|
||||||
|
from .wiki_routes import router as wiki_router
|
||||||
|
|
||||||
# Include sub-routers
|
# Include sub-routers
|
||||||
router.include_router(audit_router)
|
router.include_router(audit_router)
|
||||||
@@ -65,6 +66,7 @@ router.include_router(incident_router)
|
|||||||
router.include_router(change_request_router)
|
router.include_router(change_request_router)
|
||||||
router.include_router(generation_router)
|
router.include_router(generation_router)
|
||||||
router.include_router(project_router)
|
router.include_router(project_router)
|
||||||
|
router.include_router(wiki_router)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"router",
|
"router",
|
||||||
@@ -98,4 +100,5 @@ __all__ = [
|
|||||||
"change_request_router",
|
"change_request_router",
|
||||||
"generation_router",
|
"generation_router",
|
||||||
"project_router",
|
"project_router",
|
||||||
|
"wiki_router",
|
||||||
]
|
]
|
||||||
|
|||||||
218
backend-compliance/compliance/api/wiki_routes.py
Normal file
218
backend-compliance/compliance/api/wiki_routes.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
FastAPI routes for Compliance Wiki (read-only knowledge base).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /v1/wiki/categories → All categories with article counts
|
||||||
|
- GET /v1/wiki/articles → All articles (optional category filter)
|
||||||
|
- GET /v1/wiki/articles/{id} → Single article
|
||||||
|
- GET /v1/wiki/search → Full-text search (PostgreSQL tsvector)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/v1/wiki", tags=["wiki"])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RESPONSE MODELS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class WikiCategoryResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
sort_order: int
|
||||||
|
article_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class WikiArticleResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
category_id: str
|
||||||
|
category_name: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
content: str
|
||||||
|
legal_refs: list[str]
|
||||||
|
tags: list[str]
|
||||||
|
relevance: str
|
||||||
|
source_urls: list[str]
|
||||||
|
version: int
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSearchResultResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
category_name: str
|
||||||
|
relevance: str
|
||||||
|
highlight: str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HELPERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _article_row_to_response(row) -> dict:
|
||||||
|
"""Convert a DB row to WikiArticleResponse dict."""
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"category_id": row.category_id,
|
||||||
|
"category_name": getattr(row, "category_name", ""),
|
||||||
|
"title": row.title,
|
||||||
|
"summary": row.summary,
|
||||||
|
"content": row.content,
|
||||||
|
"legal_refs": list(row.legal_refs) if row.legal_refs else [],
|
||||||
|
"tags": list(row.tags) if row.tags else [],
|
||||||
|
"relevance": row.relevance or "info",
|
||||||
|
"source_urls": list(row.source_urls) if row.source_urls else [],
|
||||||
|
"version": row.version or 1,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories():
|
||||||
|
"""List all wiki categories with article counts."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = db.execute(text("""
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, c.sort_order,
|
||||||
|
COUNT(a.id) AS article_count
|
||||||
|
FROM compliance_wiki_categories c
|
||||||
|
LEFT JOIN compliance_wiki_articles a ON a.category_id = c.id
|
||||||
|
GROUP BY c.id, c.name, c.description, c.icon, c.sort_order
|
||||||
|
ORDER BY c.sort_order
|
||||||
|
"""))
|
||||||
|
rows = result.fetchall()
|
||||||
|
return {
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"description": row.description or "",
|
||||||
|
"icon": row.icon or "",
|
||||||
|
"sort_order": row.sort_order or 0,
|
||||||
|
"article_count": row.article_count or 0,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles")
|
||||||
|
async def list_articles(
|
||||||
|
category_id: Optional[str] = Query(None, description="Filter by category"),
|
||||||
|
):
|
||||||
|
"""List all wiki articles, optionally filtered by category."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if category_id:
|
||||||
|
result = db.execute(text("""
|
||||||
|
SELECT a.*, c.name AS category_name
|
||||||
|
FROM compliance_wiki_articles a
|
||||||
|
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||||
|
WHERE a.category_id = :category_id
|
||||||
|
ORDER BY
|
||||||
|
CASE a.relevance
|
||||||
|
WHEN 'critical' THEN 0
|
||||||
|
WHEN 'important' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
a.title
|
||||||
|
"""), {"category_id": category_id})
|
||||||
|
else:
|
||||||
|
result = db.execute(text("""
|
||||||
|
SELECT a.*, c.name AS category_name
|
||||||
|
FROM compliance_wiki_articles a
|
||||||
|
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||||
|
ORDER BY c.sort_order,
|
||||||
|
CASE a.relevance
|
||||||
|
WHEN 'critical' THEN 0
|
||||||
|
WHEN 'important' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
a.title
|
||||||
|
"""))
|
||||||
|
rows = result.fetchall()
|
||||||
|
return {
|
||||||
|
"articles": [_article_row_to_response(row) for row in rows],
|
||||||
|
"total": len(rows),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles/{article_id}")
|
||||||
|
async def get_article(article_id: str):
|
||||||
|
"""Get a single wiki article by ID."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = db.execute(text("""
|
||||||
|
SELECT a.*, c.name AS category_name
|
||||||
|
FROM compliance_wiki_articles a
|
||||||
|
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||||
|
WHERE a.id = :article_id
|
||||||
|
"""), {"article_id": article_id})
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
return _article_row_to_response(row)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search_wiki(
|
||||||
|
q: str = Query(..., min_length=2, description="Search query"),
|
||||||
|
):
|
||||||
|
"""Full-text search across wiki articles using PostgreSQL tsvector."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = db.execute(text("""
|
||||||
|
SELECT a.id, a.title, a.summary, a.relevance,
|
||||||
|
c.name AS category_name,
|
||||||
|
ts_headline('german', a.content, plainto_tsquery('german', :query),
|
||||||
|
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS highlight
|
||||||
|
FROM compliance_wiki_articles a
|
||||||
|
JOIN compliance_wiki_categories c ON c.id = a.category_id
|
||||||
|
WHERE to_tsvector('german', a.title || ' ' || a.summary || ' ' || a.content)
|
||||||
|
@@ plainto_tsquery('german', :query)
|
||||||
|
ORDER BY
|
||||||
|
ts_rank(to_tsvector('german', a.title || ' ' || a.summary || ' ' || a.content),
|
||||||
|
plainto_tsquery('german', :query)) DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""), {"query": q})
|
||||||
|
rows = result.fetchall()
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title,
|
||||||
|
"summary": row.summary,
|
||||||
|
"category_name": row.category_name,
|
||||||
|
"relevance": row.relevance or "info",
|
||||||
|
"highlight": row.highlight or "",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
"total": len(rows),
|
||||||
|
"query": q,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
465
backend-compliance/migrations/040_compliance_wiki.sql
Normal file
465
backend-compliance/migrations/040_compliance_wiki.sql
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
-- Migration 040: Compliance Wiki (Strukturierte Wissensbasis)
|
||||||
|
-- Interne Admin-Wissensbasis fuer DSGVO/Compliance-Fachwissen.
|
||||||
|
-- System-Eintraege (read-only), kein tenant_id — globale Daten.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. Wiki-Kategorien
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_wiki_categories (
|
||||||
|
id VARCHAR(100) PRIMARY KEY,
|
||||||
|
name VARCHAR(300) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
icon VARCHAR(50) DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. Wiki-Artikel
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_wiki_articles (
|
||||||
|
id VARCHAR(100) PRIMARY KEY,
|
||||||
|
category_id VARCHAR(100) NOT NULL REFERENCES compliance_wiki_categories(id),
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
legal_refs TEXT[] DEFAULT '{}',
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
relevance VARCHAR(20) DEFAULT 'info',
|
||||||
|
source_urls TEXT[] DEFAULT '{}',
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wiki_articles_category ON compliance_wiki_articles(category_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wiki_articles_tags ON compliance_wiki_articles USING GIN(tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wiki_articles_search ON compliance_wiki_articles
|
||||||
|
USING GIN(to_tsvector('german', title || ' ' || summary || ' ' || content));
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 3. Seed-Daten: Kategorien
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO compliance_wiki_categories (id, name, description, icon, sort_order) VALUES
|
||||||
|
('datenkategorien', 'Datenkategorien & Abgrenzung', 'Welche personenbezogenen Daten gibt es und wie grenzt man sie voneinander ab?', 'Database', 10),
|
||||||
|
('dsgvo-grundlagen', 'DSGVO-Grundlagen', 'Grundlegende Konzepte der Datenschutz-Grundverordnung', 'Shield', 20),
|
||||||
|
('art9-besondere', 'Besondere Kategorien (Art. 9)', 'Besonders schuetzenswerte Daten nach Art. 9 DSGVO', 'AlertTriangle', 30),
|
||||||
|
('rechtsgrundlagen', 'Rechtsgrundlagen', 'Die sechs Rechtsgrundlagen fuer die Datenverarbeitung', 'Scale', 40),
|
||||||
|
('avv-dienstleister', 'Auftragsverarbeitung (AVV)', 'Regeln fuer externe Dienstleister, die Daten verarbeiten', 'Handshake', 50),
|
||||||
|
('arbeitsrecht', 'Arbeitsrecht & Compliance', 'Datenschutz im Arbeitsverhaeltnis', 'Briefcase', 60),
|
||||||
|
('hinschg', 'Hinweisgeberschutz (HinSchG)', 'Pflichten zum Schutz von Hinweisgebern', 'MessageCircle', 70),
|
||||||
|
('branchenspezifisch', 'Branchenspezifisches', 'Besonderheiten einzelner Branchen', 'Building2', 80)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 4. Seed-Daten: Artikel
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 1. Gesundheitsdaten — Abgrenzung
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('gesundheitsdaten-abgrenzung', 'datenkategorien',
|
||||||
|
'Gesundheitsdaten — Was zaehlt dazu und was nicht?',
|
||||||
|
'Nicht alles, was mit Gesundheit zu tun hat, ist automatisch ein Gesundheitsdatum im Sinne der DSGVO. Die Abgrenzung ist in der Praxis wichtig.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Der Name der Krankenkasse (z.B. "AOK Bayern", "TK") ist **kein Gesundheitsdatum** nach Art. 9 DSGVO. Er verraet nichts ueber den Gesundheitszustand einer Person — jeder Arbeitnehmer hat eine Krankenkasse, unabhaengig davon ob er gesund oder krank ist.
|
||||||
|
|
||||||
|
## Was SIND Gesundheitsdaten?
|
||||||
|
|
||||||
|
- Diagnosen, Krankheitsbilder, Befunde
|
||||||
|
- Krankmeldungen (AU-Bescheinigungen) mit Diagnose
|
||||||
|
- Schwerbehindertenausweis / Grad der Behinderung
|
||||||
|
- Medikamenteneinnahme
|
||||||
|
- Ergebnisse von Eignungsuntersuchungen
|
||||||
|
|
||||||
|
## Was sind KEINE Gesundheitsdaten?
|
||||||
|
|
||||||
|
- Name der Krankenkasse (reine Verwaltungsinformation)
|
||||||
|
- Anzahl Krankheitstage (ohne Diagnose)
|
||||||
|
- Versichertennummer
|
||||||
|
- Beitragssatz
|
||||||
|
|
||||||
|
## Warum ist das wichtig?
|
||||||
|
|
||||||
|
Gesundheitsdaten unterliegen dem besonderen Schutz nach Art. 9 DSGVO. Fuer ihre Verarbeitung braucht man eine **ausdrueckliche Rechtsgrundlage** (z.B. § 26 Abs. 3 BDSG im Beschaeftigungsverhaeltnis). Verwaltungsdaten wie der Krankenkassenname fallen unter die normalen Regeln.
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Wenn Sie im VVT oder in der DSFA Datenkategorien zuordnen: Pruefen Sie genau, ob ein Datum tatsaechlich Rueckschluesse auf den Gesundheitszustand zulaesst. Nur dann ist es ein Art.-9-Datum.',
|
||||||
|
ARRAY['Art. 9 DSGVO', '§ 26 Abs. 3 BDSG', 'ErwGr. 35 DSGVO'],
|
||||||
|
ARRAY['gesundheit', 'art9', 'abgrenzung', 'krankenkasse'],
|
||||||
|
'critical',
|
||||||
|
ARRAY['https://www.bfdi.bund.de', 'EuGH C-184/20'])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. Beschaeftigtendaten — Umfang
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('beschaeftigtendaten-umfang', 'datenkategorien',
|
||||||
|
'Beschaeftigtendaten — Was gehoert alles dazu?',
|
||||||
|
'Beschaeftigtendaten umfassen weit mehr als Name und Adresse. Hier eine Uebersicht der typischen Datenkategorien im Arbeitsverhaeltnis.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Im Arbeitsverhaeltnis fallen viele verschiedene personenbezogene Daten an. Sie alle unterliegen dem Beschaeftigtendatenschutz nach § 26 BDSG.
|
||||||
|
|
||||||
|
## Typische Beschaeftigtendaten
|
||||||
|
|
||||||
|
### Stammdaten
|
||||||
|
- Name, Adresse, Geburtsdatum
|
||||||
|
- Steuer-ID, Sozialversicherungsnummer
|
||||||
|
- Bankverbindung (fuer Gehaltsauszahlung)
|
||||||
|
|
||||||
|
### Vertragsdaten
|
||||||
|
- Arbeitsvertrag, Stellenbeschreibung
|
||||||
|
- Gehalt, Zulagen, Bonusvereinbarungen
|
||||||
|
- Arbeitszeit, Urlaubsanspruch
|
||||||
|
|
||||||
|
### Verwaltungsdaten
|
||||||
|
- Krankenkassenname, Beitragssatz
|
||||||
|
- Steuerklasse, Kinderfreibetraege
|
||||||
|
- Kirchensteuermerkmal
|
||||||
|
|
||||||
|
### Leistungsdaten
|
||||||
|
- Beurteilungen, Zielvereinbarungen
|
||||||
|
- Fortbildungsnachweise, Zertifikate
|
||||||
|
- Abmahnungen, Zwischenzeugnisse
|
||||||
|
|
||||||
|
## Abgrenzung zu Art.-9-Daten
|
||||||
|
|
||||||
|
Das **Kirchensteuermerkmal** verraet die Religionszugehoerigkeit und ist damit ein Art.-9-Datum. Die Steuerklasse hingegen ist ein normales Verwaltungsdatum.
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Erfassen Sie im VVT die Beschaeftigtendaten moeglichst nach Kategorien getrennt (Stammdaten, Vertragsdaten etc.). Das erleichtert spaeter die Zuordnung von Loeschfristen und Zugriffsrechten.',
|
||||||
|
ARRAY['§ 26 BDSG', 'Art. 6 Abs. 1b DSGVO', 'Art. 88 DSGVO'],
|
||||||
|
ARRAY['beschaeftigte', 'personal', 'stammdaten', 'lohnabrechnung'],
|
||||||
|
'important',
|
||||||
|
ARRAY[])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 3. Arbeitszeiterfassung — Pflicht
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('arbeitszeiterfassung-pflicht', 'arbeitsrecht',
|
||||||
|
'Arbeitszeiterfassung — Wer muss was erfassen?',
|
||||||
|
'Seit dem BAG-Beschluss 2022 besteht in Deutschland eine Pflicht zur systematischen Arbeitszeiterfassung. Das betrifft fast alle Unternehmen.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Das Bundesarbeitsgericht hat im September 2022 entschieden, dass Arbeitgeber die Arbeitszeiten ihrer Mitarbeiter systematisch erfassen muessen. Diese Pflicht ergibt sich aus dem Arbeitsschutzgesetz.
|
||||||
|
|
||||||
|
## Was muss erfasst werden?
|
||||||
|
|
||||||
|
- **Beginn** und **Ende** der taeglichen Arbeitszeit
|
||||||
|
- **Dauer** der Arbeitszeit
|
||||||
|
- **Ueberstunden** und Mehrarbeit
|
||||||
|
- Einhaltung der **Ruhezeiten** (mind. 11 Stunden)
|
||||||
|
- Einhaltung der **Pausenregelungen**
|
||||||
|
|
||||||
|
## Wer ist betroffen?
|
||||||
|
|
||||||
|
Grundsaetzlich alle Arbeitgeber — unabhaengig von der Unternehmensgroesse. Ausnahmen gibt es nur in sehr engen Grenzen (z.B. leitende Angestellte nach § 18 ArbZG).
|
||||||
|
|
||||||
|
## Datenschutz-Aspekte
|
||||||
|
|
||||||
|
Die Arbeitszeitdaten sind **personenbezogene Daten**. Die Rechtsgrundlage fuer die Erfassung ist die **rechtliche Verpflichtung** (Art. 6 Abs. 1c DSGVO i.V.m. § 3 ArbZG).
|
||||||
|
|
||||||
|
Wichtig: Die Daten duerfen **nicht** fuer andere Zwecke verwendet werden (z.B. Leistungskontrolle), es sei denn, es gibt dafuer eine eigene Rechtsgrundlage.
|
||||||
|
|
||||||
|
## Aufbewahrungsfrist
|
||||||
|
|
||||||
|
Arbeitszeitaufzeichnungen muessen mindestens **2 Jahre** aufbewahrt werden (§ 16 Abs. 2 ArbZG).
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Setzen Sie im VVT eine eigene Verarbeitungstaetigkeit "Arbeitszeiterfassung" auf und ordnen Sie die passende Rechtsgrundlage (Art. 6 Abs. 1c) zu.',
|
||||||
|
ARRAY['§ 3 ArbZG', '§ 16 Abs. 2 ArbZG', 'Art. 6 Abs. 1c DSGVO', 'BAG 1 ABR 22/21'],
|
||||||
|
ARRAY['arbeitszeit', 'zeiterfassung', 'bag', 'pflicht'],
|
||||||
|
'critical',
|
||||||
|
ARRAY['BAG 1 ABR 22/21 (13.09.2022)', 'EuGH C-55/18 (CCOO)'])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. HinSchG — Grundlagen
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('hinschg-grundlagen', 'hinschg',
|
||||||
|
'Hinweisgeberschutzgesetz — Ab wann gilt was?',
|
||||||
|
'Seit Dezember 2023 muessen alle Unternehmen ab 50 Mitarbeitern eine interne Meldestelle einrichten. Das hat auch datenschutzrechtliche Auswirkungen.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Das Hinweisgeberschutzgesetz (HinSchG) schuetzt Personen, die auf Missstaende in Unternehmen hinweisen ("Whistleblower"). Seit dem 17. Dezember 2023 gilt die volle Pflicht fuer Unternehmen ab 50 Beschaeftigten.
|
||||||
|
|
||||||
|
## Kernpflichten
|
||||||
|
|
||||||
|
### Interne Meldestelle einrichten
|
||||||
|
- Kann eine **interne Person** oder ein **externer Dienstleister** sein
|
||||||
|
- Meldungen muessen **muendlich, schriftlich und persoenlich** moeglich sein
|
||||||
|
- Eingangsbestaetigung innerhalb von **7 Tagen**
|
||||||
|
- Rueckmeldung an den Hinweisgeber innerhalb von **3 Monaten**
|
||||||
|
|
||||||
|
### Vertraulichkeitsgebot (§ 8 HinSchG)
|
||||||
|
- Die **Identitaet des Hinweisgebers** darf nur den zustaendigen Personen bekannt sein
|
||||||
|
- Verstoss ist bussgeldbewehrt (bis 50.000 EUR)
|
||||||
|
|
||||||
|
## Welche Daten fallen an?
|
||||||
|
|
||||||
|
- Identitaet des Hinweisgebers (besonders schuetzenswert!)
|
||||||
|
- Beschuldigte Personen
|
||||||
|
- Zeugen und weitere Beteiligte
|
||||||
|
- Inhalt der Meldung (kann sensible Daten enthalten)
|
||||||
|
- Kommunikationsverlauf
|
||||||
|
|
||||||
|
## Datenschutz-Anforderungen
|
||||||
|
|
||||||
|
- **Eigene Verarbeitungstaetigkeit** im VVT anlegen
|
||||||
|
- Rechtsgrundlage: Art. 6 Abs. 1c DSGVO (rechtliche Verpflichtung)
|
||||||
|
- **Zugriffsbeschraenkung:** Nur die benannte Meldestelle darf auf die Daten zugreifen
|
||||||
|
- **Loeschfrist:** 3 Jahre nach Abschluss des Verfahrens (§ 11 Abs. 5 HinSchG)
|
||||||
|
- Bei Art.-9-Daten in Meldungen: besondere Schutzmassnahmen erforderlich
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Pruefen Sie bei externen Meldestellen-Anbietern, ob ein **AVV** erforderlich ist. In den meisten Faellen ja — der Anbieter verarbeitet personenbezogene Daten in Ihrem Auftrag.',
|
||||||
|
ARRAY['§ 8 HinSchG', '§ 11 Abs. 5 HinSchG', '§ 12 HinSchG', 'Art. 6 Abs. 1c DSGVO'],
|
||||||
|
ARRAY['hinweisgeberschutz', 'whistleblower', 'meldestelle', 'vertraulichkeit'],
|
||||||
|
'critical',
|
||||||
|
ARRAY[])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 5. AVV — Website-Betrieb
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('avv-website-betrieb', 'avv-dienstleister',
|
||||||
|
'AVV beim Website-Betrieb — Wer braucht einen Vertrag?',
|
||||||
|
'Beim Betrieb einer Website sind fast immer externe Dienstleister beteiligt. Fuer die meisten davon brauchen Sie einen Auftragsverarbeitungsvertrag.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Sobald ein externer Dienstleister in Ihrem Auftrag personenbezogene Daten verarbeitet, brauchen Sie einen **Auftragsverarbeitungsvertrag (AVV)** nach Art. 28 DSGVO.
|
||||||
|
|
||||||
|
## Typische AVV-Pflichten beim Website-Betrieb
|
||||||
|
|
||||||
|
| Dienstleister | AVV noetig? | Grund |
|
||||||
|
|--------------|-------------|-------|
|
||||||
|
| Hosting-Anbieter | Ja | Zugriff auf Server-Logs mit IP-Adressen |
|
||||||
|
| Newsletter-Tool | Ja | Verarbeitet E-Mail-Adressen |
|
||||||
|
| Analytics (Matomo gehostet) | Ja | Verarbeitet Nutzungsdaten |
|
||||||
|
| Cookie-Consent-Tool | Kommt drauf an | Nur wenn Daten beim Anbieter liegen |
|
||||||
|
| CDN (Cloudflare etc.) | Ja | IP-Adressen werden verarbeitet |
|
||||||
|
| Externer IT-Support | Ja | Potentieller Zugriff auf alle Daten |
|
||||||
|
|
||||||
|
## Was muss im AVV stehen?
|
||||||
|
|
||||||
|
- **Gegenstand und Dauer** der Verarbeitung
|
||||||
|
- **Art und Zweck** der Verarbeitung
|
||||||
|
- **Art der personenbezogenen Daten** (IP-Adressen, E-Mails etc.)
|
||||||
|
- **Kategorien betroffener Personen** (Website-Besucher, Newsletter-Abonnenten)
|
||||||
|
- **Technisch-organisatorische Massnahmen (TOMs)** des Dienstleisters
|
||||||
|
- **Unterauftragsverarbeiter** — muessen genehmigt werden
|
||||||
|
|
||||||
|
## Cookies & Analytics
|
||||||
|
|
||||||
|
Auch wenn Sie einen AVV mit dem Analytics-Anbieter haben: Die **datenschutzrechtliche Verantwortung** bleibt bei Ihnen! Sie muessen sicherstellen, dass eine gueltige Einwilligung vorliegt (§ 25 TDDDG).
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Fuehren Sie eine **Liste aller Dienstleister** mit Website-Bezug und pruefen Sie fuer jeden, ob ein AVV vorliegt. Viele Anbieter bieten Standard-AVVs zum Download an.',
|
||||||
|
ARRAY['Art. 28 DSGVO', '§ 25 TDDDG', 'Art. 32 DSGVO'],
|
||||||
|
ARRAY['avv', 'website', 'hosting', 'analytics', 'dienstleister'],
|
||||||
|
'important',
|
||||||
|
ARRAY[])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 6. AVV — Lohnbuchhaltung
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('avv-lohnbuchhaltung', 'avv-dienstleister',
|
||||||
|
'AVV bei externer Lohnbuchhaltung',
|
||||||
|
'Wer die Lohnabrechnung an einen externen Dienstleister auslagert, braucht zwingend einen Auftragsverarbeitungsvertrag — denn es werden sensible Beschaeftigtendaten uebermittelt.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Die externe Lohnbuchhaltung ist einer der haeufigsten Faelle von Auftragsverarbeitung. Der Dienstleister erhaelt umfangreiche personenbezogene Daten Ihrer Beschaeftigten.
|
||||||
|
|
||||||
|
## Welche Daten werden uebermittelt?
|
||||||
|
|
||||||
|
- Name, Adresse, Geburtsdatum
|
||||||
|
- Sozialversicherungsnummer
|
||||||
|
- Steuer-ID, Steuerklasse
|
||||||
|
- Krankenkasse, Beitragssaetze
|
||||||
|
- Gehalt, Zulagen, Praemien
|
||||||
|
- Arbeitszeiten, Fehlzeiten
|
||||||
|
- Ggf. Kirchensteuermerkmal (Art.-9-Datum!)
|
||||||
|
- Ggf. Pfaendungsdaten
|
||||||
|
|
||||||
|
## AVV-Pflicht
|
||||||
|
|
||||||
|
Ein AVV nach Art. 28 DSGVO ist **zwingend erforderlich**. Der Dienstleister handelt weisungsgebunden in Ihrem Auftrag.
|
||||||
|
|
||||||
|
## Besondere Schutzmassnahmen
|
||||||
|
|
||||||
|
Da potenziell Art.-9-Daten betroffen sind (Kirchensteuermerkmal → Religion), sollten folgende TOMs beim Dienstleister nachgewiesen werden:
|
||||||
|
|
||||||
|
- **Verschluesselung** der Datenuebertragung
|
||||||
|
- **Zugriffsbeschraenkung** auf die Lohndaten
|
||||||
|
- **Protokollierung** aller Zugriffe
|
||||||
|
- **Regelmaessige Audits** des Dienstleisters
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Pruefen Sie, ob der Lohnbuchhaltungs-Dienstleister seinerseits **Unterauftragsverarbeiter** einsetzt (z.B. Cloud-Hosting, DATEV). Diese muessen im AVV aufgefuehrt sein.',
|
||||||
|
ARRAY['Art. 28 DSGVO', '§ 26 BDSG', 'Art. 9 DSGVO', 'Art. 32 DSGVO'],
|
||||||
|
ARRAY['avv', 'lohnbuchhaltung', 'personal', 'beschaeftigte'],
|
||||||
|
'important',
|
||||||
|
ARRAY[])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 7. Religion bei Bewerbungen
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('religion-bewerbung', 'art9-besondere',
|
||||||
|
'Religion im Bewerbungsverfahren — Was darf gefragt werden?',
|
||||||
|
'Die Religionszugehoerigkeit ist ein besonders geschuetztes Datum nach Art. 9 DSGVO. Im Bewerbungsverfahren gelten strenge Regeln.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Die Religionszugehoerigkeit faellt unter die **besonderen Kategorien** personenbezogener Daten (Art. 9 DSGVO). Im Bewerbungsverfahren darf grundsaetzlich **nicht** danach gefragt werden.
|
||||||
|
|
||||||
|
## Frageverbote
|
||||||
|
|
||||||
|
Das **Allgemeine Gleichbehandlungsgesetz (AGG)** verbietet die Benachteiligung wegen der Religion. Daraus folgt:
|
||||||
|
|
||||||
|
- **Keine Frage** nach der Religionszugehoerigkeit im Bewerbungsgespraech
|
||||||
|
- **Kein Feld** "Religion" im Bewerbungsformular
|
||||||
|
- **Keine Rueckschluesse** aus dem Lebenslauf ziehen (z.B. Mitgliedschaft in religioesen Organisationen)
|
||||||
|
|
||||||
|
## Ausnahmen
|
||||||
|
|
||||||
|
Eine Ausnahme gilt fuer **Tendenzbetriebe** (z.B. kirchliche Einrichtungen). Hier kann die Religionszugehoerigkeit eine wesentliche und gerechtfertigte berufliche Anforderung sein — allerdings mit Einschraenkungen nach der EuGH-Rechtsprechung.
|
||||||
|
|
||||||
|
## Wann Religion doch relevant wird
|
||||||
|
|
||||||
|
Spaetestens bei der **Lohnabrechnung** wird die Religionszugehoerigkeit relevant, weil das Kirchensteuermerkmal uebermittelt werden muss. Dies ist dann durch **§ 26 Abs. 3 BDSG** gedeckt.
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Gestalten Sie Bewerbungsformulare so, dass keine besonderen Kategorien abgefragt werden. Fuehren Sie im VVT die Verarbeitung "Bewerbermanagement" mit den richtigen Datenkategorien — und listen Sie Religion dort **nicht** auf.',
|
||||||
|
ARRAY['Art. 9 DSGVO', '§ 1 AGG', '§ 26 Abs. 3 BDSG', 'Art. 4 Nr. 13 DSGVO'],
|
||||||
|
ARRAY['religion', 'bewerbung', 'art9', 'agg', 'diskriminierung'],
|
||||||
|
'important',
|
||||||
|
ARRAY['EuGH C-414/16 (Egenberger)'])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 8. Kontaktdaten von Ansprechpartnern
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('kontaktdaten-ansprechpartner', 'datenkategorien',
|
||||||
|
'Kontaktdaten von Kunden- und Lieferanten-Ansprechpartnern',
|
||||||
|
'Auch die Kontaktdaten von Ansprechpartnern bei Geschaeftspartnern sind personenbezogene Daten und muessen datenschutzkonform verarbeitet werden.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
In jedem CRM-System, jeder SAP-Kontaktpflege und jedem E-Mail-Verteiler werden personenbezogene Daten von **Ansprechpartnern** bei Kunden und Lieferanten gespeichert. Diese Daten unterliegen der DSGVO.
|
||||||
|
|
||||||
|
## Typische Daten
|
||||||
|
|
||||||
|
- Name, Vorname, Titel
|
||||||
|
- Geschaeftliche E-Mail-Adresse
|
||||||
|
- Geschaeftliche Telefonnummer
|
||||||
|
- Position / Abteilung
|
||||||
|
- Ggf. Foto (z.B. in Kontaktdatenbanken)
|
||||||
|
|
||||||
|
## Rechtsgrundlage
|
||||||
|
|
||||||
|
Die uebliche Rechtsgrundlage ist das **berechtigte Interesse** (Art. 6 Abs. 1f DSGVO). Die Geschaeftsbeziehung macht es erforderlich, Ansprechpartner zu kennen und zu kontaktieren.
|
||||||
|
|
||||||
|
## Informationspflicht
|
||||||
|
|
||||||
|
Auch Ansprechpartner bei Geschaeftspartnern muessen ueber die Datenverarbeitung informiert werden (Art. 13/14 DSGVO). In der Praxis geschieht das oft ueber:
|
||||||
|
- Einen Datenschutzhinweis in der E-Mail-Signatur
|
||||||
|
- Einen Link zur Datenschutzerklaerung in der Auftragsbestaetigung
|
||||||
|
- Einen separaten Datenschutzhinweis bei Vertragsabschluss
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Fuehren Sie im VVT eine Verarbeitungstaetigkeit "Kunden-/Lieferantenmanagement" mit der Datenkategorie "Geschaeftliche Kontaktdaten" auf. Die Rechtsgrundlage ist in der Regel Art. 6 Abs. 1f DSGVO.',
|
||||||
|
ARRAY['Art. 6 Abs. 1f DSGVO', 'Art. 13 DSGVO', 'Art. 14 DSGVO'],
|
||||||
|
ARRAY['kontaktdaten', 'crm', 'kunden', 'lieferanten', 'b2b'],
|
||||||
|
'info',
|
||||||
|
ARRAY[])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 9. Gemeinsame Verantwortlichkeit
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('gemeinsame-verantwortlichkeit', 'dsgvo-grundlagen',
|
||||||
|
'Gemeinsame Verantwortlichkeit vs. Auftragsverarbeitung',
|
||||||
|
'Die Abgrenzung zwischen Art. 26 (gemeinsame Verantwortlichkeit) und Art. 28 (Auftragsverarbeitung) ist in der Praxis oft schwierig, aber entscheidend fuer die richtige vertragliche Gestaltung.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Wenn zwei oder mehr Stellen gemeinsam ueber **Zwecke und Mittel** der Datenverarbeitung entscheiden, liegt eine **gemeinsame Verantwortlichkeit** nach Art. 26 DSGVO vor. Das ist etwas anderes als eine Auftragsverarbeitung (Art. 28), bei der ein Dienstleister weisungsgebunden handelt.
|
||||||
|
|
||||||
|
## Auftragsverarbeitung (Art. 28)
|
||||||
|
|
||||||
|
Der Auftragsverarbeiter:
|
||||||
|
- Handelt **weisungsgebunden**
|
||||||
|
- Entscheidet **nicht** ueber Zweck und Mittel
|
||||||
|
- Verarbeitet Daten **nur im Auftrag** des Verantwortlichen
|
||||||
|
|
||||||
|
**Beispiele:** Hosting-Anbieter, externe Lohnbuchhaltung, Cloud-Speicher
|
||||||
|
|
||||||
|
## Gemeinsame Verantwortlichkeit (Art. 26)
|
||||||
|
|
||||||
|
Beide Parteien:
|
||||||
|
- Entscheiden **gemeinsam** ueber Zwecke und/oder Mittel
|
||||||
|
- Haben **eigene Interessen** an der Verarbeitung
|
||||||
|
- Muessen eine **Vereinbarung** ueber ihre jeweiligen Pflichten treffen
|
||||||
|
|
||||||
|
**Beispiele:**
|
||||||
|
- Facebook-Fanpage (EuGH Wirtschaftsakademie)
|
||||||
|
- Gemeinsame Kundendatenbank zweier Unternehmen
|
||||||
|
- Konzernweites HR-System mit gemeinsamer Steuerung
|
||||||
|
|
||||||
|
## Wann wird aus AVV eine gemeinsame Verantwortlichkeit?
|
||||||
|
|
||||||
|
Sobald der "Auftragsverarbeiter" beginnt, Daten fuer **eigene Zwecke** zu nutzen (z.B. eigene Analysen, Produktverbesserung mit Kundendaten), verschiebt sich die Rolle Richtung gemeinsame Verantwortlichkeit.
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Pruefen Sie bei jedem Dienstleister: Hat er ein **eigenes Interesse** an den Daten? Nutzt er sie fuer **eigene Zwecke**? Wenn ja, brauchen Sie eine Art.-26-Vereinbarung statt eines AVV.',
|
||||||
|
ARRAY['Art. 26 DSGVO', 'Art. 28 DSGVO', 'Art. 4 Nr. 7 DSGVO'],
|
||||||
|
ARRAY['art26', 'art28', 'avv', 'verantwortlichkeit', 'joint-controller'],
|
||||||
|
'important',
|
||||||
|
ARRAY['EuGH C-210/16 (Wirtschaftsakademie)', 'EuGH C-40/17 (Fashion ID)'])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 10. Qualifikationsdaten
|
||||||
|
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||||
|
('qualifikationsdaten', 'datenkategorien',
|
||||||
|
'Qualifikationsdaten — Fortbildungen, Zertifikate, Schulungsnachweise',
|
||||||
|
'Qualifikationsdaten gehoeren zu den Beschaeftigtendaten und unterliegen besonderen Aufbewahrungsregeln.',
|
||||||
|
'## Ueberblick
|
||||||
|
|
||||||
|
Qualifikationsdaten dokumentieren die beruflichen Faehigkeiten und Weiterbildungen von Beschaeftigten. Sie sind personenbezogene Daten und gehoeren zu den Beschaeftigtendaten.
|
||||||
|
|
||||||
|
## Was sind Qualifikationsdaten?
|
||||||
|
|
||||||
|
- Abschlusszeugnisse und Studiennachweise
|
||||||
|
- Berufliche Zertifizierungen (z.B. ISO-Auditor, Datenschutzbeauftragter)
|
||||||
|
- Teilnahmenachweise fuer Fortbildungen
|
||||||
|
- Schulungsnachweise (z.B. Arbeitssicherheit, Datenschutz)
|
||||||
|
- Fuehrerscheine / Fahrerlaubnisse (bei Relevanz fuer den Job)
|
||||||
|
- Sprachkenntnisse, IT-Kenntnisse
|
||||||
|
|
||||||
|
## Rechtsgrundlage
|
||||||
|
|
||||||
|
- **Waehrend des Arbeitsverhaeltnisses:** § 26 BDSG (Durchfuehrung des Beschaeftigungsverhaeltnisses)
|
||||||
|
- **Bei Pflichtschulungen:** Art. 6 Abs. 1c DSGVO (z.B. Arbeitssicherheitsunterweisungen)
|
||||||
|
|
||||||
|
## Aufbewahrungsfristen
|
||||||
|
|
||||||
|
| Datum | Frist | Grund |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Unterweisungsnachweise (Arbeitssicherheit) | Dauer des Arbeitsverhaeltnisses | ArbSchG |
|
||||||
|
| Fortbildungsnachweise | 3 Jahre nach Ende des AV | Nachweis der Personalentwicklung |
|
||||||
|
| Pflichtschulungen (z.B. Datenschutz) | 3 Jahre nach Durchfuehrung | Nachweispflicht |
|
||||||
|
| Fuehrerscheinkopien | Regelmaessige Ueberpruefung | UVV |
|
||||||
|
|
||||||
|
## Praxis-Tipp
|
||||||
|
|
||||||
|
Fuehren Sie Qualifikationsdaten als eigene Datenkategorie im VVT. Achten Sie auf die **Zweckbindung**: Schulungsnachweise zum Datenschutz duerfen nicht fuer die Leistungsbewertung herangezogen werden.',
|
||||||
|
ARRAY['§ 26 BDSG', 'Art. 6 Abs. 1c DSGVO', 'Art. 17 DSGVO'],
|
||||||
|
ARRAY['qualifikation', 'fortbildung', 'schulung', 'zertifikate', 'personal'],
|
||||||
|
'info',
|
||||||
|
ARRAY[])
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
Reference in New Issue
Block a user