diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index b14314d..47e2481 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.115.6 +httpx==0.28.1 uvicorn[standard]==0.34.0 alembic==1.14.0 SQLAlchemy==2.0.36 diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index 61b70f5..27aefb4 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime -from uuid import NAMESPACE_URL, uuid5 +from uuid import NAMESPACE_URL, UUID, uuid5 from src.domain.contracts import ( ArticleCreateRequest, @@ -9,6 +9,10 @@ from src.domain.contracts import ( ArticleSummary, ArticleWorkflowStatus, PublishingStatus, + ReviewActionResponse, + ReviewStatus, + ReviewSummary, + ReviewType, ) @@ -37,3 +41,21 @@ def build_placeholder_article_response( updated_at=PLACEHOLDER_CREATED_AT, ) return ArticleCreateResponse(article=article) + + +def build_placeholder_plan_approval_response( + *, + article_id: UUID, + plan_id: UUID, + reviewer_id: UUID, +) -> ReviewActionResponse: + review = ReviewSummary( + id=uuid5(NAMESPACE_URL, f"plan-approval:{article_id}:{plan_id}:{reviewer_id}"), + article_id=article_id, + review_type=ReviewType.PLAN, + status=ReviewStatus.APPROVED, + reviewer_id=reviewer_id, + notes=None, + created_at=PLACEHOLDER_CREATED_AT, + ) + return ReviewActionResponse(review=review) diff --git a/apps/backend/src/application/auth.py b/apps/backend/src/application/auth.py new file mode 100644 index 0000000..51769ac --- /dev/null +++ b/apps/backend/src/application/auth.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from src.domain.contracts import CurrentUser + + +def get_current_user_by_email(repository: object, email: str) -> CurrentUser: + user = repository.users.get_by_email(email) + return CurrentUser( + id=user.id, + email=email, + display_name=user.display_name, + role=user.role, + ) diff --git a/apps/backend/src/application/site_config.py b/apps/backend/src/application/site_config.py new file mode 100644 index 0000000..e12777c --- /dev/null +++ b/apps/backend/src/application/site_config.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from hashlib import sha256 +from uuid import NAMESPACE_URL, UUID, uuid5 + +from src.domain.contracts import ( + CurrentUser, + ScriptConfigVersionCreateRequest, + ScriptConfigVersionResponse, + ScriptConfigVersionStatus, + ScriptConfigVersionSummary, + TargetSiteConfigCreateRequest, + TargetSiteConfigResponse, + TargetSiteConfigUpdateRequest, +) + + +def create_target_site( + repository: object, + request: TargetSiteConfigCreateRequest, +) -> TargetSiteConfigResponse: + now = _now() + site = repository.target_sites.upsert( + site_id=_stable_uuid(f"target-site:{request.slug}"), + name=request.name, + slug=request.slug, + publishing_type=request.publishing_type, + default_language=request.default_language, + brand_voice=request.brand_voice, + audience=request.audience, + seo_rules=request.seo_rules, + visual_rules=request.visual_rules, + source_rules=request.source_rules, + publishing_rules=request.publishing_rules, + active_script_config_version_id=request.active_script_config_version_id, + created_at=now, + updated_at=now, + ) + return TargetSiteConfigResponse(site=site) + + +def update_target_site( + repository: object, + site_id: UUID, + request: TargetSiteConfigUpdateRequest, +) -> TargetSiteConfigResponse: + existing = repository.target_sites.get_by_id(site_id) + changes = request.model_dump(exclude_unset=True) + publishing_rules = ( + request.publishing_rules + if "publishing_rules" in changes + else existing.publishing_rules + ) + now = _now() + site = repository.target_sites.update( + site_id=existing.id, + name=changes.get("name", existing.name), + slug=changes.get("slug", existing.slug), + publishing_type=changes.get("publishing_type", existing.publishing_type), + default_language=changes.get("default_language", existing.default_language), + brand_voice=changes.get("brand_voice", existing.brand_voice), + audience=changes.get("audience", existing.audience), + seo_rules=changes.get("seo_rules", existing.seo_rules), + visual_rules=changes.get("visual_rules", existing.visual_rules), + source_rules=changes.get("source_rules", existing.source_rules), + publishing_rules=publishing_rules, + active_script_config_version_id=changes.get( + "active_script_config_version_id", + existing.active_script_config_version_id, + ), + updated_at=now, + ) + return TargetSiteConfigResponse(site=site) + + +def create_script_config_version( + repository: object, + *, + site_id: UUID, + current_user: CurrentUser, + request: ScriptConfigVersionCreateRequest, +) -> ScriptConfigVersionResponse: + repository.target_sites.get_by_id(site_id) + existing_versions = repository.script_config_versions.list_for_site(site_id) + version = request.version or _next_version(existing_versions) + now = _now() + version_id = _stable_uuid(f"script-config-version:{site_id}:{version}") + status = ( + ScriptConfigVersionStatus.ACTIVE + if request.activate + else ScriptConfigVersionStatus.DRAFT + ) + row = repository.script_config_versions.upsert( + version_id=version_id, + target_site_id=site_id, + version=version, + status=status, + created_by=current_user.id, + created_at=now, + updated_at=now, + diff=request.diff, + rollback_target_version_id=request.rollback_target_version_id, + activated_at=now if request.activate else None, + publishing_yaml=request.publishing_yaml, + publishing_yaml_hash=_sha256(request.publishing_yaml), + transform_script=request.transform_script, + transform_script_hash=_sha256(request.transform_script), + ) + if request.activate: + row = repository.script_config_versions.activate( + target_site_id=site_id, + version_id=version_id, + activated_at=now, + ) + + return ScriptConfigVersionResponse(version=_script_version_summary(row)) + + +def activate_script_config_version( + repository: object, + *, + site_id: UUID, + version_id: UUID, +) -> ScriptConfigVersionResponse: + repository.target_sites.get_by_id(site_id) + row = repository.script_config_versions.activate( + target_site_id=site_id, + version_id=version_id, + activated_at=_now(), + ) + return ScriptConfigVersionResponse(version=_script_version_summary(row)) + + +def list_script_config_versions( + repository: object, + site_id: UUID, +) -> list[ScriptConfigVersionSummary]: + repository.target_sites.get_by_id(site_id) + rows = repository.script_config_versions.list_for_site(site_id) + return [_script_version_summary(row) for row in rows] + + +def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary: + return ScriptConfigVersionSummary( + id=row["id"], + target_site_id=row["target_site_id"], + version=row["version"], + status=row["status"], + publishing_yaml_hash=row["publishing_yaml_hash"], + transform_script_hash=row["transform_script_hash"], + created_by=row["created_by"], + created_at=row["created_at"], + ) + + +def _next_version(rows: list[dict[str, object]]) -> int: + versions = [int(row["version"]) for row in rows] + if not versions: + return 1 + return max(versions) + 1 + + +def _stable_uuid(value: str) -> UUID: + return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}") + + +def _sha256(value: str) -> str: + return sha256(value.encode("utf-8")).hexdigest() + + +def _now() -> datetime: + return datetime.now(UTC) diff --git a/apps/backend/src/domain/auth.py b/apps/backend/src/domain/auth.py new file mode 100644 index 0000000..65e3091 --- /dev/null +++ b/apps/backend/src/domain/auth.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from src.domain.contracts.enums import Role + + +ADMIN_ROLES = frozenset({Role.ADMIN}) +EDITOR_OR_ADMIN_ROLES = frozenset({Role.ADMIN, Role.EDITOR}) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index c7fce44..f303b61 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -24,6 +24,8 @@ from .models import ( ArticleSummary, AssetSummary, ClaimSummary, + CurrentUser, + CurrentUserResponse, DraftSummary, EvidenceSummary, PlanSectionSummary, @@ -32,10 +34,17 @@ from .models import ( PublishingRules, ResearchArtifactManifestSummary, ResearchArtifactSummary, + ReviewActionResponse, ReviewSummary, RunnerFileRef, + ScriptConfigVersionCreateRequest, + ScriptConfigVersionListResponse, + ScriptConfigVersionResponse, ScriptConfigVersionSummary, TargetSiteConfig, + TargetSiteConfigCreateRequest, + TargetSiteConfigResponse, + TargetSiteConfigUpdateRequest, UserSummary, ) from .workflow import ( @@ -64,6 +73,8 @@ __all__ = [ "ClaimRiskLevel", "ClaimSummary", "ClaimSupportStatus", + "CurrentUser", + "CurrentUserResponse", "DraftSummary", "EvidenceSummary", "PlanReviewStatus", @@ -74,14 +85,21 @@ __all__ = [ "PublishingStatus", "ResearchArtifactManifestSummary", "ResearchArtifactSummary", + "ReviewActionResponse", "ReviewStatus", "ReviewSummary", "ReviewType", "Role", "RunnerFileRef", + "ScriptConfigVersionCreateRequest", + "ScriptConfigVersionListResponse", + "ScriptConfigVersionResponse", "ScriptConfigVersionStatus", "ScriptConfigVersionSummary", "TargetSiteConfig", + "TargetSiteConfigCreateRequest", + "TargetSiteConfigResponse", + "TargetSiteConfigUpdateRequest", "UserSummary", "next_workflow_statuses", ] diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index 6795ff5..35181fb 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -37,6 +37,17 @@ class UserSummary(ContractModel): role: Role +class CurrentUser(ContractModel): + id: UUID + email: str = Field(min_length=1) + display_name: str = Field(min_length=1) + role: Role + + +class CurrentUserResponse(ContractModel): + user: CurrentUser + + class PublishingRules(ContractModel): repository_url: str = Field(min_length=1) production_branch: str = Field(min_length=1) @@ -65,6 +76,38 @@ class TargetSiteConfig(ContractModel): updated_at: datetime +class TargetSiteConfigCreateRequest(ContractModel): + name: str = Field(min_length=1) + slug: str = Field(min_length=1) + publishing_type: str = Field(default="git_next", min_length=1) + default_language: str = Field(default="en", min_length=2) + brand_voice: str = Field(min_length=1) + audience: str = Field(min_length=1) + seo_rules: JsonObject = Field(default_factory=dict) + visual_rules: JsonObject = Field(default_factory=dict) + source_rules: JsonObject = Field(default_factory=dict) + publishing_rules: PublishingRules + active_script_config_version_id: UUID | None = None + + +class TargetSiteConfigUpdateRequest(ContractModel): + name: str | None = Field(default=None, min_length=1) + slug: str | None = Field(default=None, min_length=1) + publishing_type: str | None = Field(default=None, min_length=1) + default_language: str | None = Field(default=None, min_length=2) + brand_voice: str | None = Field(default=None, min_length=1) + audience: str | None = Field(default=None, min_length=1) + seo_rules: JsonObject | None = None + visual_rules: JsonObject | None = None + source_rules: JsonObject | None = None + publishing_rules: PublishingRules | None = None + active_script_config_version_id: UUID | None = None + + +class TargetSiteConfigResponse(ContractModel): + site: TargetSiteConfig + + class ScriptConfigVersionSummary(ContractModel): id: UUID target_site_id: UUID @@ -76,6 +119,23 @@ class ScriptConfigVersionSummary(ContractModel): created_at: datetime +class ScriptConfigVersionCreateRequest(ContractModel): + publishing_yaml: str = Field(min_length=1) + transform_script: str = Field(min_length=1) + diff: JsonObject = Field(default_factory=dict) + rollback_target_version_id: UUID | None = None + activate: bool = False + version: int | None = Field(default=None, ge=1) + + +class ScriptConfigVersionResponse(ContractModel): + version: ScriptConfigVersionSummary + + +class ScriptConfigVersionListResponse(ContractModel): + versions: list[ScriptConfigVersionSummary] + + class ArticleCreateRequest(ContractModel): target_site_id: UUID brief_description: str = Field(min_length=1) @@ -208,6 +268,10 @@ class ReviewSummary(ContractModel): created_at: datetime +class ReviewActionResponse(ContractModel): + review: ReviewSummary + + class PublishCommitSummary(ContractModel): id: UUID article_id: UUID diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index 068cce6..11ebd7a 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -31,6 +31,8 @@ from .models import ( ArticleSummary, AssetSummary, ClaimSummary, + CurrentUser, + CurrentUserResponse, DraftSummary, EvidenceSummary, PlanSectionSummary, @@ -39,10 +41,17 @@ from .models import ( PublishingRules, ResearchArtifactManifestSummary, ResearchArtifactSummary, + ReviewActionResponse, ReviewSummary, RunnerFileRef, + ScriptConfigVersionCreateRequest, + ScriptConfigVersionListResponse, + ScriptConfigVersionResponse, ScriptConfigVersionSummary, TargetSiteConfig, + TargetSiteConfigCreateRequest, + TargetSiteConfigResponse, + TargetSiteConfigUpdateRequest, UserSummary, ) @@ -66,9 +75,17 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = ( CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( UserSummary, + CurrentUser, + CurrentUserResponse, PublishingRules, TargetSiteConfig, + TargetSiteConfigCreateRequest, + TargetSiteConfigUpdateRequest, + TargetSiteConfigResponse, ScriptConfigVersionSummary, + ScriptConfigVersionCreateRequest, + ScriptConfigVersionResponse, + ScriptConfigVersionListResponse, ArticleCreateRequest, ArticleSummary, ArticleCreateResponse, @@ -82,6 +99,7 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( DraftSummary, AssetSummary, ReviewSummary, + ReviewActionResponse, PublishCommitSummary, RunnerFileRef, AgentJobSummary, diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 4146495..59c8cc9 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -270,6 +270,93 @@ class TargetSitesRepository: raise LookupError(f"Target site not found: {slug}") return _target_site_from_row(row) + def get_by_id(self, site_id: UUID) -> TargetSiteConfig: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT + id, + name, + slug, + publishing_type, + default_language, + brand_voice, + audience, + seo_rules, + visual_rules, + source_rules, + publishing_rules, + active_script_config_version_id, + created_at, + updated_at + FROM target_sites + WHERE id = {placeholder} + """, + (str(site_id),), + ).fetchone() + + if row is None: + raise LookupError(f"Target site not found: {site_id}") + return _target_site_from_row(row) + + def update( + self, + *, + site_id: UUID, + name: str, + slug: str, + publishing_type: str, + default_language: str, + brand_voice: str, + audience: str, + seo_rules: JsonObject, + visual_rules: JsonObject, + source_rules: JsonObject, + publishing_rules: PublishingRules, + active_script_config_version_id: UUID | None, + updated_at: datetime, + ) -> TargetSiteConfig: + placeholder = self._repository.placeholder() + json_cast = self._repository.json_cast() + with self._repository.connection() as connection: + connection.execute( + f""" + UPDATE target_sites + SET + name = {placeholder}, + slug = {placeholder}, + publishing_type = {placeholder}, + default_language = {placeholder}, + brand_voice = {placeholder}, + audience = {placeholder}, + seo_rules = {placeholder}{json_cast}, + visual_rules = {placeholder}{json_cast}, + source_rules = {placeholder}{json_cast}, + publishing_rules = {placeholder}{json_cast}, + active_script_config_version_id = {placeholder}, + updated_at = {placeholder} + WHERE id = {placeholder} + """, + ( + name, + slug, + publishing_type, + default_language, + brand_voice, + audience, + _json_value(seo_rules), + _json_value(visual_rules), + _json_value(source_rules), + _json_value(publishing_rules.model_dump(mode="json")), + _uuid_value(active_script_config_version_id), + _datetime_value(updated_at), + str(site_id), + ), + ) + + return self.get_by_id(site_id) + def list(self) -> list[TargetSiteConfig]: with self._repository.connection() as connection: rows = connection.execute( @@ -407,6 +494,88 @@ class ScriptConfigVersionsRepository: ) return _plain_row(row) + def get_by_id(self, version_id: UUID) -> dict[str, Any]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT * + FROM script_config_versions + WHERE id = {placeholder} + """, + (str(version_id),), + ).fetchone() + + if row is None: + raise LookupError(f"Script config version not found: {version_id}") + return _plain_row(row) + + def activate( + self, + *, + target_site_id: UUID, + version_id: UUID, + activated_at: datetime, + ) -> dict[str, Any]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + existing = connection.execute( + f""" + SELECT id + FROM script_config_versions + WHERE id = {placeholder} AND target_site_id = {placeholder} + """, + (str(version_id), str(target_site_id)), + ).fetchone() + if existing is None: + raise LookupError( + f"Script config version not found: {target_site_id} {version_id}" + ) + + connection.execute( + f""" + UPDATE script_config_versions + SET + status = {placeholder}, + updated_at = {placeholder} + WHERE target_site_id = {placeholder} AND id <> {placeholder} + """, + ( + ScriptConfigVersionStatus.DEPRECATED.value, + _datetime_value(activated_at), + str(target_site_id), + str(version_id), + ), + ) + connection.execute( + f""" + UPDATE script_config_versions + SET + status = {placeholder}, + activated_at = {placeholder}, + updated_at = {placeholder} + WHERE id = {placeholder} + """, + ( + ScriptConfigVersionStatus.ACTIVE.value, + _datetime_value(activated_at), + _datetime_value(activated_at), + str(version_id), + ), + ) + connection.execute( + f""" + UPDATE target_sites + SET + active_script_config_version_id = {placeholder}, + updated_at = {placeholder} + WHERE id = {placeholder} + """, + (str(version_id), _datetime_value(activated_at), str(target_site_id)), + ) + + return self.get_by_id(version_id) + def list_for_site(self, target_site_id: UUID) -> list[dict[str, Any]]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: diff --git a/apps/backend/src/presentation/dependencies.py b/apps/backend/src/presentation/dependencies.py new file mode 100644 index 0000000..8b019c8 --- /dev/null +++ b/apps/backend/src/presentation/dependencies.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +import tempfile +from collections.abc import Callable, Iterable +from pathlib import Path + +from fastapi import Depends, Header, HTTPException, status + +from src.application.auth import get_current_user_by_email +from src.application.seed_data import seed_reference_data +from src.domain.contracts import CurrentUser, Role +from src.infrastructure.repositories import BackendRepository, open_backend_repository + + +DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email" +UNAUTHORIZED_DETAIL = "Unauthorized" +FORBIDDEN_DETAIL = "Forbidden" + +_runtime_repository: BackendRepository | None = None +_runtime_repository_dsn: str | None = None +_runtime_temp_dir: tempfile.TemporaryDirectory[str] | None = None + + +def get_repository() -> BackendRepository: + global _runtime_repository + global _runtime_repository_dsn + global _runtime_temp_dir + + dsn = ( + os.environ.get("DATABASE_URL") + or os.environ.get("POSTGRES_DSN") + or os.environ.get("PIPELINE_TEST_DATABASE_DSN") + ) + if dsn is None: + if _runtime_temp_dir is None: + _runtime_temp_dir = tempfile.TemporaryDirectory() + sqlite_path = Path(_runtime_temp_dir.name) / "pipeline-demo-auth.db" + dsn = f"sqlite:///{sqlite_path}" + + if _runtime_repository is not None and _runtime_repository_dsn == dsn: + return _runtime_repository + + repository = open_backend_repository(dsn) + repository.setup() + seed_reference_data(repository) + _runtime_repository = repository + _runtime_repository_dsn = dsn + return repository + + +def get_current_user( + demo_user_email: str | None = Header(default=None, alias=DEMO_USER_EMAIL_HEADER), + repository: BackendRepository = Depends(get_repository), +) -> CurrentUser: + if not demo_user_email: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=UNAUTHORIZED_DETAIL, + ) + + try: + return get_current_user_by_email(repository, demo_user_email) + except LookupError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=UNAUTHORIZED_DETAIL, + ) from error + + +def require_roles( + allowed_roles: Iterable[Role], +) -> Callable[[CurrentUser], CurrentUser]: + allowed = frozenset(allowed_roles) + + def dependency( + current_user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if current_user.role not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=FORBIDDEN_DETAIL, + ) + return current_user + + return dependency diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index 00c6963..bf0ccc6 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -6,10 +6,14 @@ from fastapi.responses import JSONResponse from src.domain.contracts.openapi import inject_contract_schemas from src.presentation.routes.articles import router as articles_router +from src.presentation.routes.auth import router as auth_router +from src.presentation.routes.sites import router as sites_router app = FastAPI(title="AI Content Pipeline Backend") +app.include_router(auth_router) app.include_router(articles_router) +app.include_router(sites_router) @app.get("/health") diff --git a/apps/backend/src/presentation/routes/articles.py b/apps/backend/src/presentation/routes/articles.py index 38fac05..77ec0c2 100644 --- a/apps/backend/src/presentation/routes/articles.py +++ b/apps/backend/src/presentation/routes/articles.py @@ -1,9 +1,21 @@ from __future__ import annotations -from fastapi import APIRouter, status +from uuid import UUID -from src.application.articles import build_placeholder_article_response -from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse +from fastapi import APIRouter, Depends, status + +from src.application.articles import ( + build_placeholder_article_response, + build_placeholder_plan_approval_response, +) +from src.domain.auth import EDITOR_OR_ADMIN_ROLES +from src.domain.contracts import ( + ArticleCreateRequest, + ArticleCreateResponse, + CurrentUser, + ReviewActionResponse, +) +from src.presentation.dependencies import require_roles router = APIRouter(prefix="/api", tags=["articles"]) @@ -14,5 +26,24 @@ router = APIRouter(prefix="/api", tags=["articles"]) response_model=ArticleCreateResponse, status_code=status.HTTP_201_CREATED, ) -def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse: +def create_article( + request: ArticleCreateRequest, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), +) -> ArticleCreateResponse: return build_placeholder_article_response(request) + + +@router.post( + "/articles/{article_id}/plans/{plan_id}/approve", + response_model=ReviewActionResponse, +) +def approve_plan( + article_id: UUID, + plan_id: UUID, + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), +) -> ReviewActionResponse: + return build_placeholder_plan_approval_response( + article_id=article_id, + plan_id=plan_id, + reviewer_id=current_user.id, + ) diff --git a/apps/backend/src/presentation/routes/auth.py b/apps/backend/src/presentation/routes/auth.py new file mode 100644 index 0000000..0a3547c --- /dev/null +++ b/apps/backend/src/presentation/routes/auth.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from src.domain.contracts import CurrentUser, CurrentUserResponse +from src.presentation.dependencies import get_current_user + + +router = APIRouter(prefix="/api", tags=["auth"]) + + +@router.get("/me", response_model=CurrentUserResponse) +def get_me( + current_user: CurrentUser = Depends(get_current_user), +) -> CurrentUserResponse: + return CurrentUserResponse(user=current_user) diff --git a/apps/backend/src/presentation/routes/sites.py b/apps/backend/src/presentation/routes/sites.py new file mode 100644 index 0000000..d6fa160 --- /dev/null +++ b/apps/backend/src/presentation/routes/sites.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from src.application.site_config import ( + activate_script_config_version, + create_script_config_version, + create_target_site, + list_script_config_versions, + update_target_site, +) +from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES +from src.domain.contracts import ( + CurrentUser, + ScriptConfigVersionCreateRequest, + ScriptConfigVersionListResponse, + ScriptConfigVersionResponse, + TargetSiteConfigCreateRequest, + TargetSiteConfigResponse, + TargetSiteConfigUpdateRequest, +) +from src.infrastructure.repositories import BackendRepository +from src.presentation.dependencies import get_repository, require_roles + + +router = APIRouter(prefix="/api", tags=["sites"]) + + +@router.get("/sites", response_model=list[TargetSiteConfigResponse]) +def list_sites( + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> list[TargetSiteConfigResponse]: + return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()] + + +@router.post( + "/sites", + response_model=TargetSiteConfigResponse, + status_code=status.HTTP_201_CREATED, +) +def post_site( + request: TargetSiteConfigCreateRequest, + _: CurrentUser = Depends(require_roles(ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> TargetSiteConfigResponse: + return create_target_site(repository, request) + + +@router.patch("/sites/{site_id}", response_model=TargetSiteConfigResponse) +def patch_site( + site_id: UUID, + request: TargetSiteConfigUpdateRequest, + _: CurrentUser = Depends(require_roles(ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> TargetSiteConfigResponse: + try: + return update_target_site(repository, site_id, request) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + +@router.get( + "/sites/{site_id}/publishing-config/versions", + response_model=ScriptConfigVersionListResponse, +) +def get_script_config_versions( + site_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> ScriptConfigVersionListResponse: + try: + versions = list_script_config_versions(repository, site_id) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + return ScriptConfigVersionListResponse(versions=versions) + + +@router.post( + "/sites/{site_id}/publishing-config/versions", + response_model=ScriptConfigVersionResponse, + status_code=status.HTTP_201_CREATED, +) +def post_script_config_version( + site_id: UUID, + request: ScriptConfigVersionCreateRequest, + current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> ScriptConfigVersionResponse: + try: + return create_script_config_version( + repository, + site_id=site_id, + current_user=current_user, + request=request, + ) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + +@router.post( + "/sites/{site_id}/publishing-config/versions/{version_id}/activate", + response_model=ScriptConfigVersionResponse, +) +def post_activate_script_config_version( + site_id: UUID, + version_id: UUID, + _: CurrentUser = Depends(require_roles(ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> ScriptConfigVersionResponse: + try: + return activate_script_config_version( + repository, + site_id=site_id, + version_id=version_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_auth_authorization_public_api.py b/apps/backend/tests/integration/test_auth_authorization_public_api.py new file mode 100644 index 0000000..7418fa2 --- /dev/null +++ b/apps/backend/tests/integration/test_auth_authorization_public_api.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(BACKEND_ROOT)) + +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 AuthAuthorizationPublicApiTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(app) + + def test_me_returns_selected_demo_user_and_role(self) -> None: + response = self.client.get( + "/api/me", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + ) + + self.assertEqual(200, response.status_code, response.text) + self.assertEqual(DEMO_ADMIN_EMAIL, response.json()["user"]["email"]) + self.assertEqual("ADMIN", response.json()["user"]["role"]) + + def test_missing_and_unknown_demo_users_are_rejected_consistently(self) -> None: + missing_response = self.client.get("/api/me") + unknown_response = self.client.get( + "/api/me", + headers={DEMO_USER_EMAIL_HEADER: "missing@example.com"}, + ) + + self.assertEqual(401, missing_response.status_code, missing_response.text) + self.assertEqual(401, unknown_response.status_code, unknown_response.text) + self.assertEqual({"detail": "Unauthorized"}, missing_response.json()) + self.assertEqual({"detail": "Unauthorized"}, unknown_response.json()) + + def test_editor_cannot_create_target_site_config(self) -> None: + response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=self._site_payload("editorial_ops_blog"), + ) + + self.assertEqual(403, response.status_code, response.text) + self.assertEqual({"detail": "Forbidden"}, response.json()) + + def test_admin_can_create_and_update_target_site_config(self) -> None: + create_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("admin_ops_blog"), + ) + + self.assertEqual(201, create_response.status_code, create_response.text) + site = create_response.json()["site"] + self.assertEqual("admin_ops_blog", site["slug"]) + + update_response = self.client.patch( + f"/api/sites/{site['id']}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json={"brand_voice": "Updated practical editorial operations guidance."}, + ) + + self.assertEqual(200, update_response.status_code, update_response.text) + self.assertEqual( + "Updated practical editorial operations guidance.", + update_response.json()["site"]["brand_voice"], + ) + + def test_editor_cannot_update_site_or_mutate_script_versions(self) -> None: + create_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("script_editor_denied_blog"), + ) + self.assertEqual(201, create_response.status_code, create_response.text) + site_id = create_response.json()["site"]["id"] + + version_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._script_version_payload(), + ) + self.assertEqual(201, version_response.status_code, version_response.text) + version_id = version_response.json()["version"]["id"] + + site_update_response = self.client.patch( + f"/api/sites/{site_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={"brand_voice": "Editor should not mutate Admin config."}, + ) + version_create_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json=self._script_version_payload(), + ) + version_activate_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + + self.assertEqual(403, site_update_response.status_code, site_update_response.text) + self.assertEqual( + 403, + version_create_response.status_code, + version_create_response.text, + ) + self.assertEqual( + 403, + version_activate_response.status_code, + version_activate_response.text, + ) + + def test_admin_can_create_and_activate_script_config_version(self) -> None: + site_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("script_admin_blog"), + ) + self.assertEqual(201, site_response.status_code, site_response.text) + site_id = site_response.json()["site"]["id"] + + create_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._script_version_payload(), + ) + self.assertEqual(201, create_response.status_code, create_response.text) + self.assertEqual("DRAFT", create_response.json()["version"]["status"]) + + version_id = create_response.json()["version"]["id"] + activate_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + ) + + self.assertEqual(200, activate_response.status_code, activate_response.text) + self.assertEqual("ACTIVE", activate_response.json()["version"]["status"]) + + def test_editor_can_create_articles_and_approve_plan_review(self) -> None: + sites_response = self.client.get( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, sites_response.status_code, sites_response.text) + target_site_id = sites_response.json()[0]["site"]["id"] + + article_response = self.client.post( + "/api/articles", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "target_site_id": target_site_id, + "brief_description": "Write about editorial AI workflow controls.", + "working_title": "Editorial AI Workflow Controls", + }, + ) + + self.assertEqual(201, article_response.status_code, article_response.text) + self.assertEqual( + "ARTICLE_BRIEF_CREATED", + article_response.json()["article"]["status"], + ) + + article_id = article_response.json()["article"]["id"] + review_response = self.client.post( + f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + + self.assertEqual(200, review_response.status_code, review_response.text) + self.assertEqual("PLAN", review_response.json()["review"]["review_type"]) + self.assertEqual("APPROVED", review_response.json()["review"]["status"]) + + def test_unauthorized_requests_are_rejected_for_protected_mutations(self) -> None: + response = self.client.post( + "/api/articles", + json={ + "target_site_id": "00000000-0000-0000-0000-000000000001", + "brief_description": "No user selected.", + }, + ) + + self.assertEqual(401, response.status_code, response.text) + self.assertEqual({"detail": "Unauthorized"}, response.json()) + + def _site_payload(self, slug: str) -> dict[str, object]: + return { + "name": "Editorial Ops Blog", + "slug": slug, + "publishing_type": "git_next", + "default_language": "en", + "brand_voice": "Practical editorial operations guidance.", + "audience": "Editorial teams running AI-assisted workflows.", + "seo_rules": {"primary_keyword_required": True}, + "visual_rules": {"hero_style": "product editorial"}, + "source_rules": {"minimum_sources": 3}, + "publishing_rules": { + "repository_url": "git@github.com:example/editorial-ops.git", + "production_branch": "main", + "content_format": "mdx", + "content_path_template": "content/articles/{slug}.mdx", + "asset_path_template": "public/articles/{slug}/{filename}", + "frontmatter_mapping": {"title": "title"}, + "dry_run_renderer": "next-mdx", + }, + } + + def _script_version_payload(self) -> dict[str, object]: + return { + "publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n", + "transform_script": "export function transformArticle(article) { return article; }\n", + "diff": {"summary": "Task 004 authorization fixture."}, + } + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/package.json b/apps/frontend/package.json index defc29d..8c3e29b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -5,6 +5,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "test:roles": "node tests/role-navigation.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 348c869..2f422fc 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -19,3 +19,70 @@ main { min-height: 100vh; padding: 48px; } + +.pageHeader { + display: flex; + max-width: 920px; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; +} + +.pageHeader h1 { + margin: 0 0 8px; + font-size: 32px; + line-height: 1.2; + letter-spacing: 0; +} + +.pageHeader p { + margin: 0; + color: #667085; +} + +.roleBadge { + display: inline-flex; + min-height: 32px; + align-items: center; + border: 1px solid #b7c0cc; + border-radius: 6px; + padding: 0 12px; + background: #ffffff; + color: #344054; + font-size: 14px; +} + +.roleNavigation { + display: grid; + max-width: 920px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + padding: 0; + margin: 0; + list-style: none; +} + +.roleNavigation a { + display: flex; + min-height: 52px; + align-items: center; + border: 1px solid #d0d5dd; + border-radius: 8px; + padding: 0 16px; + background: #ffffff; + color: #1d2733; + font-weight: 600; + text-decoration: none; +} + +@media (max-width: 640px) { + main { + padding: 28px; + } + + .pageHeader { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index cd0d019..ed5af1d 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,5 +1,7 @@ import type { ArticleListResponse } from "@pipeline/shared"; +import { RoleNavigation } from "@/widgets/role-navigation"; + const initialArticles: ArticleListResponse = { articles: [], }; @@ -7,11 +9,14 @@ const initialArticles: ArticleListResponse = { export default function HomePage() { return (
-

AI Content Pipeline

-

Dockerized monorepo foundation is running.

-

- {initialArticles.articles.length} articles in the contract-backed dashboard. -

+
+
+

AI Content Pipeline

+

{initialArticles.articles.length} active article workflows

+
+ Editor demo +
+
); } diff --git a/apps/frontend/src/widgets/role-navigation/index.ts b/apps/frontend/src/widgets/role-navigation/index.ts new file mode 100644 index 0000000..21148c4 --- /dev/null +++ b/apps/frontend/src/widgets/role-navigation/index.ts @@ -0,0 +1,3 @@ +export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model"; +export type { NavigationItem } from "./model"; +export { RoleNavigation } from "./ui"; diff --git a/apps/frontend/src/widgets/role-navigation/model.ts b/apps/frontend/src/widgets/role-navigation/model.ts new file mode 100644 index 0000000..83f4fb6 --- /dev/null +++ b/apps/frontend/src/widgets/role-navigation/model.ts @@ -0,0 +1,47 @@ +import type { Role } from "@pipeline/shared"; + +export type NavigationItem = { + id: string; + label: string; + href: string; + roles: readonly Role[]; +}; + +export const ROLE_NAVIGATION_ITEMS = [ + { + id: "dashboard", + label: "Dashboard", + href: "/", + roles: ["ADMIN", "EDITOR"], + }, + { + id: "new-article", + label: "New article", + href: "/articles/new", + roles: ["ADMIN", "EDITOR"], + }, + { + id: "review", + label: "Review", + href: "/review", + roles: ["ADMIN", "EDITOR"], + }, + { + id: "site-config", + label: "Site configuration", + href: "/admin/sites", + roles: ["ADMIN"], + }, + { + id: "script-versions", + label: "Script versions", + href: "/admin/scripts", + roles: ["ADMIN"], + }, +] as const satisfies readonly NavigationItem[]; + +export function getRoleNavigationItems(role: Role): NavigationItem[] { + return ROLE_NAVIGATION_ITEMS.filter((item) => + item.roles.some((allowedRole) => allowedRole === role), + ); +} diff --git a/apps/frontend/src/widgets/role-navigation/ui.tsx b/apps/frontend/src/widgets/role-navigation/ui.tsx new file mode 100644 index 0000000..879443b --- /dev/null +++ b/apps/frontend/src/widgets/role-navigation/ui.tsx @@ -0,0 +1,23 @@ +import type { Role } from "@pipeline/shared"; + +import { getRoleNavigationItems } from "./model"; + +type RoleNavigationProps = { + role: Role; +}; + +export function RoleNavigation({ role }: RoleNavigationProps) { + const items = getRoleNavigationItems(role); + + return ( + + ); +} diff --git a/apps/frontend/tests/role-navigation.test.mjs b/apps/frontend/tests/role-navigation.test.mjs new file mode 100644 index 0000000..5521882 --- /dev/null +++ b/apps/frontend/tests/role-navigation.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const sourcePath = resolve(testDir, "../src/widgets/role-navigation/model.ts"); +const source = readFileSync(sourcePath, "utf8"); +const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, +}); + +const exports = {}; +new Function("exports", transpiled.outputText)(exports); + +const editorItems = exports.getRoleNavigationItems("EDITOR"); +const adminItems = exports.getRoleNavigationItems("ADMIN"); + +assert.deepEqual( + editorItems.map((item) => item.id), + ["dashboard", "new-article", "review"], +); +assert.equal( + editorItems.some((item) => item.id === "site-config" || item.id === "script-versions"), + false, +); +assert.equal( + adminItems.some((item) => item.id === "site-config"), + true, +); +assert.equal( + adminItems.some((item) => item.id === "script-versions"), + true, +); + +console.log("role navigation hides Admin-only items for Editors"); diff --git a/package.json b/package.json index bbef9f7..4fcd048 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev:stack": "docker compose up --build", "generate:contracts": "python3 scripts/generate_openapi_contracts.py", "smoke": "bash tests/smoke/public-health.sh", + "test:frontend:roles": "pnpm --filter @pipeline/frontend test:roles", "typecheck": "pnpm --filter @pipeline/shared typecheck && pnpm --filter @pipeline/frontend typecheck" }, "workspaces": [ diff --git a/packages/shared/openapi.json b/packages/shared/openapi.json index 7a7cbe0..fc68642 100644 --- a/packages/shared/openapi.json +++ b/packages/shared/openapi.json @@ -703,6 +703,50 @@ "title": "ClaimSupportStatus", "type": "string" }, + "CurrentUser": { + "additionalProperties": false, + "properties": { + "display_name": { + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "email": { + "minLength": 1, + "title": "Email", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "id", + "email", + "display_name", + "role" + ], + "title": "CurrentUser", + "type": "object" + }, + "CurrentUserResponse": { + "additionalProperties": false, + "properties": { + "user": { + "$ref": "#/components/schemas/CurrentUser" + } + }, + "required": [ + "user" + ], + "title": "CurrentUserResponse", + "type": "object" + }, "DraftSummary": { "additionalProperties": false, "properties": { @@ -1285,6 +1329,19 @@ "title": "ResearchArtifactSummary", "type": "object" }, + "ReviewActionResponse": { + "additionalProperties": false, + "properties": { + "review": { + "$ref": "#/components/schemas/ReviewSummary" + } + }, + "required": [ + "review" + ], + "title": "ReviewActionResponse", + "type": "object" + }, "ReviewStatus": { "enum": [ "PENDING", @@ -1400,6 +1457,93 @@ "title": "RunnerFileRef", "type": "object" }, + "ScriptConfigVersionCreateRequest": { + "additionalProperties": false, + "properties": { + "activate": { + "default": false, + "title": "Activate", + "type": "boolean" + }, + "diff": { + "additionalProperties": true, + "title": "Diff", + "type": "object" + }, + "publishing_yaml": { + "minLength": 1, + "title": "Publishing Yaml", + "type": "string" + }, + "rollback_target_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rollback Target Version Id" + }, + "transform_script": { + "minLength": 1, + "title": "Transform Script", + "type": "string" + }, + "version": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + } + }, + "required": [ + "publishing_yaml", + "transform_script" + ], + "title": "ScriptConfigVersionCreateRequest", + "type": "object" + }, + "ScriptConfigVersionListResponse": { + "additionalProperties": false, + "properties": { + "versions": { + "items": { + "$ref": "#/components/schemas/ScriptConfigVersionSummary" + }, + "title": "Versions", + "type": "array" + } + }, + "required": [ + "versions" + ], + "title": "ScriptConfigVersionListResponse", + "type": "object" + }, + "ScriptConfigVersionResponse": { + "additionalProperties": false, + "properties": { + "version": { + "$ref": "#/components/schemas/ScriptConfigVersionSummary" + } + }, + "required": [ + "version" + ], + "title": "ScriptConfigVersionResponse", + "type": "object" + }, "ScriptConfigVersionStatus": { "enum": [ "DRAFT", @@ -1559,6 +1703,244 @@ "title": "TargetSiteConfig", "type": "object" }, + "TargetSiteConfigCreateRequest": { + "additionalProperties": false, + "properties": { + "active_script_config_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Script Config Version Id" + }, + "audience": { + "minLength": 1, + "title": "Audience", + "type": "string" + }, + "brand_voice": { + "minLength": 1, + "title": "Brand Voice", + "type": "string" + }, + "default_language": { + "default": "en", + "minLength": 2, + "title": "Default Language", + "type": "string" + }, + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "publishing_rules": { + "$ref": "#/components/schemas/PublishingRules" + }, + "publishing_type": { + "default": "git_next", + "minLength": 1, + "title": "Publishing Type", + "type": "string" + }, + "seo_rules": { + "additionalProperties": true, + "title": "Seo Rules", + "type": "object" + }, + "slug": { + "minLength": 1, + "title": "Slug", + "type": "string" + }, + "source_rules": { + "additionalProperties": true, + "title": "Source Rules", + "type": "object" + }, + "visual_rules": { + "additionalProperties": true, + "title": "Visual Rules", + "type": "object" + } + }, + "required": [ + "name", + "slug", + "brand_voice", + "audience", + "publishing_rules" + ], + "title": "TargetSiteConfigCreateRequest", + "type": "object" + }, + "TargetSiteConfigResponse": { + "additionalProperties": false, + "properties": { + "site": { + "$ref": "#/components/schemas/TargetSiteConfig" + } + }, + "required": [ + "site" + ], + "title": "TargetSiteConfigResponse", + "type": "object" + }, + "TargetSiteConfigUpdateRequest": { + "additionalProperties": false, + "properties": { + "active_script_config_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Script Config Version Id" + }, + "audience": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audience" + }, + "brand_voice": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Brand Voice" + }, + "default_language": { + "anyOf": [ + { + "minLength": 2, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default Language" + }, + "name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "publishing_rules": { + "anyOf": [ + { + "$ref": "#/components/schemas/PublishingRules" + }, + { + "type": "null" + } + ], + "default": null + }, + "publishing_type": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publishing Type" + }, + "seo_rules": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Seo Rules" + }, + "slug": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "source_rules": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Rules" + }, + "visual_rules": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Visual Rules" + } + }, + "title": "TargetSiteConfigUpdateRequest", + "type": "object" + }, "UserSummary": { "additionalProperties": false, "properties": { @@ -1628,6 +2010,24 @@ "/api/articles": { "post": { "operationId": "create_article_api_articles_post", + "parameters": [ + { + "in": "header", + "name": "X-Demo-User-Email", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Demo-User-Email" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1666,6 +2066,498 @@ ] } }, + "/api/articles/{article_id}/plans/{plan_id}/approve": { + "post": { + "operationId": "approve_plan_api_articles__article_id__plans__plan_id__approve_post", + "parameters": [ + { + "in": "path", + "name": "article_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Article Id", + "type": "string" + } + }, + { + "in": "path", + "name": "plan_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Plan 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/ReviewActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Approve Plan", + "tags": [ + "articles" + ] + } + }, + "/api/me": { + "get": { + "operationId": "get_me_api_me_get", + "parameters": [ + { + "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/CurrentUserResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Me", + "tags": [ + "auth" + ] + } + }, + "/api/sites": { + "get": { + "operationId": "list_sites_api_sites_get", + "parameters": [ + { + "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": { + "items": { + "$ref": "#/components/schemas/TargetSiteConfigResponse" + }, + "title": "Response List Sites Api Sites Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Sites", + "tags": [ + "sites" + ] + }, + "post": { + "operationId": "post_site_api_sites_post", + "parameters": [ + { + "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/TargetSiteConfigCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetSiteConfigResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Site", + "tags": [ + "sites" + ] + } + }, + "/api/sites/{site_id}": { + "patch": { + "operationId": "patch_site_api_sites__site_id__patch", + "parameters": [ + { + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Site 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/TargetSiteConfigUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetSiteConfigResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch Site", + "tags": [ + "sites" + ] + } + }, + "/api/sites/{site_id}/publishing-config/versions": { + "get": { + "operationId": "get_script_config_versions_api_sites__site_id__publishing_config_versions_get", + "parameters": [ + { + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Site 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/ScriptConfigVersionListResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Script Config Versions", + "tags": [ + "sites" + ] + }, + "post": { + "operationId": "post_script_config_version_api_sites__site_id__publishing_config_versions_post", + "parameters": [ + { + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Site 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/ScriptConfigVersionCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptConfigVersionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Script Config Version", + "tags": [ + "sites" + ] + } + }, + "/api/sites/{site_id}/publishing-config/versions/{version_id}/activate": { + "post": { + "operationId": "post_activate_script_config_version_api_sites__site_id__publishing_config_versions__version_id__activate_post", + "parameters": [ + { + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Site Id", + "type": "string" + } + }, + { + "in": "path", + "name": "version_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Version 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/ScriptConfigVersionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Post Activate Script Config Version", + "tags": [ + "sites" + ] + } + }, "/health": { "get": { "operationId": "health_health_get", diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts index b336379..a929906 100644 --- a/packages/shared/src/api-types.ts +++ b/packages/shared/src/api-types.ts @@ -110,6 +110,17 @@ export type ClaimSummary = { export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW"; +export type CurrentUser = { + display_name: string; + email: string; + id: string; + role: Role; +}; + +export type CurrentUserResponse = { + user: CurrentUser; +}; + export type DraftSummary = { article_id: string; body_object_key?: string | null; @@ -209,6 +220,10 @@ export type ResearchArtifactSummary = { source_url: string; }; +export type ReviewActionResponse = { + review: ReviewSummary; +}; + export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED"; export type ReviewSummary = { @@ -230,6 +245,23 @@ export type RunnerFileRef = { path: string; }; +export type ScriptConfigVersionCreateRequest = { + activate?: boolean; + diff?: Record; + publishing_yaml: string; + rollback_target_version_id?: string | null; + transform_script: string; + version?: number | null; +}; + +export type ScriptConfigVersionListResponse = { + versions: ScriptConfigVersionSummary[]; +}; + +export type ScriptConfigVersionResponse = { + version: ScriptConfigVersionSummary; +}; + export type ScriptConfigVersionStatus = "DRAFT" | "ACTIVE" | "DEPRECATED"; export type ScriptConfigVersionSummary = { @@ -260,6 +292,38 @@ export type TargetSiteConfig = { visual_rules?: Record; }; +export type TargetSiteConfigCreateRequest = { + active_script_config_version_id?: string | null; + audience: string; + brand_voice: string; + default_language?: string; + name: string; + publishing_rules: PublishingRules; + publishing_type?: string; + seo_rules?: Record; + slug: string; + source_rules?: Record; + visual_rules?: Record; +}; + +export type TargetSiteConfigResponse = { + site: TargetSiteConfig; +}; + +export type TargetSiteConfigUpdateRequest = { + active_script_config_version_id?: string | null; + audience?: string | null; + brand_voice?: string | null; + default_language?: string | null; + name?: string | null; + publishing_rules?: PublishingRules | null; + publishing_type?: string | null; + seo_rules?: Record | null; + slug?: string | null; + source_rules?: Record | null; + visual_rules?: Record | null; +}; + export type UserSummary = { display_name: string; id: string; diff --git a/tasks/004-auth-and-admin-editor-roles.md b/tasks/004-auth-and-admin-editor-roles.md index 6c2f913..a4f03eb 100644 --- a/tasks/004-auth-and-admin-editor-roles.md +++ b/tasks/004-auth-and-admin-editor-roles.md @@ -24,13 +24,13 @@ Development description: Implement simple internal authentication and role-based ## Acceptance Criteria -- [ ] TDD pre-requirement: before implementation, write one failing public API authorization test for an Editor attempting an Admin-only action; proceed one permission behavior at a time and record red-green evidence in `Result`. -- [ ] `GET /api/me` returns the active user and role. -- [ ] Admin can create/update site config and script versions. -- [ ] Editor cannot create/update site config or script versions. -- [ ] Editor can create articles and perform review actions. -- [ ] Unauthorized requests are rejected consistently. -- [ ] Frontend hides Admin-only navigation for Editors. +- [x] TDD pre-requirement: before implementation, write one failing public API authorization test for an Editor attempting an Admin-only action; proceed one permission behavior at a time and record red-green evidence in `Result`. +- [x] `GET /api/me` returns the active user and role. +- [x] Admin can create/update site config and script versions. +- [x] Editor cannot create/update site config or script versions. +- [x] Editor can create articles and perform review actions. +- [x] Unauthorized requests are rejected consistently. +- [x] Frontend hides Admin-only navigation for Editors. ## Verification @@ -40,9 +40,81 @@ Development description: Implement simple internal authentication and role-based ## 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: Completed. +- TDD plan: + - First behavior slice: public FastAPI HTTP authorization denial for a demo + Editor attempting an Admin-only site configuration mutation. + - Public API contract selected for local demo auth: + `X-Demo-User-Email: editor@example.com`. + - Red test: `POST /api/sites` with an Editor-selected demo user must return + stable `403`. + - Green slices: `GET /api/me`, Admin allow/Editor deny for site config and + script version mutations, Editor article create and plan approval review + action, stable `401`/`403` responses, and role-aware frontend navigation. +- Red evidence: + - Command: + `docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python apps/backend/tests/integration/test_auth_authorization_public_api.py` + - Result: failed as expected because `POST /api/sites` and auth/role checks + are not implemented yet. + - Output: + ```text + F + ====================================================================== + FAIL: test_editor_cannot_create_target_site_config (__main__.AuthAuthorizationPublicApiTest.test_editor_cannot_create_target_site_config) + ---------------------------------------------------------------------- + Traceback (most recent call last): + File "/app/apps/backend/tests/integration/test_auth_authorization_public_api.py", line 49, in test_editor_cannot_create_target_site_config + self.assertEqual(403, response.status_code, response.text) + AssertionError: 403 != 404 : {"detail":"Not Found"} + + ---------------------------------------------------------------------- + Ran 1 test in 0.004s + + FAILED (failures=1) + ``` +- Green evidence: + - Command: + `docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python apps/backend/tests/integration/test_auth_authorization_public_api.py` + - Output: + ```text + ........ + ---------------------------------------------------------------------- + Ran 8 tests in 0.249s + + OK + ``` +- Refactor notes: + - Added local demo auth via `X-Demo-User-Email` using seeded users. + - Kept role constants in domain, current-user/site-config orchestration in + application, repository persistence in infrastructure, and FastAPI + dependencies/guards in presentation. + - Added minimal plan approval review action endpoint for Task 004 role checks. + - Added FSD role navigation model/component and a deterministic Node test that + executes the TypeScript source. + - Regenerated `packages/shared/openapi.json` and `packages/shared/src/api-types.ts`. +- Verification output: + - `docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python -m unittest discover apps/backend/tests` + ```text + ..............s.... + ---------------------------------------------------------------------- + Ran 19 tests in 0.364s + + OK (skipped=1) + ``` + - `pnpm --filter @pipeline/frontend test:roles` + ```text + role navigation hides Admin-only items for Editors + ``` + - `pnpm typecheck` + ```text + @pipeline/shared typecheck: tsc --noEmit + @pipeline/frontend typecheck: tsc --noEmit + ``` + - `bash tests/smoke/public-health.sh` + ```text + backend health ok + runner health ok + frontend health ok + backend dependencies ok + public health smoke ok + ```