feat(legal-docs): 5-stage lifecycle (draft → review_internal → review_client → approved → published)
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 30s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

Phase 1 of the workspace-cutover initiative: compliance becomes the
single source of truth for documents. Step one is making the existing
compliance_legal_documents workflow rich enough to express the DSB→
Mandant approval pattern that the workspace's 5-stage UI needed.

Migration 148:
- Adds CHECK constraint on status (was free-form VARCHAR20)
- Allows: draft, review, review_internal, review_client, approved,
  published, archived, rejected (legacy "review" kept for backward
  compat — 0 existing rows so no backfill needed)
- Adds CHECK on approvals.action with extended values:
  submitted_internal, submitted_client, approved_internal,
  approved_client, rejected_internal, rejected_client
- Adds 6 new columns for the richer audit trail: submitted_by/at,
  approved_internal_by/at, approved_client_by/at

Service:
- New methods submit_internal_review, approve_internal, approve_client
- submit_review / approve kept as backwards-compat aliases that map to
  the new methods
- reject() now reads current status to log specific rejected_internal
  or rejected_client action
- _version_to_response includes all new audit fields

Routes:
- POST /versions/{id}/submit-internal-review
- POST /versions/{id}/approve-internal  (DSB sagt OK → Mandant ist dran)
- POST /versions/{id}/approve-client    (Mandant sagt OK → approved)
- Existing submit-review / approve endpoints stay but map through aliases

Schema:
- VersionResponse extended with optional submitted_by/at,
  approved_internal_by/at, approved_client_by/at fields

This unlocks Phase 2 (Generate-All in compliance generator), Phase 3
(Document-Library tab in admin), Phase 4 (workspace cutover — drop its
own document storage and route everything through this lifecycle).

[migration-approved]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-08 08:31:08 +02:00
parent 327e6a8984
commit e34f7cb507
5 changed files with 221 additions and 13 deletions
@@ -59,6 +59,12 @@ def _version_to_response(v: LegalDocumentVersionDB) -> VersionResponse:
created_by=v.created_by,
approved_by=v.approved_by,
approved_at=v.approved_at,
submitted_by=getattr(v, "submitted_by", None),
submitted_at=getattr(v, "submitted_at", None),
approved_internal_by=getattr(v, "approved_internal_by", None),
approved_internal_at=getattr(v, "approved_internal_at", None),
approved_client_by=getattr(v, "approved_client_by", None),
approved_client_at=getattr(v, "approved_client_at", None),
rejection_reason=v.rejection_reason,
created_at=v.created_at,
updated_at=v.updated_at,
@@ -259,38 +265,92 @@ class LegalDocumentService:
return {"html": html_content, "filename": filename}
# ------------------------------------------------------------------
# Workflow transitions
# Workflow transitions (5-stage: draft → review_internal → review_client → approved → published)
# ------------------------------------------------------------------
def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse:
def submit_internal_review(
self, version_id: str, request: ActionRequest
) -> VersionResponse:
"""draft (oder rejected) → review_internal: DSB-Pruefung beginnt."""
return _transition(
self.db, version_id, ["draft", "rejected"], "review", "submitted",
request.approver, request.comment,
)
def approve(self, version_id: str, request: ActionRequest) -> VersionResponse:
return _transition(
self.db, version_id, ["review"], "approved", "approved",
self.db, version_id, ["draft", "rejected"], "review_internal", "submitted_internal",
request.approver, request.comment,
extra_updates={
"submitted_by": request.approver,
"submitted_at": datetime.now(timezone.utc),
},
)
def approve_internal(
self, version_id: str, request: ActionRequest
) -> VersionResponse:
"""review_internal → review_client: DSB hat geprueft, Mandant ist dran."""
return _transition(
self.db, version_id, ["review_internal"], "review_client", "approved_internal",
request.approver, request.comment,
extra_updates={
"approved_internal_by": request.approver,
"approved_internal_at": datetime.now(timezone.utc),
},
)
def approve_client(
self, version_id: str, request: ActionRequest
) -> VersionResponse:
"""review_client → approved: Mandant hat freigegeben."""
now = datetime.now(timezone.utc)
return _transition(
self.db, version_id, ["review_client"], "approved", "approved_client",
request.approver, request.comment,
extra_updates={
"approved_client_by": request.approver,
"approved_client_at": now,
# Legacy-Felder mitfuehren fuer alte Konsumenten
"approved_by": request.approver,
"approved_at": datetime.now(timezone.utc),
"approved_at": now,
},
)
def reject(self, version_id: str, request: ActionRequest) -> VersionResponse:
"""Ablehnung aus review_internal oder review_client (oder legacy 'review') → rejected."""
v = (
self.db.query(LegalDocumentVersionDB)
.filter(LegalDocumentVersionDB.id == version_id)
.first()
)
action = "rejected"
if v is not None:
if v.status == "review_internal":
action = "rejected_internal"
elif v.status == "review_client":
action = "rejected_client"
return _transition(
self.db, version_id, ["review"], "rejected", "rejected",
self.db, version_id,
["review", "review_internal", "review_client"],
"rejected", action,
request.approver, request.comment,
extra_updates={"rejection_reason": request.comment},
)
def publish(self, version_id: str, request: ActionRequest) -> VersionResponse:
"""approved → published."""
return _transition(
self.db, version_id, ["approved"], "published", "published",
request.approver, request.comment,
)
# ------------------------------------------------------------------
# Backwards-compat aliases for 4-stage callers
# ------------------------------------------------------------------
def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse:
"""Backwards-compat alias — leitet auf submit_internal_review um."""
return self.submit_internal_review(version_id, request)
def approve(self, version_id: str, request: ActionRequest) -> VersionResponse:
"""Backwards-compat alias — leitet auf approve_internal (DSB-Schritt)."""
return self.approve_internal(version_id, request)
def approval_history(self, version_id: str) -> list[ApprovalHistoryEntry]:
version = (
self.db.query(LegalDocumentVersionDB)