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 ``
+ }
+ )
+
+ // 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, '')
+
+ // 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 (
+
+ )
+ }
+
+ 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 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;