From d3990b4019ead89e6a8c172a1c31d0d0d4be7bdf Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 22 May 2026 00:31:48 +0300 Subject: [PATCH] feat: implement final approval gate workflow and checklist --- .../backend/src/application/final_approval.py | 378 +++++++++++++++ apps/backend/src/domain/contracts/__init__.py | 14 + apps/backend/src/domain/contracts/models.py | 44 ++ apps/backend/src/domain/contracts/openapi.py | 14 + apps/backend/src/presentation/main.py | 2 + .../src/presentation/routes/final_approval.py | 89 ++++ .../test_final_approval_gate_public_api.py | 427 ++++++++++++++++ .../src/features/final-approval/model.ts | 54 +++ .../src/features/final-approval/ui.tsx | 205 ++++++++ .../src/pages/article-detail/index.tsx | 2 + apps/frontend/src/shared/pipeline-api.ts | 33 ++ .../tests/final_approval.model.test.mjs | 53 ++ packages/shared/openapi.json | 455 ++++++++++++++++++ packages/shared/src/api-types.ts | 44 ++ tasks/016-final-approval-gate.md | 51 +- 15 files changed, 1851 insertions(+), 14 deletions(-) create mode 100644 apps/backend/src/application/final_approval.py create mode 100644 apps/backend/src/presentation/routes/final_approval.py create mode 100644 apps/backend/tests/integration/test_final_approval_gate_public_api.py create mode 100644 apps/frontend/src/features/final-approval/model.ts create mode 100644 apps/frontend/src/features/final-approval/ui.tsx create mode 100644 apps/frontend/tests/final_approval.model.test.mjs diff --git a/apps/backend/src/application/final_approval.py b/apps/backend/src/application/final_approval.py new file mode 100644 index 0000000..cdd9187 --- /dev/null +++ b/apps/backend/src/application/final_approval.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import re +from datetime import UTC, datetime +from uuid import UUID + +from src.domain.contracts import ( + AgentJobType, + AssetStatus, + AssetType, + ArticleWorkflowStatus, + ClaimRiskLevel, + ClaimSupportStatus, + ContentReviewKind, + FinalApprovalChecklistItemSummary, + FinalApprovalChecklistResponse, + FinalApprovalRequest, + FinalApprovalResponse, + FinalRevisionRequest, + FinalRevisionRequestResponse, + PlanReviewStatus, +) + + +def get_final_approval_checklist( + repository: object, + *, + article_id: UUID, + request: FinalApprovalRequest | None = None, +) -> FinalApprovalChecklistResponse: + repository.articles.get(article_id) + latest_draft = repository.article_drafts.latest_for_article(article_id) + approved_plan = _approved_plan(repository, article_id=article_id) + evidence = repository.evidence_items.list_for_article(article_id) + assets = repository.assets.list_for_article(article_id) + unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id) + high_risk_unsupported = _high_risk_unsupported_claims( + repository, + article_id=article_id, + draft=latest_draft, + ) + settings = request.publishing_settings if request is not None else None + requested_draft_version = request.draft_version if request is not None else None + + checklist_items: list[FinalApprovalChecklistItemSummary] = [] + blockers: list[str] = [] + completed_items: list[str] = [] + + def add_item(key: str, title: str, completed: bool, blocker: str | None = None) -> None: + checklist_items.append( + FinalApprovalChecklistItemSummary( + key=key, + title=title, + completed=completed, + blocker=blocker, + ) + ) + if completed: + completed_items.append(title) + elif blocker: + blockers.append(blocker) + + has_draft = latest_draft is not None + add_item( + key="exact_draft_version", + title="Exact draft version selected", + completed=has_draft and requested_draft_version == latest_draft.version, + blocker=( + None + if has_draft and requested_draft_version == latest_draft.version + else ( + "Draft version is required for final approval." + if not has_draft + else f"Final approval requires latest draft version {latest_draft.version}." + ) + ), + ) + + add_item( + key="plan_followed", + title="Plan followed", + completed=has_draft and _draft_contains_approved_plan(approved_plan, latest_draft.body_markdown), + blocker="Approved plan sections are not fully reflected in the assembled draft.", + ) + add_item( + key="evidence_reviewed", + title="Evidence reviewed", + completed=bool(evidence) and all(item.review_status != "PENDING" for item in evidence), + blocker="Evidence review is incomplete (there are pending evidence items).", + ) + add_item( + key="no_high_risk_unsupported_claims", + title="No high-risk unsupported claims remain", + completed=not high_risk_unsupported, + blocker=( + None + if not high_risk_unsupported + else ( + "High-risk unsupported claims must be resolved: " + + "; ".join(high_risk_unsupported[:3]) + ) + ), + ) + + add_item( + key="seo_metadata_approved", + title="SEO metadata approved", + completed=has_draft and _has_seo_metadata(latest_draft) and not _has_unresolved_meta_issues(unresolved), + blocker="SEO metadata is incomplete or has unresolved review issues.", + ) + + required_assets = [asset for asset in assets if asset.status != AssetStatus.REJECTED] + unapproved_required_assets = [ + asset + for asset in required_assets + if asset.status != AssetStatus.APPROVED or not asset.file_url + ] + add_item( + key="images_assets_approved", + title="Images/assets approved", + completed=bool(required_assets) and not unapproved_required_assets, + blocker=( + "Required assets are missing." + if not required_assets + else f"{len(unapproved_required_assets)} required assets are not approved." + ), + ) + + required_table_diagrams = [ + asset + for asset in required_assets + if asset.asset_type in { + AssetType.DIAGRAM, + AssetType.TABLE, + AssetType.FLOWCHART, + AssetType.COMPARISON_MATRIX, + AssetType.ARCHITECTURE_DIAGRAM, + } + ] + unapproved_table_diagrams = [ + asset + for asset in required_table_diagrams + if asset.status != AssetStatus.APPROVED or not asset.file_url + ] + add_item( + key="tables_and_diagrams_approved", + title="Tables and diagrams approved", + completed=bool(required_table_diagrams) and not unapproved_table_diagrams, + blocker=( + "Required table/diagram assets are missing." + if not required_table_diagrams + else f"{len(unapproved_table_diagrams)} table/diagram assets are not approved." + ), + ) + + has_internal_links = has_draft and _has_internal_links(latest_draft.body_markdown) + add_item( + key="internal_links_approved", + title="Internal links approved", + completed=bool(has_internal_links) and not _has_unresolved_internal_link_issue(unresolved), + blocker="Draft must include approved internal links.", + ) + + frontmatter = settings.frontmatter if settings is not None else None + add_item( + key="frontmatter_fields_selected", + title="Frontmatter fields selected", + completed=isinstance(frontmatter, dict) and bool(frontmatter), + blocker="Required publishing settings are missing: frontmatter fields.", + ) + content_path = settings.content_path if settings is not None else None + add_item( + key="content_path_selected", + title="Content path selected", + completed=bool(content_path and content_path.strip()), + blocker="Required publishing settings are missing: content path.", + ) + author = settings.author if settings is not None else None + add_item( + key="author_selected", + title="Author selected", + completed=bool(author and author.strip()), + blocker="Required publishing settings are missing: author.", + ) + publishing_mode = settings.publishing_mode if settings is not None else None + add_item( + key="publishing_mode_selected", + title="Publishing mode selected", + completed=bool(publishing_mode and publishing_mode.strip()), + blocker="Required publishing settings are missing: publishing mode.", + ) + + can_approve = len(blockers) == 0 + return FinalApprovalChecklistResponse( + article_id=article_id, + draft_version=latest_draft.version if latest_draft is not None else None, + requested_draft_version=requested_draft_version, + can_approve=can_approve, + completed_items=completed_items, + blockers=blockers, + items=checklist_items, + ) + + +def approve_final_article( + repository: object, + *, + article_id: UUID, + request: FinalApprovalRequest, + actor_user_id: UUID, +) -> FinalApprovalResponse: + article = repository.articles.get(article_id) + checklist = get_final_approval_checklist( + repository, + article_id=article_id, + request=request, + ) + if not checklist.can_approve: + raise PermissionError("; ".join(checklist.blockers)) + + now = _now() + updated = repository.articles.update_status( + article_id=article_id, + status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED, + updated_at=now, + ) + repository.articles.create_workflow_event( + article_id=article_id, + event_type="FINAL_APPROVAL_GRANTED", + from_status=article.status, + to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED, + actor_user_id=actor_user_id, + payload={ + "draft_version": checklist.draft_version, + "publishing_settings": ( + request.publishing_settings.model_dump(mode="json") + if request.publishing_settings is not None + else {} + ), + "completed_items": checklist.completed_items, + }, + created_at=now, + ) + return FinalApprovalResponse(article=updated, checklist=checklist) + + +def request_final_revision( + repository: object, + *, + article_id: UUID, + request: FinalRevisionRequest, + actor_user_id: UUID, +) -> FinalRevisionRequestResponse: + article = repository.articles.get(article_id) + checklist_request = FinalApprovalRequest( + draft_version=request.draft_version, + publishing_settings=None, + ) + checklist = get_final_approval_checklist( + repository, + article_id=article_id, + request=checklist_request, + ) + now = _now() + updated = repository.articles.update_status( + article_id=article_id, + status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED, + updated_at=now, + ) + repository.articles.create_workflow_event( + article_id=article_id, + event_type="FINAL_REVISION_REQUESTED", + from_status=article.status, + to_status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED, + actor_user_id=actor_user_id, + payload={ + "reason": (request.reason or "").strip(), + "draft_version": checklist.draft_version, + "blockers": checklist.blockers, + "completed_items": checklist.completed_items, + }, + created_at=now, + ) + return FinalRevisionRequestResponse(article=updated, checklist=checklist) + + +def _approved_plan(repository: object, *, article_id: UUID) -> object | None: + plans = repository.article_plans.list_for_article(article_id) + approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED] + if not approved_plans: + return None + approved_plans.sort(key=lambda plan: plan.version) + return approved_plans[-1] + + +def _draft_contains_approved_plan(approved_plan: object | None, body_markdown: str) -> bool: + if approved_plan is None or not approved_plan.sections: + return False + for section in approved_plan.sections: + if f"## {section.heading}" not in body_markdown: + return False + return True + + +def _has_seo_metadata(draft: object) -> bool: + return bool(draft.meta_title and draft.meta_title.strip()) and bool( + draft.meta_description and draft.meta_description.strip() + ) + + +def _has_unresolved_meta_issues(unresolved: list[object]) -> bool: + return any( + item.review_kind == ContentReviewKind.SEO + and item.location in {"meta_title", "meta_description", "title"} + for item in unresolved + ) + + +def _has_internal_links(markdown: str) -> bool: + return re.search(r"\]\(/[^)]+\)", markdown) is not None + + +def _has_unresolved_internal_link_issue(unresolved: list[object]) -> bool: + for item in unresolved: + if item.suggestion_key == "internal-link": + return True + if "internal link" in item.message.lower(): + return True + return False + + +def _high_risk_unsupported_claims( + repository: object, + *, + article_id: UUID, + draft: object | None, +) -> list[str]: + claims: list[str] = [] + for claim in repository.claims.list_for_article(article_id): + if ( + claim.support_status == ClaimSupportStatus.UNSUPPORTED + and claim.risk_level == ClaimRiskLevel.HIGH + ): + claims.append(claim.claim_text) + + for job in repository.agent_jobs.list_for_article(article_id): + if job.job_type != AgentJobType.SECTION_SCAFFOLD: + continue + raw_claims = job.payload.get("unsupported_claims") + if not isinstance(raw_claims, list): + continue + for raw in raw_claims: + if isinstance(raw, dict): + risk_level = str(raw.get("risk_level", "")).lower() + claim_text = str(raw.get("claim_text", "")).strip() + if risk_level == "high" and claim_text: + claims.append(claim_text) + + if draft is not None: + for warning in draft.unsupported_claim_warnings: + if "high-risk" in warning.lower(): + claims.append(warning) + + deduped: list[str] = [] + seen: set[str] = set() + for claim in claims: + normalized = claim.strip() + if not normalized: + continue + if normalized in seen: + continue + seen.add(normalized) + deduped.append(normalized) + return deduped + + +def _now() -> datetime: + return datetime.now(UTC) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index bc793ae..dbd0f37 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -57,6 +57,11 @@ from .models import ( EvidenceMatrixResponse, EvidenceResponse, EvidenceUpdateRequest, + FinalApprovalChecklistItemSummary, + FinalApprovalChecklistResponse, + FinalApprovalPublishingSettings, + FinalApprovalRequest, + FinalApprovalResponse, PlanSectionSummary, PlanSectionEditRequest, PlanListResponse, @@ -78,6 +83,8 @@ from .models import ( SeoReviewReportResponse, LanguageReviewRunResponse, LanguageReviewReportResponse, + FinalRevisionRequest, + FinalRevisionRequestResponse, FinalReviewIssueSummary, FinalReviewIssuesResponse, RunnerFileRef, @@ -149,6 +156,13 @@ __all__ = [ "EvidenceMatrixResponse", "EvidenceResponse", "EvidenceUpdateRequest", + "FinalApprovalChecklistItemSummary", + "FinalApprovalChecklistResponse", + "FinalApprovalPublishingSettings", + "FinalApprovalRequest", + "FinalApprovalResponse", + "FinalRevisionRequest", + "FinalRevisionRequestResponse", "FinalReviewIssueSummary", "FinalReviewIssuesResponse", "LanguageReviewRunResponse", diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index a04abb9..71b92e7 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -598,6 +598,50 @@ class FinalReviewIssuesResponse(ContractModel): unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list) +class FinalApprovalPublishingSettings(ContractModel): + content_path: str | None = None + author: str | None = None + publishing_mode: str | None = None + frontmatter: JsonObject | None = None + + +class FinalApprovalRequest(ContractModel): + draft_version: int | None = Field(default=None, ge=1) + publishing_settings: FinalApprovalPublishingSettings | None = None + + +class FinalRevisionRequest(ContractModel): + reason: str | None = None + draft_version: int | None = Field(default=None, ge=1) + + +class FinalApprovalChecklistItemSummary(ContractModel): + key: str = Field(min_length=1) + title: str = Field(min_length=1) + completed: bool + blocker: str | None = None + + +class FinalApprovalChecklistResponse(ContractModel): + article_id: UUID + draft_version: int | None = Field(default=None, ge=1) + requested_draft_version: int | None = Field(default=None, ge=1) + can_approve: bool + completed_items: list[str] = Field(default_factory=list) + blockers: list[str] = Field(default_factory=list) + items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list) + + +class FinalApprovalResponse(ContractModel): + article: ArticleSummary + checklist: FinalApprovalChecklistResponse + + +class FinalRevisionRequestResponse(ContractModel): + article: ArticleSummary + checklist: FinalApprovalChecklistResponse + + class AssetGenerateSpecsResponse(ContractModel): assets: list[AssetSummary] = Field(default_factory=list) diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index 9d79a91..edde99e 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -58,6 +58,11 @@ from .models import ( ContentReviewIssueSummary, ContentReviewReportSummary, ContentReviewSuggestionSummary, + FinalApprovalChecklistItemSummary, + FinalApprovalChecklistResponse, + FinalApprovalPublishingSettings, + FinalApprovalRequest, + FinalApprovalResponse, EvidenceSummary, EvidenceCreateRequest, EvidenceMatrixResponse, @@ -84,6 +89,8 @@ from .models import ( ReviewSuggestionEditRequest, ReviewActionResponse, ReviewSummary, + FinalRevisionRequest, + FinalRevisionRequestResponse, SeoReviewReportResponse, SeoReviewRunResponse, RunnerFileRef, @@ -167,6 +174,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( ContentReviewIssueSummary, ContentReviewReportSummary, ContentReviewSuggestionSummary, + FinalApprovalChecklistItemSummary, + FinalApprovalChecklistResponse, + FinalApprovalPublishingSettings, + FinalApprovalRequest, + FinalApprovalResponse, + FinalRevisionRequest, + FinalRevisionRequestResponse, FinalReviewIssueSummary, FinalReviewIssuesResponse, LanguageReviewReportResponse, diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index 35e0d59..2dbb501 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -15,6 +15,7 @@ from src.presentation.routes.auth import router as auth_router from src.presentation.routes.boundary_questions import router as boundary_questions_router from src.presentation.routes.drafts import router as drafts_router from src.presentation.routes.evidence import router as evidence_router +from src.presentation.routes.final_approval import router as final_approval_router from src.presentation.routes.plans import router as plans_router from src.presentation.routes.reviews import router as reviews_router from src.presentation.routes.sites import router as sites_router @@ -29,6 +30,7 @@ app.include_router(plans_router) app.include_router(evidence_router) app.include_router(drafts_router) app.include_router(reviews_router) +app.include_router(final_approval_router) app.include_router(agent_jobs_router) app.include_router(internal_agent_jobs_router) app.include_router(sites_router) diff --git a/apps/backend/src/presentation/routes/final_approval.py b/apps/backend/src/presentation/routes/final_approval.py new file mode 100644 index 0000000..adc22b6 --- /dev/null +++ b/apps/backend/src/presentation/routes/final_approval.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from src.application.final_approval import ( + approve_final_article, + get_final_approval_checklist, + request_final_revision, +) +from src.domain.auth import EDITOR_OR_ADMIN_ROLES +from src.domain.contracts import ( + CurrentUser, + FinalApprovalChecklistResponse, + FinalApprovalRequest, + FinalApprovalResponse, + FinalRevisionRequest, + FinalRevisionRequestResponse, +) +from src.infrastructure.repositories import BackendRepository +from src.presentation.dependencies import get_repository, require_roles + + +router = APIRouter(prefix="/api", tags=["final-approval"]) + + +@router.get( + "/articles/{article_id}/final-approval/checklist", + response_model=FinalApprovalChecklistResponse, +) +def get_article_final_approval_checklist( + article_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> FinalApprovalChecklistResponse: + try: + return get_final_approval_checklist( + repository, + article_id=article_id, + request=None, + ) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + +@router.post( + "/articles/{article_id}/final-approval", + response_model=FinalApprovalResponse, +) +def post_article_final_approval( + article_id: UUID, + request: FinalApprovalRequest, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> FinalApprovalResponse: + try: + return approve_final_article( + repository, + article_id=article_id, + request=request, + actor_user_id=current_user.id, + ) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + except PermissionError as error: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error + + +@router.post( + "/articles/{article_id}/final-revision-request", + response_model=FinalRevisionRequestResponse, +) +def post_article_final_revision_request( + article_id: UUID, + request: FinalRevisionRequest, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> FinalRevisionRequestResponse: + try: + return request_final_revision( + repository, + article_id=article_id, + request=request, + actor_user_id=current_user.id, + ) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + diff --git a/apps/backend/tests/integration/test_final_approval_gate_public_api.py b/apps/backend/tests/integration/test_final_approval_gate_public_api.py new file mode 100644 index 0000000..e3a0fe1 --- /dev/null +++ b/apps/backend/tests/integration/test_final_approval_gate_public_api.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import base64 +import os +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi.testclient import TestClient + + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(BACKEND_ROOT)) + +from src.application.seed_data import seed_reference_data # noqa: E402 +from src.infrastructure.repositories import open_backend_repository # noqa: E402 +from src.presentation.dependencies import get_repository # noqa: E402 +from src.presentation.main import app # noqa: E402 + + +DEMO_EDITOR_EMAIL = "editor@example.com" +DEMO_ADMIN_EMAIL = "admin@example.com" +DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email" + + +class FinalApprovalGatePublicApiTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects") + dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'final-approval-gate.db'}" + self.repository = open_backend_repository(dsn) + self.repository.setup() + seed_reference_data(self.repository) + app.dependency_overrides[get_repository] = lambda: self.repository + self.client = TestClient(app) + + def tearDown(self) -> None: + app.dependency_overrides.clear() + os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None) + self.tmp_dir.cleanup() + + def test_high_risk_unsupported_claims_block_final_approval(self) -> None: + article_id, draft = self._prepare_article_ready_for_final_approval( + include_high_risk_unsupported=True, + ) + + response = self.client.post( + f"/api/articles/{article_id}/final-approval", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=self._final_approval_payload(draft_version=draft["version"]), + ) + self.assertEqual(409, response.status_code, response.text) + self.assertIn("high-risk unsupported claims", response.text.lower()) + + def test_final_approval_requires_exact_draft_version(self) -> None: + article_id, draft = self._prepare_article_ready_for_final_approval() + + response = self.client.post( + f"/api/articles/{article_id}/final-approval", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=self._final_approval_payload(draft_version=draft["version"] + 1), + ) + self.assertEqual(409, response.status_code, response.text) + self.assertIn("latest draft version", response.text.lower()) + + def test_missing_required_publishing_settings_block_final_approval(self) -> None: + article_id, draft = self._prepare_article_ready_for_final_approval() + + response = self.client.post( + f"/api/articles/{article_id}/final-approval", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "draft_version": draft["version"], + "publishing_settings": { + "content_path": "", + "author": "", + "publishing_mode": "", + "frontmatter": {}, + }, + }, + ) + self.assertEqual(409, response.status_code, response.text) + self.assertIn("required publishing settings are missing", response.text.lower()) + + def test_unapproved_required_assets_block_final_approval(self) -> None: + article_id, draft = self._prepare_article_ready_for_final_approval(approve_assets=False) + + response = self.client.post( + f"/api/articles/{article_id}/final-approval", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=self._final_approval_payload(draft_version=draft["version"]), + ) + self.assertEqual(409, response.status_code, response.text) + self.assertIn("required assets", response.text.lower()) + + def test_successful_final_approval_writes_event_and_moves_to_publish_dry_run_required(self) -> None: + article_id, draft = self._prepare_article_ready_for_final_approval() + actor = self.client.get( + "/api/me", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()["user"] + payload = self._final_approval_payload(draft_version=draft["version"]) + + response = self.client.post( + f"/api/articles/{article_id}/final-approval", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=payload, + ) + self.assertEqual(200, response.status_code, response.text) + body = response.json() + self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"]) + self.assertTrue(body["checklist"]["can_approve"]) + self.assertFalse(body["checklist"]["blockers"]) + + detail = self.client.get( + f"/api/articles/{article_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json() + events = [ + event + for event in detail["workflow_events"] + if event["event_type"] == "FINAL_APPROVAL_GRANTED" + ] + self.assertTrue(events, "Expected FINAL_APPROVAL_GRANTED event") + latest = events[-1] + self.assertEqual(actor["id"], latest["actor_user_id"]) + self.assertTrue(latest["created_at"]) + self.assertEqual(draft["version"], latest["payload"]["draft_version"]) + self.assertEqual(payload["publishing_settings"], latest["payload"]["publishing_settings"]) + + def test_revision_request_writes_event_and_moves_article_to_final_revision_required(self) -> None: + article_id, draft = self._prepare_article_ready_for_final_approval() + actor = self.client.get( + "/api/me", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()["user"] + + response = self.client.post( + f"/api/articles/{article_id}/final-revision-request", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "reason": "Need factual clarification in section 2.", + "draft_version": draft["version"], + }, + ) + self.assertEqual(200, response.status_code, response.text) + body = response.json() + self.assertEqual("FINAL_REVISION_REQUIRED", body["article"]["status"]) + + detail = self.client.get( + f"/api/articles/{article_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json() + events = [ + event + for event in detail["workflow_events"] + if event["event_type"] == "FINAL_REVISION_REQUESTED" + ] + self.assertTrue(events, "Expected FINAL_REVISION_REQUESTED event") + latest = events[-1] + self.assertEqual(actor["id"], latest["actor_user_id"]) + self.assertEqual("Need factual clarification in section 2.", latest["payload"]["reason"]) + self.assertEqual(draft["version"], latest["payload"]["draft_version"]) + + def test_checklist_endpoint_returns_blockers_and_completed_items(self) -> None: + article_id, _ = self._prepare_article_ready_for_final_approval(approve_assets=False) + + response = self.client.get( + f"/api/articles/{article_id}/final-approval/checklist", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, response.status_code, response.text) + checklist = response.json() + self.assertFalse(checklist["can_approve"]) + self.assertTrue(checklist["blockers"]) + self.assertTrue(checklist["completed_items"]) + self.assertTrue(any(item["completed"] for item in checklist["items"])) + self.assertTrue(any(not item["completed"] for item in checklist["items"])) + + def _final_approval_payload(self, *, draft_version: int) -> dict[str, Any]: + return { + "draft_version": draft_version, + "publishing_settings": { + "content_path": "/guides/final-approval-gate", + "author": "Editorial Team", + "publishing_mode": "MANUAL", + "frontmatter": { + "category": "Guides", + "tags": ["workflow", "approval"], + }, + }, + } + + def _prepare_article_ready_for_final_approval( + self, + *, + include_high_risk_unsupported: bool = False, + approve_assets: bool = True, + ) -> tuple[str, dict[str, Any]]: + article_id = self._create_article_with_approved_plan() + self._ensure_evidence_ready(article_id) + self._approve_all_evidence(article_id) + + section_jobs = self._start_parallel_production(article_id) + self.assertGreaterEqual(len(section_jobs), 1) + first_job = section_jobs[0] + + if include_high_risk_unsupported: + failed = self._complete_job( + first_job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/high-risk.md"}], + "payload": { + "used_evidence_ids": first_job["payload"]["used_evidence_ids"], + "unsupported_claims": [ + { + "claim_text": "Unverified migration benchmark for market leadership.", + "risk_level": "high", + } + ], + "draft_markdown": "Section draft with high-risk unsupported claim.", + }, + }, + ) + self.assertEqual("FAILED", failed["status"]) + retry_response = self.client.post( + f"/api/agent-jobs/{first_job['id']}/retry", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + ) + self.assertEqual(201, retry_response.status_code, retry_response.text) + retry_job = retry_response.json()["job"] + retried = self._complete_job( + retry_job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/retry-clean.md"}], + "payload": { + "used_evidence_ids": retry_job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": ( + f"## {retry_job['payload']['heading']}\n\n" + "Clean retry section with [internal guide](/guides/internal)." + ), + }, + }, + ) + self.assertEqual("SUCCEEDED", retried["status"]) + else: + completed = self._complete_job( + first_job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": "outputs/section-1.md"}], + "payload": { + "used_evidence_ids": first_job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": ( + f"## {first_job['payload']['heading']}\n\n" + "Use [internal guide](/guides/internal) for implementation details." + ), + }, + }, + ) + self.assertEqual("SUCCEEDED", completed["status"]) + + for index, job in enumerate(section_jobs[1:], start=2): + completed = self._complete_job( + job["id"], + output={ + "status": "SUCCEEDED", + "output_files": [{"path": f"outputs/section-{index}.md"}], + "payload": { + "used_evidence_ids": job["payload"]["used_evidence_ids"], + "unsupported_claims": [], + "draft_markdown": f"## {job['payload']['heading']}\n\nSection {index} ready.", + }, + }, + ) + self.assertEqual("SUCCEEDED", completed["status"]) + + assemble_response = self.client.post( + f"/api/articles/{article_id}/draft/assemble", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, assemble_response.status_code, assemble_response.text) + draft = assemble_response.json()["draft"] + + assets_response = self.client.post( + f"/api/articles/{article_id}/assets/generate-specs", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, assets_response.status_code, assets_response.text) + assets = assets_response.json()["assets"] + self.assertTrue(assets) + + if approve_assets: + for asset in assets: + upload_response = self.client.post( + f"/api/articles/{article_id}/assets/{asset['id']}/upload", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "filename": f"{asset['id']}.png", + "content_base64": base64.b64encode(b"asset-binary").decode("utf-8"), + "content_type": "image/png", + }, + ) + self.assertEqual(200, upload_response.status_code, upload_response.text) + approve_response = self.client.post( + f"/api/articles/{article_id}/assets/{asset['id']}/approve", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, approve_response.status_code, approve_response.text) + + return article_id, draft + + def _approve_all_evidence(self, article_id: str) -> None: + evidence_response = self.client.get( + f"/api/articles/{article_id}/evidence", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, evidence_response.status_code, evidence_response.text) + evidence = evidence_response.json()["evidence"] + self.assertTrue(evidence) + for item in evidence: + patch_response = self.client.patch( + f"/api/articles/{article_id}/evidence/{item['id']}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={"review_status": "APPROVED"}, + ) + self.assertEqual(200, patch_response.status_code, patch_response.text) + + def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]: + response = self.client.post( + f"/internal/agent-jobs/{job_id}/complete", + json={ + "workspace_path": f"/tmp/{job_id}", + "stdout": "fake section scaffolding runner\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "output": output, + }, + ) + self.assertEqual(200, response.status_code, response.text) + return response.json()["job"] + + def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]: + response = self.client.post( + f"/api/articles/{article_id}/draft/start", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(202, response.status_code, response.text) + return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"] + + def _ensure_evidence_ready(self, article_id: str) -> None: + research_response = self.client.post( + f"/api/articles/{article_id}/research/start", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, research_response.status_code, research_response.text) + + evidence_response = self.client.get( + f"/api/articles/{article_id}/evidence", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, evidence_response.status_code, evidence_response.text) + self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"]) + + def _create_article_with_approved_plan(self) -> str: + site = self.client.get( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()[0]["site"] + article = self.client.post( + "/api/articles", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "target_site_id": site["id"], + "brief_description": "Validate final approval gate checklist and transitions.", + "working_title": "Final Approval Gate", + "content_type": "longform_guide", + "primary_keyword": "final approval gate", + }, + ).json()["article"] + article_id = article["id"] + + questions = self.client.post( + f"/api/articles/{article_id}/boundary-questions/generate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ).json()["questions"] + for question in questions: + if question["is_required"]: + patch_response = self.client.patch( + f"/api/articles/{article_id}/boundary-questions/{question['id']}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={"answer": f"Answer for {question['category']}"}, + ) + self.assertEqual(200, patch_response.status_code, patch_response.text) + + submit_response = self.client.post( + f"/api/articles/{article_id}/boundary-questions/submit", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, submit_response.status_code, submit_response.text) + + plan_response = self.client.post( + f"/api/articles/{article_id}/plan/generate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(201, plan_response.status_code, plan_response.text) + plan = plan_response.json()["plan"] + self.assertGreaterEqual(len(plan["sections"]), 1) + + approve_response = self.client.post( + f"/api/articles/{article_id}/plans/{plan['id']}/approve", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, approve_response.status_code, approve_response.text) + return article_id + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/src/features/final-approval/model.ts b/apps/frontend/src/features/final-approval/model.ts new file mode 100644 index 0000000..a64f1e2 --- /dev/null +++ b/apps/frontend/src/features/final-approval/model.ts @@ -0,0 +1,54 @@ +import type { + FinalApprovalChecklistItemSummary, + FinalApprovalChecklistResponse, +} from "@pipeline/shared"; + +export type FinalApprovalChecklistItemViewModel = { + key: string; + title: string; + completed: boolean; + blocker: string; +}; + +export type FinalApprovalViewModel = { + canApprove: boolean; + completedItems: string[]; + blockers: string[]; + draftVersion: number | null; + requestedDraftVersion: number | null; + items: FinalApprovalChecklistItemViewModel[]; +}; + +export function buildFinalApprovalViewModel( + checklist: FinalApprovalChecklistResponse | null, +): FinalApprovalViewModel { + if (!checklist) { + return { + canApprove: false, + completedItems: [], + blockers: [], + draftVersion: null, + requestedDraftVersion: null, + items: [], + }; + } + return { + canApprove: checklist.can_approve, + completedItems: checklist.completed_items ?? [], + blockers: checklist.blockers ?? [], + draftVersion: checklist.draft_version ?? null, + requestedDraftVersion: checklist.requested_draft_version ?? null, + items: (checklist.items ?? []).map(mapChecklistItem), + }; +} + +function mapChecklistItem( + item: FinalApprovalChecklistItemSummary, +): FinalApprovalChecklistItemViewModel { + return { + key: item.key, + title: item.title, + completed: item.completed, + blocker: item.blocker ?? "", + }; +} diff --git a/apps/frontend/src/features/final-approval/ui.tsx b/apps/frontend/src/features/final-approval/ui.tsx new file mode 100644 index 0000000..231d254 --- /dev/null +++ b/apps/frontend/src/features/final-approval/ui.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import type { FinalApprovalChecklistResponse } from "@pipeline/shared"; + +import { + ApiError, + fetchFinalApprovalChecklist, + submitFinalApproval, + submitFinalRevisionRequest, +} from "@/shared/pipeline-api"; +import { buildFinalApprovalViewModel } from "./model"; + +type FinalApprovalPanelProps = { + articleId: string; +}; + +const DEFAULT_FRONTMATTER = '{\n "category": "Guides"\n}'; + +export function FinalApprovalPanel({ articleId }: FinalApprovalPanelProps) { + const [checklist, setChecklist] = useState(null); + const [draftVersionInput, setDraftVersionInput] = useState(""); + const [contentPath, setContentPath] = useState("/guides/final-approval-gate"); + const [author, setAuthor] = useState("Editorial Team"); + const [publishingMode, setPublishingMode] = useState("MANUAL"); + const [frontmatterText, setFrontmatterText] = useState(DEFAULT_FRONTMATTER); + const [revisionReason, setRevisionReason] = useState("Need final refinements before approval."); + const [busyKey, setBusyKey] = useState(null); + const [message, setMessage] = useState(""); + + const viewModel = useMemo( + () => buildFinalApprovalViewModel(checklist), + [checklist], + ); + + async function refreshChecklist() { + setBusyKey("refresh"); + setMessage(""); + try { + const response = await fetchFinalApprovalChecklist(articleId); + setChecklist(response); + if (!draftVersionInput && response.draft_version) { + setDraftVersionInput(String(response.draft_version)); + } + } catch (error) { + setMessage(error instanceof ApiError ? error.message : "Failed to fetch final checklist."); + } finally { + setBusyKey(null); + } + } + + async function approve() { + let frontmatter: Record | null = null; + try { + const parsed = JSON.parse(frontmatterText); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + frontmatter = parsed as Record; + } + } catch { + setMessage("Frontmatter must be a valid JSON object."); + return; + } + + const parsedDraftVersion = Number.parseInt(draftVersionInput, 10); + setBusyKey("approve"); + setMessage(""); + try { + const response = await submitFinalApproval(articleId, { + draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion, + publishing_settings: { + content_path: contentPath, + author, + publishing_mode: publishingMode, + frontmatter, + }, + }); + setChecklist(response.checklist); + setMessage(`Final approval completed. Article status: ${response.article.status}.`); + } catch (error) { + setMessage(error instanceof ApiError ? error.message : "Final approval failed."); + await refreshChecklist(); + } finally { + setBusyKey(null); + } + } + + async function requestRevision() { + const parsedDraftVersion = Number.parseInt(draftVersionInput, 10); + setBusyKey("revision"); + setMessage(""); + try { + const response = await submitFinalRevisionRequest(articleId, { + reason: revisionReason, + draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion, + }); + setChecklist(response.checklist); + setMessage(`Revision requested. Article status: ${response.article.status}.`); + } catch (error) { + setMessage(error instanceof ApiError ? error.message : "Failed to request revision."); + } finally { + setBusyKey(null); + } + } + + return ( +
+
+
+

Final approval gate

+

Checklist blockers: {viewModel.blockers.length}

+
+
+ +
+
+ + {message ?

{message}

: null} + + {viewModel.items.length > 0 ? ( + + + + + + + + + + {viewModel.items.map((item) => ( + + + + + + ))} + +
Checklist itemStatusDetails
{item.title}{item.completed ? "Completed" : "Blocked"}{item.completed ? "—" : item.blocker || "—"}
+ ) : ( +

No checklist yet. Refresh to load current gate status.

+ )} + + {viewModel.completedItems.length > 0 ? ( +
    + {viewModel.completedItems.map((item) => ( +
  • Completed: {item}
  • + ))} +
+ ) : null} + + {viewModel.blockers.length > 0 ? ( +
    + {viewModel.blockers.map((item) => ( +
  • {item}
  • + ))} +
+ ) : null} + +
+ setDraftVersionInput(event.target.value)} + placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"} + inputMode="numeric" + /> + setContentPath(event.target.value)} + placeholder="/guides/final-approval-gate" + /> + setAuthor(event.target.value)} + placeholder="Author" + /> + setPublishingMode(event.target.value)} + placeholder="MANUAL" + /> +