mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 19:33:41 +00:00
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>
1009 lines
31 KiB
Markdown
1009 lines
31 KiB
Markdown
# 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<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**
|
|
|
|
```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 && <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**
|
|
|
|
```tsx
|
|
<Input
|
|
placeholder={isAdmin ? "Suche nach Name, Fall-ID, KVNR..." : "Suche nach Fall-ID, KVNR..."}
|
|
...
|
|
/>
|
|
```
|
|
|
|
**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 (
|
|
<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:
|
|
|
|
```tsx
|
|
{!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:
|
|
|
|
```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**
|
|
|
|
```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<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**
|
|
|
|
```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:
|
|
<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:
|
|
|
|
```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
|