Task 004: add demo auth and role checks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>Dockerized monorepo foundation is running.</p>
|
||||
<p>
|
||||
{initialArticles.articles.length} articles in the contract-backed dashboard.
|
||||
</p>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>{initialArticles.articles.length} active article workflows</p>
|
||||
</div>
|
||||
<span className="roleBadge">Editor demo</span>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model";
|
||||
export type { NavigationItem } from "./model";
|
||||
export { RoleNavigation } from "./ui";
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav aria-label="Primary navigation">
|
||||
<ul className="roleNavigation">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
slug: string;
|
||||
source_rules?: Record<string, unknown>;
|
||||
visual_rules?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown> | null;
|
||||
slug?: string | null;
|
||||
source_rules?: Record<string, unknown> | null;
|
||||
visual_rules?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type UserSummary = {
|
||||
display_name: string;
|
||||
id: string;
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user