feat: implement final approval gate workflow and checklist
This commit is contained in:
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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<FinalApprovalChecklistResponse | null>(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<string | null>(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<string, unknown> | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(frontmatterText);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
frontmatter = parsed as Record<string, unknown>;
|
||||
}
|
||||
} 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 (
|
||||
<section className="reviewPanel">
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Final approval gate</h2>
|
||||
<p>Checklist blockers: {viewModel.blockers.length}</p>
|
||||
</div>
|
||||
<div className="formActions">
|
||||
<button type="button" onClick={() => void refreshChecklist()} disabled={busyKey !== null}>
|
||||
{busyKey === "refresh" ? "Refreshing..." : "Refresh checklist"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
|
||||
|
||||
{viewModel.items.length > 0 ? (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Checklist item</th>
|
||||
<th>Status</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewModel.items.map((item) => (
|
||||
<tr key={item.key}>
|
||||
<td>{item.title}</td>
|
||||
<td>{item.completed ? "Completed" : "Blocked"}</td>
|
||||
<td>{item.completed ? "—" : item.blocker || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p>No checklist yet. Refresh to load current gate status.</p>
|
||||
)}
|
||||
|
||||
{viewModel.completedItems.length > 0 ? (
|
||||
<ul className="timeline">
|
||||
{viewModel.completedItems.map((item) => (
|
||||
<li key={item}>Completed: {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{viewModel.blockers.length > 0 ? (
|
||||
<ul className="timeline">
|
||||
{viewModel.blockers.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="reviewActions">
|
||||
<input
|
||||
value={draftVersionInput}
|
||||
onChange={(event) => setDraftVersionInput(event.target.value)}
|
||||
placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<input
|
||||
value={contentPath}
|
||||
onChange={(event) => setContentPath(event.target.value)}
|
||||
placeholder="/guides/final-approval-gate"
|
||||
/>
|
||||
<input
|
||||
value={author}
|
||||
onChange={(event) => setAuthor(event.target.value)}
|
||||
placeholder="Author"
|
||||
/>
|
||||
<input
|
||||
value={publishingMode}
|
||||
onChange={(event) => setPublishingMode(event.target.value)}
|
||||
placeholder="MANUAL"
|
||||
/>
|
||||
<textarea
|
||||
value={frontmatterText}
|
||||
onChange={(event) => setFrontmatterText(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<button type="button" onClick={() => void approve()} disabled={busyKey !== null}>
|
||||
{busyKey === "approve" ? "Approving..." : "Approve final"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="reviewActions">
|
||||
<input
|
||||
value={revisionReason}
|
||||
onChange={(event) => setRevisionReason(event.target.value)}
|
||||
placeholder="Revision reason"
|
||||
/>
|
||||
<button type="button" onClick={() => void requestRevision()} disabled={busyKey !== null}>
|
||||
{busyKey === "revision" ? "Submitting..." : "Request revision"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ArticleDetailShell } from "@/features/article-detail/ui";
|
||||
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||
import { FinalApprovalPanel } from "@/features/final-approval/ui";
|
||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { ApiError } from "@/shared/pipeline-api";
|
||||
|
||||
@@ -44,6 +45,7 @@ export default async function ArticleDetailPage({
|
||||
assets={detail.assets ?? []}
|
||||
/>
|
||||
<ArticleReviewPanel articleId={articleId} />
|
||||
<FinalApprovalPanel articleId={articleId} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,11 @@ import type {
|
||||
EvidenceCreateRequest,
|
||||
EvidenceUpdateRequest,
|
||||
FinalReviewIssuesResponse,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
PlanListResponse,
|
||||
@@ -622,3 +627,31 @@ export function fetchFinalReviewIssues(
|
||||
): Promise<FinalReviewIssuesResponse> {
|
||||
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
|
||||
}
|
||||
|
||||
export function fetchFinalApprovalChecklist(
|
||||
articleId: string,
|
||||
): Promise<FinalApprovalChecklistResponse> {
|
||||
return apiGet<FinalApprovalChecklistResponse>(
|
||||
`/api/articles/${articleId}/final-approval/checklist`,
|
||||
);
|
||||
}
|
||||
|
||||
export function submitFinalApproval(
|
||||
articleId: string,
|
||||
request: FinalApprovalRequest,
|
||||
): Promise<FinalApprovalResponse> {
|
||||
return apiPost<FinalApprovalRequest, FinalApprovalResponse>(
|
||||
`/api/articles/${articleId}/final-approval`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function submitFinalRevisionRequest(
|
||||
articleId: string,
|
||||
request: FinalRevisionRequest,
|
||||
): Promise<FinalRevisionRequestResponse> {
|
||||
return apiPost<FinalRevisionRequest, FinalRevisionRequestResponse>(
|
||||
`/api/articles/${articleId}/final-revision-request`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(testDir, "../src/features/final-approval/model.ts");
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
});
|
||||
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const { buildFinalApprovalViewModel } = moduleExports;
|
||||
|
||||
const viewModel = buildFinalApprovalViewModel({
|
||||
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
draft_version: 3,
|
||||
requested_draft_version: 2,
|
||||
can_approve: false,
|
||||
completed_items: ["Plan followed", "Evidence reviewed"],
|
||||
blockers: [
|
||||
"Final approval requires latest draft version 3.",
|
||||
"Required publishing settings are missing: content path.",
|
||||
],
|
||||
items: [
|
||||
{
|
||||
key: "exact_draft_version",
|
||||
title: "Exact draft version selected",
|
||||
completed: false,
|
||||
blocker: "Final approval requires latest draft version 3.",
|
||||
},
|
||||
{
|
||||
key: "plan_followed",
|
||||
title: "Plan followed",
|
||||
completed: true,
|
||||
blocker: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(viewModel.canApprove, false);
|
||||
assert.equal(viewModel.draftVersion, 3);
|
||||
assert.equal(viewModel.requestedDraftVersion, 2);
|
||||
assert.equal(viewModel.completedItems.length, 2);
|
||||
assert.equal(viewModel.blockers.length, 2);
|
||||
assert.equal(viewModel.items.length, 2);
|
||||
assert.equal(viewModel.items[0].completed, false);
|
||||
assert.equal(viewModel.items[1].completed, true);
|
||||
@@ -2075,6 +2075,215 @@
|
||||
"title": "EvidenceUpdateRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalApprovalChecklistItemSummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"blocker": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Blocker"
|
||||
},
|
||||
"completed": {
|
||||
"title": "Completed",
|
||||
"type": "boolean"
|
||||
},
|
||||
"key": {
|
||||
"minLength": 1,
|
||||
"title": "Key",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"minLength": 1,
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"title",
|
||||
"completed"
|
||||
],
|
||||
"title": "FinalApprovalChecklistItemSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalApprovalChecklistResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"article_id": {
|
||||
"format": "uuid",
|
||||
"title": "Article Id",
|
||||
"type": "string"
|
||||
},
|
||||
"blockers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Blockers",
|
||||
"type": "array"
|
||||
},
|
||||
"can_approve": {
|
||||
"title": "Can Approve",
|
||||
"type": "boolean"
|
||||
},
|
||||
"completed_items": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Completed Items",
|
||||
"type": "array"
|
||||
},
|
||||
"draft_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Draft Version"
|
||||
},
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FinalApprovalChecklistItemSummary"
|
||||
},
|
||||
"title": "Items",
|
||||
"type": "array"
|
||||
},
|
||||
"requested_draft_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Requested Draft Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"article_id",
|
||||
"can_approve"
|
||||
],
|
||||
"title": "FinalApprovalChecklistResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalApprovalPublishingSettings": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"author": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Author"
|
||||
},
|
||||
"content_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Content Path"
|
||||
},
|
||||
"frontmatter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Frontmatter"
|
||||
},
|
||||
"publishing_mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Publishing Mode"
|
||||
}
|
||||
},
|
||||
"title": "FinalApprovalPublishingSettings",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalApprovalRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"draft_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Draft Version"
|
||||
},
|
||||
"publishing_settings": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FinalApprovalPublishingSettings"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"title": "FinalApprovalRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalApprovalResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"article": {
|
||||
"$ref": "#/components/schemas/ArticleSummary"
|
||||
},
|
||||
"checklist": {
|
||||
"$ref": "#/components/schemas/FinalApprovalChecklistResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"article",
|
||||
"checklist"
|
||||
],
|
||||
"title": "FinalApprovalResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalReviewIssueSummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -2144,6 +2353,55 @@
|
||||
"title": "FinalReviewIssuesResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalRevisionRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"draft_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Draft Version"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Reason"
|
||||
}
|
||||
},
|
||||
"title": "FinalRevisionRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"FinalRevisionRequestResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"article": {
|
||||
"$ref": "#/components/schemas/ArticleSummary"
|
||||
},
|
||||
"checklist": {
|
||||
"$ref": "#/components/schemas/FinalApprovalChecklistResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"article",
|
||||
"checklist"
|
||||
],
|
||||
"title": "FinalRevisionRequestResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
@@ -5552,6 +5810,134 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/articles/{article_id}/final-approval": {
|
||||
"post": {
|
||||
"operationId": "post_article_final_approval_api_articles__article_id__final_approval_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "article_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"title": "Article Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Demo-User-Email",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Demo-User-Email"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FinalApprovalRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FinalApprovalResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Post Article Final Approval",
|
||||
"tags": [
|
||||
"final-approval"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/articles/{article_id}/final-approval/checklist": {
|
||||
"get": {
|
||||
"operationId": "get_article_final_approval_checklist_api_articles__article_id__final_approval_checklist_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "article_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"title": "Article Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Demo-User-Email",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Demo-User-Email"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FinalApprovalChecklistResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Get Article Final Approval Checklist",
|
||||
"tags": [
|
||||
"final-approval"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/articles/{article_id}/final-review/issues": {
|
||||
"get": {
|
||||
"operationId": "get_article_final_review_issues_api_articles__article_id__final_review_issues_get",
|
||||
@@ -5611,6 +5997,75 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/articles/{article_id}/final-revision-request": {
|
||||
"post": {
|
||||
"operationId": "post_article_final_revision_request_api_articles__article_id__final_revision_request_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "article_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"title": "Article Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Demo-User-Email",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Demo-User-Email"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FinalRevisionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FinalRevisionRequestResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Post Article Final Revision Request",
|
||||
"tags": [
|
||||
"final-approval"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/articles/{article_id}/final/approve": {
|
||||
"post": {
|
||||
"operationId": "post_final_approve_api_articles__article_id__final_approve_post",
|
||||
|
||||
@@ -349,6 +349,40 @@ export type EvidenceUpdateRequest = {
|
||||
review_status?: string | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalChecklistItemSummary = {
|
||||
blocker?: string | null;
|
||||
completed: boolean;
|
||||
key: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type FinalApprovalChecklistResponse = {
|
||||
article_id: string;
|
||||
blockers?: string[];
|
||||
can_approve: boolean;
|
||||
completed_items?: string[];
|
||||
draft_version?: number | null;
|
||||
items?: FinalApprovalChecklistItemSummary[];
|
||||
requested_draft_version?: number | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalPublishingSettings = {
|
||||
author?: string | null;
|
||||
content_path?: string | null;
|
||||
frontmatter?: Record<string, unknown> | null;
|
||||
publishing_mode?: string | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalRequest = {
|
||||
draft_version?: number | null;
|
||||
publishing_settings?: FinalApprovalPublishingSettings | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalResponse = {
|
||||
article: ArticleSummary;
|
||||
checklist: FinalApprovalChecklistResponse;
|
||||
};
|
||||
|
||||
export type FinalReviewIssueSummary = {
|
||||
location: string;
|
||||
message: string;
|
||||
@@ -364,6 +398,16 @@ export type FinalReviewIssuesResponse = {
|
||||
unresolved_issues?: FinalReviewIssueSummary[];
|
||||
};
|
||||
|
||||
export type FinalRevisionRequest = {
|
||||
draft_version?: number | null;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type FinalRevisionRequestResponse = {
|
||||
article: ArticleSummary;
|
||||
checklist: FinalApprovalChecklistResponse;
|
||||
};
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: ValidationError[];
|
||||
};
|
||||
|
||||
@@ -34,14 +34,14 @@ Development description: Implement the final review gate that prevents publishin
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TDD pre-requirement: before implementation, write one failing behavior test proving high-risk unsupported claims block final approval; proceed one checklist behavior at a time and record evidence in `Result`.
|
||||
- [ ] Final approval requires an exact draft version.
|
||||
- [ ] High-risk unsupported claims block approval.
|
||||
- [ ] Missing required publishing settings block approval.
|
||||
- [ ] Unapproved required assets block approval.
|
||||
- [ ] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
|
||||
- [ ] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
|
||||
- [ ] UI shows blockers and completed checklist items.
|
||||
- [x] TDD pre-requirement: before implementation, write one failing behavior test proving high-risk unsupported claims block final approval; proceed one checklist behavior at a time and record evidence in `Result`.
|
||||
- [x] Final approval requires an exact draft version.
|
||||
- [x] High-risk unsupported claims block approval.
|
||||
- [x] Missing required publishing settings block approval.
|
||||
- [x] Unapproved required assets block approval.
|
||||
- [x] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
|
||||
- [x] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
|
||||
- [x] UI shows blockers and completed checklist items.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -51,9 +51,32 @@ Development description: Implement the final review gate that prevents publishin
|
||||
|
||||
## Result
|
||||
|
||||
- Status: Pending execution.
|
||||
- TDD plan: To be filled during execution.
|
||||
- Red evidence: To be filled during execution.
|
||||
- Green evidence: To be filled during execution.
|
||||
- Refactor notes: To be filled during execution.
|
||||
- Verification output: To be filled during execution.
|
||||
- Status: Done (TDD RED -> GREEN completed).
|
||||
- TDD progression:
|
||||
1. Pre-requirement RED test created for high-risk unsupported claims blocker.
|
||||
2. Implemented final approval gate service with checklist evaluation on assembled draft + evidence + assets + review state.
|
||||
3. Added public endpoints:
|
||||
- `POST /api/articles/{article_id}/final-approval`
|
||||
- `POST /api/articles/{article_id}/final-revision-request`
|
||||
- `GET /api/articles/{article_id}/final-approval/checklist` (for UI checklist visibility).
|
||||
4. Added workflow events:
|
||||
- `FINAL_APPROVAL_GRANTED` -> `PUBLISH_DRY_RUN_REQUIRED`
|
||||
- `FINAL_REVISION_REQUESTED` -> `FINAL_REVISION_REQUIRED`
|
||||
5. Added frontend final-approval feature (FSD) to show blockers/completed checklist items and invoke approval/revision endpoints.
|
||||
|
||||
- Implemented behavior coverage:
|
||||
- Exact draft version is required and validated against latest immutable draft.
|
||||
- High-risk unsupported claims (claims + section scaffold history) block approval.
|
||||
- Missing required publishing settings (`frontmatter`, `content_path`, `author`, `publishing_mode`) block approval.
|
||||
- Unapproved required assets block approval.
|
||||
- Successful approval writes event payload with actor/timestamp/draft version/publishing settings and moves state to `PUBLISH_DRY_RUN_REQUIRED`.
|
||||
- Revision request writes event payload and moves state to `FINAL_REVISION_REQUIRED`.
|
||||
- UI renders checklist rows, blockers, and completed items.
|
||||
|
||||
- Verification output:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_final_approval_gate_public_api.py` -> OK (7 tests)
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_seo_language_review_public_api.py` -> OK (6 tests)
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> OK (7 tests)
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> OK (5 tests)
|
||||
- `node apps/frontend/tests/final_approval.model.test.mjs` -> OK
|
||||
- `pnpm --dir apps/frontend typecheck` -> OK
|
||||
|
||||
Reference in New Issue
Block a user