From e34f7cb5074119804e6d761c7d07bc6099e1ada1 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 8 Jun 2026 08:31:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(legal-docs):=205-stage=20lifecycle=20(draf?= =?UTF-8?q?t=20=E2=86=92=20review=5Finternal=20=E2=86=92=20review=5Fclient?= =?UTF-8?q?=20=E2=86=92=20approved=20=E2=86=92=20published)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../compliance/api/legal_document_routes.py | 46 +++++++++++ .../compliance/db/legal_document_models.py | 22 ++++- .../compliance/schemas/legal_document.py | 8 ++ .../services/legal_document_service.py | 82 ++++++++++++++++--- .../148_legal_document_5stage_lifecycle.sql | 76 +++++++++++++++++ 5 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql diff --git a/backend-compliance/compliance/api/legal_document_routes.py b/backend-compliance/compliance/api/legal_document_routes.py index 3dbd63dc..6979a37d 100644 --- a/backend-compliance/compliance/api/legal_document_routes.py +++ b/backend-compliance/compliance/api/legal_document_routes.py @@ -198,6 +198,52 @@ async def publish_version( return service.publish(version_id, request) +# ============================================================================ +# 5-stage Workflow (draft → review_internal → review_client → approved → published) +# ============================================================================ + +@router.post( + "/versions/{version_id}/submit-internal-review", + response_model=VersionResponse, +) +async def submit_internal_review( + version_id: str, + request: ActionRequest, + service: LegalDocumentService = Depends(_get_doc_service), +) -> VersionResponse: + """draft (oder rejected) → review_internal: DSB-Phase beginnt.""" + with translate_domain_errors(): + return service.submit_internal_review(version_id, request) + + +@router.post( + "/versions/{version_id}/approve-internal", + response_model=VersionResponse, +) +async def approve_internal( + version_id: str, + request: ActionRequest, + service: LegalDocumentService = Depends(_get_doc_service), +) -> VersionResponse: + """review_internal → review_client: DSB hat freigegeben, Mandant ist dran.""" + with translate_domain_errors(): + return service.approve_internal(version_id, request) + + +@router.post( + "/versions/{version_id}/approve-client", + response_model=VersionResponse, +) +async def approve_client( + version_id: str, + request: ActionRequest, + service: LegalDocumentService = Depends(_get_doc_service), +) -> VersionResponse: + """review_client → approved: Mandant hat freigegeben.""" + with translate_domain_errors(): + return service.approve_client(version_id, request) + + # ============================================================================ # Approval History # ============================================================================ diff --git a/backend-compliance/compliance/db/legal_document_models.py b/backend-compliance/compliance/db/legal_document_models.py index 8c6032c8..1658e7f5 100644 --- a/backend-compliance/compliance/db/legal_document_models.py +++ b/backend-compliance/compliance/db/legal_document_models.py @@ -42,7 +42,14 @@ class LegalDocumentDB(Base): class LegalDocumentVersionDB(Base): - """Version of a legal document with Approval-Workflow status.""" + """Version of a legal document with 5-stage Approval-Workflow. + + Lifecycle: + draft → review_internal (DSB-Prüfung) + → review_client (Mandanten-Prüfung) + → approved → published + (Ablehnung: review_internal/review_client → rejected → zurück zu draft) + """ __tablename__ = 'compliance_legal_document_versions' @@ -53,10 +60,21 @@ class LegalDocumentVersionDB(Base): title = Column(String(300), nullable=False) content = Column(Text, nullable=False) summary = Column(Text) - status = Column(String(20), default='draft') # draft|review|approved|published|archived|rejected + status = Column(String(20), default='draft') # draft|review_internal|review_client|approved|published|archived|rejected (+ legacy 'review') created_by = Column(String(200)) + + # Backwards-compat single approval (legacy 4-stage Flow) approved_by = Column(String(200)) approved_at = Column(DateTime) + + # 5-stage Trail + submitted_by = Column(String(200)) + submitted_at = Column(DateTime) + approved_internal_by = Column(String(200)) + approved_internal_at = Column(DateTime) + approved_client_by = Column(String(200)) + approved_client_at = Column(DateTime) + rejection_reason = Column(Text) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend-compliance/compliance/schemas/legal_document.py b/backend-compliance/compliance/schemas/legal_document.py index 90e1800b..b8ff8aea 100644 --- a/backend-compliance/compliance/schemas/legal_document.py +++ b/backend-compliance/compliance/schemas/legal_document.py @@ -57,8 +57,16 @@ class VersionResponse(BaseModel): summary: Optional[str] status: str created_by: Optional[str] + # Legacy single-approval (4-stage) approved_by: Optional[str] approved_at: Optional[datetime] + # 5-stage Trail + submitted_by: Optional[str] = None + submitted_at: Optional[datetime] = None + approved_internal_by: Optional[str] = None + approved_internal_at: Optional[datetime] = None + approved_client_by: Optional[str] = None + approved_client_at: Optional[datetime] = None rejection_reason: Optional[str] created_at: datetime updated_at: Optional[datetime] diff --git a/backend-compliance/compliance/services/legal_document_service.py b/backend-compliance/compliance/services/legal_document_service.py index b6ff55e4..f82b8297 100644 --- a/backend-compliance/compliance/services/legal_document_service.py +++ b/backend-compliance/compliance/services/legal_document_service.py @@ -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) diff --git a/backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql b/backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql new file mode 100644 index 00000000..b8ea4041 --- /dev/null +++ b/backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql @@ -0,0 +1,76 @@ +-- ========================================================= +-- Migration 148: 5-stufiger Lifecycle für Legal Documents +-- +-- Erweitert compliance_legal_document_versions.status um: +-- - review_internal (DSB-interne Pruefung, war vorher implizit 'review') +-- - review_client (Mandanten-Freigabe-Phase nach DSB-OK) +-- +-- Plus CHECK-Constraint, der bisher fehlte (status war freier VARCHAR(20)). +-- 'review' bleibt erlaubt für Rückwärts-Kompatibilität. +-- +-- Approvals-Tabelle bekommt neue action-Werte: +-- - submitted_internal | approved_internal +-- - submitted_client | approved_client +-- - rejected_internal | rejected_client (zur Differenzierung im Audit) +-- ========================================================= + +DO $$ +BEGIN + -- Status-CHECK falls noch nicht vorhanden + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'compliance.compliance_legal_document_versions'::regclass + AND conname = 'chk_legal_doc_version_status' + ) THEN + ALTER TABLE compliance.compliance_legal_document_versions + ADD CONSTRAINT chk_legal_doc_version_status + CHECK (status IN ( + 'draft', + 'review', -- backward-compat (deprecated; neuer Code nutzt review_internal/review_client) + 'review_internal', -- DSB-Phase (NEU) + 'review_client', -- Mandant-Phase (NEU) + 'approved', + 'published', + 'archived', + 'rejected' + )); + END IF; + + -- Approvals-Action-CHECK falls noch nicht vorhanden + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'compliance.compliance_legal_document_approvals'::regclass + AND conname = 'chk_legal_doc_approval_action' + ) THEN + ALTER TABLE compliance.compliance_legal_document_approvals + ADD CONSTRAINT chk_legal_doc_approval_action + CHECK (action IN ( + 'created', 'submitted', + 'submitted_internal', 'submitted_client', -- NEU + 'approved', + 'approved_internal', 'approved_client', -- NEU + 'rejected', + 'rejected_internal', 'rejected_client', -- NEU + 'published', 'archived' + )); + END IF; +END $$; + +-- Neue Felder für 5-stufige Audit-Trail-Anreicherung +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'compliance' + AND table_name = 'compliance_legal_document_versions' + AND column_name = 'approved_internal_by' + ) THEN + ALTER TABLE compliance.compliance_legal_document_versions + ADD COLUMN approved_internal_by VARCHAR(200), + ADD COLUMN approved_internal_at TIMESTAMPTZ, + ADD COLUMN approved_client_by VARCHAR(200), + ADD COLUMN approved_client_at TIMESTAMPTZ, + ADD COLUMN submitted_by VARCHAR(200), + ADD COLUMN submitted_at TIMESTAMPTZ; + END IF; +END $$;