dak.c2s/docs/plans/2026-02-26-dak-datenschutz-implementation.md
CCS Admin 00bc92322f 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 <noreply@anthropic.com>
2026-02-26 15:59:29 +00:00

31 KiB

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.

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:

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

ssh hetzner1 "mysql -u dak_user -p dak_portal < /tmp/disclosure_migration.sql"

Step 3: Commit migration file

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:

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:

# 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

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:

"""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:

# 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:

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

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

"""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

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:

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:

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:

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

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

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

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:

// Add after updated_at in Case interface:
disclosure_granted?: boolean
disclosure_expires_at?: string | null

Add new types:

// 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:

import api from './api'
import type { DisclosureRequest, DisclosureCountResponse } from '@/types'

export async function requestDisclosure(caseId: number, reason: string): Promise<DisclosureRequest> {
  const res = await api.post<DisclosureRequest>(`/cases/${caseId}/disclosure-request`, { reason })
  return res.data
}

export async function getDisclosureRequests(status?: string): Promise<DisclosureRequest[]> {
  const params = status ? { status } : {}
  const res = await api.get<DisclosureRequest[]>('/admin/disclosure-requests', { params })
  return res.data
}

export async function getDisclosureCount(): Promise<number> {
  const res = await api.get<DisclosureCountResponse>('/admin/disclosure-requests/count')
  return res.data.pending_count
}

export async function reviewDisclosure(requestId: number, status: 'approved' | 'rejected'): Promise<DisclosureRequest> {
  const res = await api.put<DisclosureRequest>(`/admin/disclosure-requests/${requestId}`, { status })
  return res.data
}

Step 3: Commit

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:

// In the TableHeader, wrap Nachname/Vorname heads:
{isAdmin && <TableHead>Nachname</TableHead>}
{isAdmin && <TableHead>Vorname</TableHead>}

// In each TableRow, wrap corresponding cells:
{isAdmin && <TableCell className="font-medium">{c.nachname}</TableCell>}
{isAdmin && <TableCell>{c.vorname || '-'}</TableCell>}

Step 2: Adjust search placeholder

<Input
  placeholder={isAdmin ? "Suche nach Name, Fall-ID, KVNR..." : "Suche nach Fall-ID, KVNR..."}
  ...
/>

Step 3: Commit

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:

visibleTo?: 'admin' | 'all'  // defaults to 'all' if not set

Mark sensitive fields:

{ 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:

{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 (
    <div key={section.title} className="border-t pt-3 space-y-3">
      ...
      {visibleFields.map((field) => (...))}
    </div>
  )
})}

Step 3: Add disclosure request button in CaseDetail

Add after the status badges section in CaseDetail, when user is not admin:

{!isAdmin && !caseData.disclosure_granted && (
  <DisclosureRequestButton caseId={caseData.id} />
)}
{caseData.disclosure_granted && caseData.disclosure_expires_at && (
  <Alert>
    <AlertDescription>
      Personendaten sichtbar bis {formatDateTime(caseData.disclosure_expires_at)}
    </AlertDescription>
  </Alert>
)}

Create the DisclosureRequestButton component within CasesPage.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 <p className="text-sm text-muted-foreground">Freigabe-Anfrage gesendet.</p>
  }

  return (
    <>
      <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
        Personendaten anfordern
      </Button>
      {open && (
        <div className="space-y-2 border rounded-lg p-3">
          <p className="text-sm text-muted-foreground">
            Begründung für die Einsicht in Personendaten:
          </p>
          <Input
            value={reason}
            onChange={(e) => setReason(e.target.value)}
            placeholder="z.B. KVNR-Fehler, Identifikation nötig"
          />
          {error && <p className="text-sm text-red-600">{error}</p>}
          <div className="flex gap-2">
            <Button size="sm" onClick={submit} disabled={loading || !reason.trim()}>
              {loading ? 'Senden...' : 'Anfrage senden'}
            </Button>
            <Button size="sm" variant="outline" onClick={() => setOpen(false)}>
              Abbrechen
            </Button>
          </div>
        </div>
      )}
    </>
  )
}

Step 4: Commit

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

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<DisclosureRequest[]>([])
  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 (
    <div className="p-6 space-y-4">
      <h1 className="text-2xl font-bold">Freigabe-Anfragen</h1>
      {loading ? (
        <div className="space-y-2">
          {[1,2,3].map(i => <Skeleton key={i} className="h-24 w-full" />)}
        </div>
      ) : requests.length === 0 ? (
        <p className="text-muted-foreground">Keine offenen Anfragen.</p>
      ) : (
        <div className="space-y-3">
          {requests.map((dr) => (
            <Card key={dr.id}>
              <CardHeader className="pb-2">
                <div className="flex items-center justify-between">
                  <CardTitle className="text-base">
                    Fall {dr.fall_id || dr.case_id}
                  </CardTitle>
                  <Badge variant="outline">{dr.status}</Badge>
                </div>
              </CardHeader>
              <CardContent className="space-y-2">
                <p className="text-sm">
                  <span className="text-muted-foreground">Angefragt von:</span>{' '}
                  {dr.requester_username || `User #${dr.requester_id}`}
                </p>
                <p className="text-sm">
                  <span className="text-muted-foreground">Begründung:</span>{' '}
                  {dr.reason}
                </p>
                <p className="text-xs text-muted-foreground">
                  {new Date(dr.created_at).toLocaleString('de-DE')}
                </p>
                <div className="flex gap-2 pt-1">
                  <Button size="sm" onClick={() => handleReview(dr.id, 'approved')}>
                    <Check className="size-4 mr-1" /> Genehmigen
                  </Button>
                  <Button size="sm" variant="destructive" onClick={() => handleReview(dr.id, 'rejected')}>
                    <X className="size-4 mr-1" /> Ablehnen
                  </Button>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  )
}

Step 2: Commit

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:

import { DisclosuresPage } from './pages/DisclosuresPage'

// Add route:
<Route path="/admin/disclosures" element={<DisclosuresPage />} />

Step 2: Add sidebar entry

In frontend/src/components/layout/Sidebar.tsx, import ShieldCheck from lucide-react and add to adminNavItems:

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

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

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

# 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