From 00bc92322f82cac76091d92804d498d9bc45a05d Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Thu, 26 Feb 2026 15:59:29 +0000 Subject: [PATCH] docs: add DAK-Datenschutz implementation plan (11 tasks) Backend-enforced field masking for dak_mitarbeiter, disclosure request workflow with 24h expiry, admin approval page, and frontend adaptations. Co-Authored-By: Claude Opus 4.6 --- ...26-02-26-dak-datenschutz-implementation.md | 1009 +++++++++++++++++ 1 file changed, 1009 insertions(+) create mode 100644 docs/plans/2026-02-26-dak-datenschutz-implementation.md diff --git a/docs/plans/2026-02-26-dak-datenschutz-implementation.md b/docs/plans/2026-02-26-dak-datenschutz-implementation.md new file mode 100644 index 0000000..06d478b --- /dev/null +++ b/docs/plans/2026-02-26-dak-datenschutz-implementation.md @@ -0,0 +1,1009 @@ +# DAK-Datenschutz Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Hide personal data (nachname, vorname, geburtsdatum) from dak_mitarbeiter users at the API level, with a time-limited disclosure request mechanism for KVNR error resolution. + +**Architecture:** Backend-enforced field masking via a helper function that strips sensitive fields from Case responses for non-admin users. New DisclosureRequest model with admin approval workflow and 24h expiry. Notifications via existing Notification model. Frontend hides columns/fields based on role and disclosure status. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0 (mapped_column), Alembic, Pydantic v2, React 19, TypeScript, shadcn/ui + +--- + +### Task 1: Alembic Migration — disclosure_requests table + Notification constraint + +**Files:** +- Create: `backend/alembic/versions/xxxx_add_disclosure_requests.py` (manual migration on Hetzner) + +**Step 1: Write migration SQL** + +The migration creates the `disclosure_requests` table and updates the Notification CHECK constraint to include the new `disclosure_request` type. + +```sql +CREATE TABLE disclosure_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + case_id INT NOT NULL, + requester_id INT NOT NULL, + reason VARCHAR(500) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reviewed_by INT NULL, + reviewed_at DATETIME NULL, + expires_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_dr_case FOREIGN KEY (case_id) REFERENCES cases(id), + CONSTRAINT fk_dr_requester FOREIGN KEY (requester_id) REFERENCES users(id), + CONSTRAINT fk_dr_reviewer FOREIGN KEY (reviewed_by) REFERENCES users(id), + CONSTRAINT chk_dr_status CHECK (status IN ('pending', 'approved', 'rejected')), + INDEX idx_dr_case_status (case_id, status), + INDEX idx_dr_requester (requester_id), + INDEX idx_dr_status (status) +); +``` + +Also update the Notification CHECK constraint: +```sql +ALTER TABLE notifications DROP CONSTRAINT chk_notif; +ALTER TABLE notifications ADD CONSTRAINT chk_notif CHECK ( + notification_type IN ( + 'new_cases_uploaded','icd_entered','icd_uploaded', + 'report_ready','coding_completed','disclosure_request','disclosure_resolved' + ) +); +``` + +**Step 2: Run migration on Hetzner 1** + +```bash +ssh hetzner1 "mysql -u dak_user -p dak_portal < /tmp/disclosure_migration.sql" +``` + +**Step 3: Commit migration file** + +```bash +git add backend/alembic/versions/ +git commit -m "feat: add disclosure_requests table and update notification types" +``` + +--- + +### Task 2: Backend Model — DisclosureRequest + +**Files:** +- Modify: `backend/app/models/audit.py` (add DisclosureRequest class after Notification) + +**Step 1: Add DisclosureRequest model** + +Add after the Notification class in `backend/app/models/audit.py`: + +```python +class DisclosureRequest(Base): + __tablename__ = "disclosure_requests" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + case_id: Mapped[int] = mapped_column( + Integer, ForeignKey("cases.id"), nullable=False + ) + requester_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + reason: Mapped[str] = mapped_column(String(500), nullable=False) + status: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="pending" + ) + reviewed_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + reviewed_at: Mapped[Optional[dt.datetime]] = mapped_column( + DateTime, nullable=True + ) + expires_at: Mapped[Optional[dt.datetime]] = mapped_column( + DateTime, nullable=True + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + # Relationships + case: Mapped["Case"] = relationship(foreign_keys=[case_id]) + requester: Mapped["User"] = relationship(foreign_keys=[requester_id]) + reviewer: Mapped[Optional["User"]] = relationship(foreign_keys=[reviewed_by]) + + __table_args__ = ( + CheckConstraint( + "status IN ('pending', 'approved', 'rejected')", + name="chk_dr_status", + ), + Index("idx_dr_case_status", "case_id", "status"), + Index("idx_dr_requester", "requester_id"), + Index("idx_dr_status", "status"), + ) +``` + +Also update the Notification CHECK constraint string to include the new types: + +```python +# In Notification.__table_args__, change: +"'new_cases_uploaded','icd_entered','icd_uploaded'," +"'report_ready','coding_completed')" +# to: +"'new_cases_uploaded','icd_entered','icd_uploaded'," +"'report_ready','coding_completed','disclosure_request','disclosure_resolved')" +``` + +**Step 2: Commit** + +```bash +git add backend/app/models/audit.py +git commit -m "feat: add DisclosureRequest model and update notification types" +``` + +--- + +### Task 3: Backend Schemas — Disclosure + CaseResponse masking + +**Files:** +- Modify: `backend/app/schemas/case.py` (add disclosure_granted field, masking helper) +- Create: `backend/app/schemas/disclosure.py` (request/response schemas) + +**Step 1: Add disclosure schemas** + +Create `backend/app/schemas/disclosure.py`: + +```python +"""Pydantic schemas for disclosure request endpoints.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class DisclosureRequestCreate(BaseModel): + """Payload for creating a disclosure request.""" + reason: str + + +class DisclosureRequestResponse(BaseModel): + """Response for a single disclosure request.""" + id: int + case_id: int + requester_id: int + requester_username: Optional[str] = None + fall_id: Optional[str] = None + reason: str + status: str + reviewed_by: Optional[int] = None + reviewed_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class DisclosureRequestUpdate(BaseModel): + """Payload for approving/rejecting a disclosure request.""" + status: str # "approved" or "rejected" + + +class DisclosureCountResponse(BaseModel): + """Count of pending disclosure requests.""" + pending_count: int +``` + +**Step 2: Add disclosure fields to CaseResponse** + +In `backend/app/schemas/case.py`, add two optional fields to `CaseResponse`: + +```python +# Add after updated_at: +disclosure_granted: bool = False +disclosure_expires_at: Optional[datetime] = None +``` + +**Step 3: Create masking helper function** + +Add a helper function at the bottom of `backend/app/schemas/case.py`: + +```python +SENSITIVE_FIELDS = ("nachname", "vorname", "geburtsdatum", "anrede") + +def mask_case_for_mitarbeiter(case_dict: dict, disclosure_granted: bool = False) -> dict: + """Remove sensitive personal data fields for dak_mitarbeiter users. + + If disclosure_granted is True, the fields remain visible. + """ + if not disclosure_granted: + for field in SENSITIVE_FIELDS: + case_dict[field] = None + case_dict["disclosure_granted"] = disclosure_granted + return case_dict +``` + +**Step 4: Commit** + +```bash +git add backend/app/schemas/case.py backend/app/schemas/disclosure.py +git commit -m "feat: add disclosure schemas and case response masking helper" +``` + +--- + +### Task 4: Backend Service — Disclosure logic + +**Files:** +- Create: `backend/app/services/disclosure_service.py` + +**Step 1: Create disclosure service** + +```python +"""Disclosure request service — create, review, check active grants.""" + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.audit import DisclosureRequest, Notification +from app.models.case import Case +from app.models.user import User + + +def has_active_disclosure(db: Session, case_id: int, user_id: int) -> tuple[bool, Optional[datetime]]: + """Check if user has an active (approved, non-expired) disclosure for a case. + + Returns (granted: bool, expires_at: datetime | None). + """ + dr = ( + db.query(DisclosureRequest) + .filter( + DisclosureRequest.case_id == case_id, + DisclosureRequest.requester_id == user_id, + DisclosureRequest.status == "approved", + DisclosureRequest.expires_at > datetime.now(timezone.utc), + ) + .first() + ) + if dr: + return True, dr.expires_at + return False, None + + +def has_pending_request(db: Session, case_id: int, user_id: int) -> bool: + """Check if user already has a pending disclosure request for a case.""" + return ( + db.query(DisclosureRequest) + .filter( + DisclosureRequest.case_id == case_id, + DisclosureRequest.requester_id == user_id, + DisclosureRequest.status == "pending", + ) + .first() + ) is not None + + +def create_disclosure_request( + db: Session, case_id: int, user_id: int, reason: str +) -> DisclosureRequest: + """Create a new disclosure request and notify admins.""" + dr = DisclosureRequest( + case_id=case_id, + requester_id=user_id, + reason=reason, + ) + db.add(dr) + db.flush() # get dr.id + + # Notify all active admins + case = db.query(Case).filter(Case.id == case_id).first() + fall_id = case.fall_id if case else str(case_id) + requester = db.query(User).filter(User.id == user_id).first() + requester_name = requester.username if requester else str(user_id) + + admins = db.query(User).filter(User.role == "admin", User.is_active == True).all() # noqa: E712 + for admin in admins: + db.add(Notification( + recipient_id=admin.id, + notification_type="disclosure_request", + title=f"Freigabe-Anfrage für Fall {fall_id}", + message=f"{requester_name} bittet um Einsicht in Personendaten. Begründung: {reason}", + related_entity_type="disclosure_request", + related_entity_id=dr.id, + )) + + db.commit() + db.refresh(dr) + return dr + + +def review_disclosure_request( + db: Session, request_id: int, admin_id: int, new_status: str +) -> DisclosureRequest: + """Approve or reject a disclosure request.""" + dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first() + if not dr: + raise ValueError("Disclosure request not found") + if dr.status != "pending": + raise ValueError("Request already reviewed") + + dr.status = new_status + dr.reviewed_by = admin_id + dr.reviewed_at = datetime.now(timezone.utc) + + if new_status == "approved": + dr.expires_at = datetime.now(timezone.utc) + timedelta(hours=24) + + # Notify requester + case = db.query(Case).filter(Case.id == dr.case_id).first() + fall_id = case.fall_id if case else str(dr.case_id) + + status_text = "genehmigt" if new_status == "approved" else "abgelehnt" + db.add(Notification( + recipient_id=dr.requester_id, + notification_type="disclosure_resolved", + title=f"Freigabe-Anfrage {status_text}", + message=f"Ihre Anfrage für Fall {fall_id} wurde {status_text}.", + related_entity_type="disclosure_request", + related_entity_id=dr.id, + )) + + db.commit() + db.refresh(dr) + return dr + + +def get_pending_requests(db: Session) -> list[DisclosureRequest]: + """Return all pending disclosure requests with related data.""" + return ( + db.query(DisclosureRequest) + .filter(DisclosureRequest.status == "pending") + .order_by(DisclosureRequest.created_at.desc()) + .all() + ) + + +def get_pending_count(db: Session) -> int: + """Return count of pending disclosure requests.""" + return ( + db.query(DisclosureRequest) + .filter(DisclosureRequest.status == "pending") + .count() + ) +``` + +**Step 2: Commit** + +```bash +git add backend/app/services/disclosure_service.py +git commit -m "feat: add disclosure service with create, review, and check logic" +``` + +--- + +### Task 5: Backend Endpoints — Case masking + Disclosure API + +**Files:** +- Modify: `backend/app/api/cases.py` (mask sensitive fields, restrict search) +- Modify: `backend/app/api/admin.py` (add disclosure endpoints) +- Modify: `backend/app/main.py` (no change needed — admin router already mounted) + +**Step 1: Add masking to case endpoints in `backend/app/api/cases.py`** + +Import the masking helper and disclosure service: + +```python +from app.schemas.case import SENSITIVE_FIELDS, mask_case_for_mitarbeiter +from app.services.disclosure_service import has_active_disclosure +``` + +Create a helper function to serialize a list of cases with masking: + +```python +def _serialize_cases(cases: list[Case], user: User, db: Session) -> list[dict]: + """Serialize cases, masking sensitive fields for dak_mitarbeiter.""" + if user.role == "admin": + return [CaseResponse.model_validate(c).model_dump() for c in cases] + + result = [] + for c in cases: + case_dict = CaseResponse.model_validate(c).model_dump() + granted, expires_at = has_active_disclosure(db, c.id, user.id) + case_dict = mask_case_for_mitarbeiter(case_dict, disclosure_granted=granted) + if expires_at: + case_dict["disclosure_expires_at"] = expires_at + result.append(case_dict) + return result +``` + +Modify `list_cases` endpoint: +- For `dak_mitarbeiter`: remove `nachname` and `vorname` from the search OR clause +- Use `_serialize_cases` instead of returning raw cases + +Modify `get_case` endpoint: +- Use masking for single case response + +Modify `list_pending_icd` endpoint: +- Use masking for case list response + +**Step 2: Restrict search for dak_mitarbeiter** + +In `list_cases`, change the search filter: + +```python +if search: + like_pattern = f"%{search}%" + if user.role == "admin": + query = query.filter( + or_( + Case.nachname.ilike(like_pattern), + Case.vorname.ilike(like_pattern), + Case.fall_id.ilike(like_pattern), + Case.kvnr.ilike(like_pattern), + ) + ) + else: + # dak_mitarbeiter can only search by fall_id and kvnr + query = query.filter( + or_( + Case.fall_id.ilike(like_pattern), + Case.kvnr.ilike(like_pattern), + ) + ) +``` + +**Step 3: Add disclosure request endpoint to cases.py** + +```python +from app.schemas.disclosure import DisclosureRequestCreate, DisclosureRequestResponse +from app.services.disclosure_service import ( + create_disclosure_request, has_active_disclosure, has_pending_request, +) + +@router.post("/{case_id}/disclosure-request", response_model=DisclosureRequestResponse) +def request_disclosure( + case_id: int, + payload: DisclosureRequestCreate, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Request disclosure of personal data for a case (dak_mitarbeiter).""" + # Verify case exists + case = db.query(Case).filter(Case.id == case_id).first() + if not case: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found") + + # Check for existing pending request + if has_pending_request(db, case_id, user.id): + raise HTTPException(status.HTTP_409_CONFLICT, "Anfrage bereits ausstehend") + + # Check for active disclosure + granted, _ = has_active_disclosure(db, case_id, user.id) + if granted: + raise HTTPException(status.HTTP_409_CONFLICT, "Freigabe bereits aktiv") + + dr = create_disclosure_request(db, case_id, user.id, payload.reason) + + log_action( + db, user_id=user.id, action="disclosure_requested", + entity_type="case", entity_id=case_id, + new_values={"reason": payload.reason}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + + return DisclosureRequestResponse( + id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, + reason=dr.reason, status=dr.status, created_at=dr.created_at, + ) +``` + +**Step 4: Add admin disclosure endpoints to `backend/app/api/admin.py`** + +```python +from app.models.audit import DisclosureRequest +from app.schemas.disclosure import ( + DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate, +) +from app.services.disclosure_service import ( + get_pending_count, get_pending_requests, review_disclosure_request, +) + +@router.get("/disclosure-requests", response_model=list[DisclosureRequestResponse]) +def list_disclosure_requests( + status_filter: Optional[str] = Query(None, alias="status"), + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """List disclosure requests (default: pending).""" + query = db.query(DisclosureRequest) + if status_filter: + query = query.filter(DisclosureRequest.status == status_filter) + else: + query = query.filter(DisclosureRequest.status == "pending") + + requests = query.order_by(DisclosureRequest.created_at.desc()).all() + + result = [] + for dr in requests: + result.append(DisclosureRequestResponse( + id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, + requester_username=dr.requester.username if dr.requester else None, + fall_id=dr.case.fall_id if dr.case else None, + reason=dr.reason, status=dr.status, + reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, + expires_at=dr.expires_at, created_at=dr.created_at, + )) + return result + + +@router.get("/disclosure-requests/count", response_model=DisclosureCountResponse) +def disclosure_request_count( + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Return count of pending disclosure requests.""" + return DisclosureCountResponse(pending_count=get_pending_count(db)) + + +@router.put("/disclosure-requests/{request_id}", response_model=DisclosureRequestResponse) +def review_disclosure( + request_id: int, + payload: DisclosureRequestUpdate, + request: Request, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Approve or reject a disclosure request.""" + if payload.status not in ("approved", "rejected"): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, + "Status must be 'approved' or 'rejected'") + try: + dr = review_disclosure_request(db, request_id, admin.id, payload.status) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) + + log_action( + db, user_id=admin.id, action=f"disclosure_{payload.status}", + entity_type="disclosure_request", entity_id=request_id, + new_values={"status": payload.status, "case_id": dr.case_id}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + + return DisclosureRequestResponse( + id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, + requester_username=dr.requester.username if dr.requester else None, + fall_id=dr.case.fall_id if dr.case else None, + reason=dr.reason, status=dr.status, + reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, + expires_at=dr.expires_at, created_at=dr.created_at, + ) +``` + +**Step 5: Commit** + +```bash +git add backend/app/api/cases.py backend/app/api/admin.py +git commit -m "feat: add case masking for dak_mitarbeiter and disclosure endpoints" +``` + +--- + +### Task 6: Frontend Types & Services + +**Files:** +- Modify: `frontend/src/types/index.ts` (add disclosure types, update Case) +- Create: `frontend/src/services/disclosureService.ts` + +**Step 1: Update Case interface** + +In `frontend/src/types/index.ts`, add to the Case interface: + +```typescript +// Add after updated_at in Case interface: +disclosure_granted?: boolean +disclosure_expires_at?: string | null +``` + +Add new types: + +```typescript +// Disclosure Requests +export interface DisclosureRequest { + id: number + case_id: number + requester_id: number + requester_username: string | null + fall_id: string | null + reason: string + status: 'pending' | 'approved' | 'rejected' + reviewed_by: number | null + reviewed_at: string | null + expires_at: string | null + created_at: string +} + +export interface DisclosureRequestCreate { + reason: string +} + +export interface DisclosureCountResponse { + pending_count: number +} +``` + +**Step 2: Create disclosure service** + +Create `frontend/src/services/disclosureService.ts`: + +```typescript +import api from './api' +import type { DisclosureRequest, DisclosureCountResponse } from '@/types' + +export async function requestDisclosure(caseId: number, reason: string): Promise { + const res = await api.post(`/cases/${caseId}/disclosure-request`, { reason }) + return res.data +} + +export async function getDisclosureRequests(status?: string): Promise { + const params = status ? { status } : {} + const res = await api.get('/admin/disclosure-requests', { params }) + return res.data +} + +export async function getDisclosureCount(): Promise { + const res = await api.get('/admin/disclosure-requests/count') + return res.data.pending_count +} + +export async function reviewDisclosure(requestId: number, status: 'approved' | 'rejected'): Promise { + const res = await api.put(`/admin/disclosure-requests/${requestId}`, { status }) + return res.data +} +``` + +**Step 3: Commit** + +```bash +git add frontend/src/types/index.ts frontend/src/services/disclosureService.ts +git commit -m "feat: add disclosure types and service functions" +``` + +--- + +### Task 7: Frontend — CasesPage masking + +**Files:** +- Modify: `frontend/src/pages/CasesPage.tsx` (hide columns, adjust search) + +**Step 1: Hide Nachname/Vorname columns for dak_mitarbeiter** + +In CasesPage, use the existing `useAuth()` hook to get `isAdmin`. Conditionally render the table headers and cells: + +```tsx +// In the TableHeader, wrap Nachname/Vorname heads: +{isAdmin && Nachname} +{isAdmin && Vorname} + +// In each TableRow, wrap corresponding cells: +{isAdmin && {c.nachname}} +{isAdmin && {c.vorname || '-'}} +``` + +**Step 2: Adjust search placeholder** + +```tsx + +``` + +**Step 3: Commit** + +```bash +git add frontend/src/pages/CasesPage.tsx +git commit -m "feat: hide personal data columns for dak_mitarbeiter in case list" +``` + +--- + +### Task 8: Frontend — CaseDetail disclosure request + +**Files:** +- Modify: `frontend/src/pages/CasesPage.tsx` (CaseDetail component) +- Modify: `frontend/src/pages/cases/fieldConfig.ts` (add visibility property) + +**Step 1: Add `visibleTo` property to FieldConfig** + +In `frontend/src/pages/cases/fieldConfig.ts`, add to FieldConfig interface: + +```typescript +visibleTo?: 'admin' | 'all' // defaults to 'all' if not set +``` + +Mark sensitive fields: + +```typescript +{ key: 'anrede', label: 'Anrede', type: 'select', editableBy: 'admin', visibleTo: 'admin', options: ANREDE_OPTIONS }, +{ key: 'vorname', label: 'Vorname', type: 'text', editableBy: 'admin', visibleTo: 'admin', placeholder: 'Vorname' }, +{ key: 'nachname', label: 'Nachname', type: 'text', editableBy: 'admin', visibleTo: 'admin', placeholder: 'Nachname' }, +{ key: 'geburtsdatum', label: 'Geburtsdatum', type: 'date', editableBy: 'admin', visibleTo: 'admin' }, +``` + +**Step 2: Filter fields in CaseDetail rendering** + +In the CaseDetail component, filter section fields by visibility: + +```tsx +{CASE_SECTIONS.map((section) => { + const visibleFields = section.fields.filter((field) => { + if (field.visibleTo === 'admin' && !isAdmin && !caseData.disclosure_granted) return false + return true + }) + if (visibleFields.length === 0) return null + return ( +
+ ... + {visibleFields.map((field) => (...))} +
+ ) +})} +``` + +**Step 3: Add disclosure request button in CaseDetail** + +Add after the status badges section in CaseDetail, when user is not admin: + +```tsx +{!isAdmin && !caseData.disclosure_granted && ( + +)} +{caseData.disclosure_granted && caseData.disclosure_expires_at && ( + + + Personendaten sichtbar bis {formatDateTime(caseData.disclosure_expires_at)} + + +)} +``` + +Create the DisclosureRequestButton component within CasesPage.tsx: + +```tsx +function DisclosureRequestButton({ caseId }: { caseId: number }) { + const [open, setOpen] = useState(false) + const [reason, setReason] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + + const submit = async () => { + if (!reason.trim()) return + setLoading(true) + setError('') + try { + await requestDisclosure(caseId, reason.trim()) + setSuccess(true) + setOpen(false) + } catch (err: any) { + setError(err.response?.data?.detail || 'Fehler beim Senden der Anfrage') + } finally { + setLoading(false) + } + } + + if (success) { + return

Freigabe-Anfrage gesendet.

+ } + + return ( + <> + + {open && ( +
+

+ Begründung für die Einsicht in Personendaten: +

+ setReason(e.target.value)} + placeholder="z.B. KVNR-Fehler, Identifikation nötig" + /> + {error &&

{error}

} +
+ + +
+
+ )} + + ) +} +``` + +**Step 4: Commit** + +```bash +git add frontend/src/pages/CasesPage.tsx frontend/src/pages/cases/fieldConfig.ts +git commit -m "feat: add disclosure request UI and field visibility for dak_mitarbeiter" +``` + +--- + +### Task 9: Frontend — Admin Disclosures Page + +**Files:** +- Create: `frontend/src/pages/DisclosuresPage.tsx` + +**Step 1: Create the admin disclosures page** + +```tsx +import { useState, useEffect } from 'react' +import { Check, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import type { DisclosureRequest } from '@/types' +import { getDisclosureRequests, reviewDisclosure } from '@/services/disclosureService' + +export function DisclosuresPage() { + const [requests, setRequests] = useState([]) + const [loading, setLoading] = useState(true) + + const load = () => { + setLoading(true) + getDisclosureRequests('pending') + .then(setRequests) + .catch(() => setRequests([])) + .finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) + + const handleReview = async (id: number, status: 'approved' | 'rejected') => { + await reviewDisclosure(id, status) + load() + } + + return ( +
+

Freigabe-Anfragen

+ {loading ? ( +
+ {[1,2,3].map(i => )} +
+ ) : requests.length === 0 ? ( +

Keine offenen Anfragen.

+ ) : ( +
+ {requests.map((dr) => ( + + +
+ + Fall {dr.fall_id || dr.case_id} + + {dr.status} +
+
+ +

+ Angefragt von:{' '} + {dr.requester_username || `User #${dr.requester_id}`} +

+

+ Begründung:{' '} + {dr.reason} +

+

+ {new Date(dr.created_at).toLocaleString('de-DE')} +

+
+ + +
+
+
+ ))} +
+ )} +
+ ) +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/pages/DisclosuresPage.tsx +git commit -m "feat: add admin disclosures page for reviewing data access requests" +``` + +--- + +### Task 10: Frontend — Routing & Sidebar + +**Files:** +- Modify: `frontend/src/App.tsx` (add route) +- Modify: `frontend/src/components/layout/Sidebar.tsx` (add admin nav item) + +**Step 1: Add route in App.tsx** + +Import DisclosuresPage and add the route alongside other admin routes: + +```tsx +import { DisclosuresPage } from './pages/DisclosuresPage' + +// Add route: +} /> +``` + +**Step 2: Add sidebar entry** + +In `frontend/src/components/layout/Sidebar.tsx`, import `ShieldCheck` from lucide-react and add to adminNavItems: + +```typescript +import { ShieldCheck } from 'lucide-react' + +const adminNavItems: NavItem[] = [ + { label: 'Freigabe-Anfragen', to: '/admin/disclosures', icon: ShieldCheck }, + { label: 'Benutzer', to: '/admin/users', icon: Users }, + { label: 'Einladungen', to: '/admin/invitations', icon: Mail }, + { label: 'Audit-Log', to: '/admin/audit', icon: History }, +] +``` + +**Step 3: Commit** + +```bash +git add frontend/src/App.tsx frontend/src/components/layout/Sidebar.tsx +git commit -m "feat: add disclosure admin route and sidebar entry" +``` + +--- + +### Task 11: Build & Deploy + +**Step 1: Build frontend** + +```bash +cd frontend && pnpm build +``` + +Expected: Build succeeds without errors. + +**Step 2: Run migration on Hetzner 1** + +Execute the SQL migration on the production database. + +**Step 3: Deploy to Hetzner 1** + +```bash +# Push and merge +git push origin develop +git checkout main && git merge develop && git push origin main + +# Deploy +ssh hetzner1 "cd /opt/dak-portal && git pull origin main" +ssh hetzner1 "cd /opt/dak-portal/frontend && pnpm install && pnpm build && cp -r dist/* /var/www/vhosts/complexcaresolutions.de/dak.complexcaresolutions.de/dist/" +ssh hetzner1 "systemctl restart dak-backend" +``` + +**Step 4: Verify** + +- Login as admin: all data visible, disclosure admin page accessible +- Login as dak_mitarbeiter: Nachname/Vorname/Geburtsdatum hidden in list and detail +- Search only works for Fall-ID/KVNR as dak_mitarbeiter +- Disclosure request flow works end-to-end