From 11d4c2fd36fe231512054893ccc6bde8f17442ec Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 9 Mar 2026 20:01:27 +0100 Subject: [PATCH] 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 --- admin-compliance/app/api/sdk/v1/wiki/route.ts | 87 ++++ admin-compliance/app/sdk/wiki/page.tsx | 451 +++++++++++++++++ .../components/sdk/Sidebar/SDKSidebar.tsx | 13 + admin-compliance/lib/sdk/api-client.ts | 106 +++- admin-compliance/lib/sdk/types.ts | 37 ++ backend-compliance/compliance/api/__init__.py | 3 + .../compliance/api/wiki_routes.py | 218 ++++++++ .../migrations/040_compliance_wiki.sql | 465 ++++++++++++++++++ 8 files changed, 1379 insertions(+), 1 deletion(-) create mode 100644 admin-compliance/app/api/sdk/v1/wiki/route.ts create mode 100644 admin-compliance/app/sdk/wiki/page.tsx create mode 100644 backend-compliance/compliance/api/wiki_routes.py create mode 100644 backend-compliance/migrations/040_compliance_wiki.sql diff --git a/admin-compliance/app/api/sdk/v1/wiki/route.ts b/admin-compliance/app/api/sdk/v1/wiki/route.ts new file mode 100644 index 0000000..8e3c48c --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/wiki/route.ts @@ -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 } + ) + } +} diff --git a/admin-compliance/app/sdk/wiki/page.tsx b/admin-compliance/app/sdk/wiki/page.tsx new file mode 100644 index 0000000..d1d89eb --- /dev/null +++ b/admin-compliance/app/sdk/wiki/page.tsx @@ -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, '>') + + // 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) => `${c.trim()}`).join('') + const rows = body.trim().split('\n').map((row: string) => { + const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) => `${c.trim()}`).join('') + return `${tds}` + }).join('') + return `${ths}${rows}
` + } + ) + + // Headers + html = html.replace(/^### (.+)$/gm, '

$1

') + html = html.replace(/^## (.+)$/gm, '

$1

') + + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1') + + // Unordered lists + html = html.replace(/^- (.+)$/gm, '
  • $1
  • ') + html = html.replace(/((?:]*>.*<\/li>\n?)+)/g, '
      $1
    ') + + // Paragraphs (lines that aren't already HTML) + html = html.replace(/^(?!<[hultd]|$)(.+)$/gm, '

    $1

    ') + + return html +} + +// ============================================================================= +// ICON MAP +// ============================================================================= + +const ICON_MAP: Record> = { + Database, + Shield, + AlertTriangle, + Scale, + Handshake, + Briefcase, + MessageCircle, + Building2, +} + +function CategoryIcon({ icon, className }: { icon: string; className?: string }) { + const Icon = ICON_MAP[icon] || BookOpen + return +} + +// ============================================================================= +// 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 ( + + {config.label} + + ) +} + +// ============================================================================= +// WIKI PAGE +// ============================================================================= + +export default function WikiPage() { + const [categories, setCategories] = useState([]) + const [articles, setArticles] = useState([]) + const [selectedCategory, setSelectedCategory] = useState(null) + const [selectedArticle, setSelectedArticle] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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) => ({ + 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) => ({ + 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) => ({ + 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 ( +
    +
    +
    + ) + } + + if (error) { + return ( +
    +
    + {error} +
    +
    + ) + } + + return ( +
    + {/* Header */} +
    +
    +
    + +
    +

    Compliance Wiki

    +

    Interne Wissensbasis — {articles.length} Artikel in {categories.length} Kategorien

    +
    +
    + {/* Search */} +
    + + 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 && ( +
    + {searchResults.map(r => ( + + ))} +
    + )} + {searchResults && searchResults.length === 0 && searchQuery.length >= 2 && ( +
    + Keine Ergebnisse fuer "{searchQuery}" +
    + )} +
    +
    +
    + + {/* Content: Two columns */} +
    + {/* Left: Categories */} +
    +
    + + +
    + {categories.map(cat => ( + + ))} +
    +
    +
    + + {/* Right: Article list or detail */} +
    + {selectedArticle ? ( + /* Article detail view */ +
    + + +
    + + + {selectedArticle.categoryName} · v{selectedArticle.version} + +
    + +

    + {selectedArticle.title} +

    +

    {selectedArticle.summary}

    + + {/* Content (rendered markdown) */} +
    + + {/* Legal References */} + {selectedArticle.legalRefs.length > 0 && ( +
    +

    Rechtsreferenzen

    +
    + {selectedArticle.legalRefs.map(ref => ( + + + {ref} + + ))} +
    +
    + )} + + {/* Tags */} + {selectedArticle.tags.length > 0 && ( +
    +

    Tags

    +
    + {selectedArticle.tags.map(tag => ( + + + {tag} + + ))} +
    +
    + )} + + {/* Source URLs */} + {selectedArticle.sourceUrls.length > 0 && ( +
    +

    Quellen

    +
    + {selectedArticle.sourceUrls.map(url => ( +
    + + {url.startsWith('http') ? ( + {url} + ) : ( + {url} + )} +
    + ))} +
    +
    + )} +
    + ) : ( + /* Article list view */ +
    +

    + {selectedCategory + ? categories.find(c => c.id === selectedCategory)?.name || 'Artikel' + : 'Alle Artikel' + } + + ({filteredArticles.length}) + +

    + + {selectedCategory && ( +

    + {categories.find(c => c.id === selectedCategory)?.description} +

    + )} + +
    + {filteredArticles.map(article => ( + + ))} + + {filteredArticles.length === 0 && ( +
    + Keine Artikel in dieser Kategorie. +
    + )} +
    +
    + )} +
    +
    +
    + ) +} diff --git a/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx b/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx index 5ea3166..b64e313 100644 --- a/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx +++ b/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx @@ -760,6 +760,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP collapsed={collapsed} projectId={projectId} /> + + + + } + label="Compliance Wiki" + isActive={pathname?.startsWith('/sdk/wiki')} + collapsed={collapsed} + projectId={projectId} + /> { + 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 { + 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 { + 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 { + 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 */ diff --git a/admin-compliance/lib/sdk/types.ts b/admin-compliance/lib/sdk/types.ts index 41fa1de..eb55207 100644 --- a/admin-compliance/lib/sdk/types.ts +++ b/admin-compliance/lib/sdk/types.ts @@ -2398,3 +2398,40 @@ export const DSFA_CATEGORY_LABELS: Record = { process: 'Prozessschritte', 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 +} diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index 28e80f8..cf12ad7 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -32,6 +32,7 @@ from .incident_routes import router as incident_router from .change_request_routes import router as change_request_router from .generation_routes import router as generation_router from .project_routes import router as project_router +from .wiki_routes import router as wiki_router # Include sub-routers router.include_router(audit_router) @@ -65,6 +66,7 @@ router.include_router(incident_router) router.include_router(change_request_router) router.include_router(generation_router) router.include_router(project_router) +router.include_router(wiki_router) __all__ = [ "router", @@ -98,4 +100,5 @@ __all__ = [ "change_request_router", "generation_router", "project_router", + "wiki_router", ] diff --git a/backend-compliance/compliance/api/wiki_routes.py b/backend-compliance/compliance/api/wiki_routes.py new file mode 100644 index 0000000..d016c73 --- /dev/null +++ b/backend-compliance/compliance/api/wiki_routes.py @@ -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() diff --git a/backend-compliance/migrations/040_compliance_wiki.sql b/backend-compliance/migrations/040_compliance_wiki.sql new file mode 100644 index 0000000..3f728e0 --- /dev/null +++ b/backend-compliance/migrations/040_compliance_wiki.sql @@ -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 bussgeld­bewehrt (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;