diff --git a/apps/backend/src/application/site_config.py b/apps/backend/src/application/site_config.py index e12777c..bc6bdb0 100644 --- a/apps/backend/src/application/site_config.py +++ b/apps/backend/src/application/site_config.py @@ -16,6 +16,11 @@ from src.domain.contracts import ( ) +_VERSION_CREATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_CREATED" +_VERSION_ACTIVATED_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ACTIVATED" +_VERSION_ROLLBACK_EVENT_TYPE = "SCRIPT_CONFIG_VERSION_ROLLBACK" + + def create_target_site( repository: object, request: TargetSiteConfigCreateRequest, @@ -40,6 +45,14 @@ def create_target_site( return TargetSiteConfigResponse(site=site) +def get_target_site( + repository: object, + site_id: UUID, +) -> TargetSiteConfigResponse: + site = repository.target_sites.get_by_id(site_id) + return TargetSiteConfigResponse(site=site) + + def update_target_site( repository: object, site_id: UUID, @@ -82,6 +95,16 @@ def create_script_config_version( request: ScriptConfigVersionCreateRequest, ) -> ScriptConfigVersionResponse: repository.target_sites.get_by_id(site_id) + + if request.rollback_target_version_id is not None: + rollback_target = repository.script_config_versions.get_by_id( + request.rollback_target_version_id + ) + if str(rollback_target["target_site_id"]) != str(site_id): + raise LookupError( + f"Rollback target version is not for site: {request.rollback_target_version_id}" + ) + existing_versions = repository.script_config_versions.list_for_site(site_id) version = request.version or _next_version(existing_versions) now = _now() @@ -112,6 +135,24 @@ def create_script_config_version( target_site_id=site_id, version_id=version_id, activated_at=now, + rollback_target_version_id=request.rollback_target_version_id, + ) + repository.script_config_version_events.create( + target_site_id=site_id, + version_id=version_id, + event_type=_VERSION_ACTIVATED_EVENT_TYPE, + actor_user_id=current_user.id, + payload={"action": "activate", "version": version}, + created_at=now, + ) + else: + repository.script_config_version_events.create( + target_site_id=site_id, + version_id=version_id, + event_type=_VERSION_CREATED_EVENT_TYPE, + actor_user_id=current_user.id, + payload={"action": "create", "version": version}, + created_at=now, ) return ScriptConfigVersionResponse(version=_script_version_summary(row)) @@ -122,6 +163,7 @@ def activate_script_config_version( *, site_id: UUID, version_id: UUID, + current_user: CurrentUser, ) -> ScriptConfigVersionResponse: repository.target_sites.get_by_id(site_id) row = repository.script_config_versions.activate( @@ -129,6 +171,54 @@ def activate_script_config_version( version_id=version_id, activated_at=_now(), ) + repository.script_config_version_events.create( + target_site_id=site_id, + version_id=version_id, + event_type=_VERSION_ACTIVATED_EVENT_TYPE, + actor_user_id=current_user.id, + payload={"action": "activate", "version_id": str(version_id)}, + created_at=_now(), + ) + return ScriptConfigVersionResponse(version=_script_version_summary(row)) + + +def rollback_script_config_version( + repository: object, + *, + site_id: UUID, + version_id: UUID, + current_user: CurrentUser, +) -> ScriptConfigVersionResponse: + target_site = repository.target_sites.get_by_id(site_id) + version_row = repository.script_config_versions.get_by_id(version_id) + if str(version_row["target_site_id"]) != str(site_id): + raise LookupError( + f"Script config version not found for site: {site_id} {version_id}" + ) + + rollback_target_id = target_site.active_script_config_version_id + if rollback_target_id == version_id: + rollback_target_id = None + + row = repository.script_config_versions.activate( + target_site_id=site_id, + version_id=version_id, + activated_at=_now(), + rollback_target_version_id=rollback_target_id, + ) + repository.script_config_version_events.create( + target_site_id=site_id, + version_id=version_id, + event_type=_VERSION_ROLLBACK_EVENT_TYPE, + actor_user_id=current_user.id, + payload={ + "action": "rollback", + "rollback_target_version_id": ( + str(rollback_target_id) if rollback_target_id is not None else None + ), + }, + created_at=_now(), + ) return ScriptConfigVersionResponse(version=_script_version_summary(row)) @@ -147,10 +237,13 @@ def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummar target_site_id=row["target_site_id"], version=row["version"], status=row["status"], + diff=row["diff"], + rollback_target_version_id=row["rollback_target_version_id"], publishing_yaml_hash=row["publishing_yaml_hash"], transform_script_hash=row["transform_script_hash"], created_by=row["created_by"], created_at=row["created_at"], + activated_at=row["activated_at"], ) diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index c9eba0d..f0342a4 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -113,10 +113,13 @@ class ScriptConfigVersionSummary(ContractModel): target_site_id: UUID version: int = Field(ge=1) status: ScriptConfigVersionStatus + diff: JsonObject = Field(default_factory=dict) publishing_yaml_hash: str = Field(min_length=1) transform_script_hash: str = Field(min_length=1) + rollback_target_version_id: UUID | None = None created_by: UUID created_at: datetime + activated_at: datetime | None = None class ScriptConfigVersionCreateRequest(ContractModel): diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 198902e..7c54a5e 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -37,6 +37,7 @@ class BackendRepository: self.target_sites = TargetSitesRepository(self) self.articles = ArticlesRepository(self) self.script_config_versions = ScriptConfigVersionsRepository(self) + self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self) self.schema = SchemaRepository(self) def setup(self) -> None: @@ -719,6 +720,7 @@ class ScriptConfigVersionsRepository: target_site_id: UUID, version_id: UUID, activated_at: datetime, + rollback_target_version_id: UUID | None = None, ) -> dict[str, Any]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: @@ -756,13 +758,15 @@ class ScriptConfigVersionsRepository: SET status = {placeholder}, activated_at = {placeholder}, - updated_at = {placeholder} + updated_at = {placeholder}, + rollback_target_version_id = COALESCE({placeholder}, rollback_target_version_id) WHERE id = {placeholder} """, ( ScriptConfigVersionStatus.ACTIVE.value, _datetime_value(activated_at), _datetime_value(activated_at), + _uuid_value(rollback_target_version_id), str(version_id), ), ) @@ -795,6 +799,97 @@ class ScriptConfigVersionsRepository: return [_plain_row(row) for row in rows] +class ScriptConfigVersionAuditEventsRepository: + def __init__(self, repository: BackendRepository) -> None: + self._repository = repository + + def create( + self, + *, + target_site_id: UUID, + version_id: UUID, + event_type: str, + actor_user_id: UUID | None, + payload: JsonObject, + created_at: datetime, + ) -> dict[str, Any]: + placeholder = self._repository.placeholder() + sql = f""" + INSERT INTO script_config_version_events ( + id, + target_site_id, + version_id, + event_type, + actor_user_id, + payload, + created_at + ) + VALUES ( + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder} + ) + """ + params = ( + str(uuid4()), + str(target_site_id), + str(version_id), + event_type, + _uuid_value(actor_user_id), + _json_value(payload), + _datetime_value(created_at), + ) + with self._repository.connection() as connection: + connection.execute(sql, params) + + return self.get(event_type=event_type, target_site_id=target_site_id, version_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: + rows = connection.execute( + f""" + SELECT id, target_site_id, version_id, event_type, actor_user_id, payload, created_at + FROM script_config_version_events + WHERE target_site_id = {placeholder} + ORDER BY created_at + """, + (str(target_site_id),), + ).fetchall() + + return [_script_config_version_event_from_row(row) for row in rows] + + def get( + self, + *, + target_site_id: UUID, + version_id: UUID, + event_type: str, + ) -> dict[str, Any]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT id, target_site_id, version_id, event_type, actor_user_id, payload, created_at + FROM script_config_version_events + WHERE target_site_id = {placeholder} + AND version_id = {placeholder} + AND event_type = {placeholder} + """, + (str(target_site_id), str(version_id), event_type), + ).fetchone() + + if row is None: + raise LookupError( + f"Script config version event not found: {target_site_id} {version_id} {event_type}" + ) + return _script_config_version_event_from_row(row) + + class SchemaRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository @@ -920,6 +1015,18 @@ def _workflow_event_from_row(row: Any) -> WorkflowEventSummary: ) +def _script_config_version_event_from_row(row: Any) -> dict[str, Any]: + return { + "id": _row_value(row, "id"), + "target_site_id": _row_value(row, "target_site_id"), + "version_id": _row_value(row, "version_id"), + "event_type": _row_value(row, "event_type"), + "actor_user_id": _row_value(row, "actor_user_id"), + "payload": _json_from_row(row, "payload"), + "created_at": _row_value(row, "created_at"), + } + + def _plain_row(row: Any) -> dict[str, Any]: if isinstance(row, sqlite3.Row): result = dict(row) diff --git a/apps/backend/src/infrastructure/schema.py b/apps/backend/src/infrastructure/schema.py index 093d9a4..c1dfb5b 100644 --- a/apps/backend/src/infrastructure/schema.py +++ b/apps/backend/src/infrastructure/schema.py @@ -285,6 +285,17 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = ( UNIQUE (prompt_key, version) ) """, + f""" + CREATE TABLE IF NOT EXISTS script_config_version_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_site_id UUID NOT NULL REFERENCES target_sites(id) ON DELETE CASCADE, + version_id UUID NOT NULL REFERENCES script_config_versions(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor_user_id UUID REFERENCES users(id), + payload JSONB NOT NULL DEFAULT '{{}}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, "CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)", """ CREATE INDEX IF NOT EXISTS idx_articles_dashboard @@ -314,6 +325,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = ( CREATE INDEX IF NOT EXISTS idx_publish_commits_article ON publish_commits (article_id, created_at) """, + """ + CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site + ON script_config_version_events (target_site_id, created_at) + """, ) @@ -550,6 +565,17 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = ( UNIQUE (prompt_key, version) ) """, + """ + CREATE TABLE IF NOT EXISTS script_config_version_events ( + id TEXT PRIMARY KEY, + target_site_id TEXT NOT NULL, + version_id TEXT NOT NULL, + event_type TEXT NOT NULL, + actor_user_id TEXT, + payload TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, "CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)", """ CREATE INDEX IF NOT EXISTS idx_articles_dashboard @@ -579,6 +605,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = ( CREATE INDEX IF NOT EXISTS idx_publish_commits_article ON publish_commits (article_id, created_at) """, + """ + CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site + ON script_config_version_events (target_site_id, created_at) + """, ) diff --git a/apps/backend/src/presentation/routes/sites.py b/apps/backend/src/presentation/routes/sites.py index d6fa160..70bace9 100644 --- a/apps/backend/src/presentation/routes/sites.py +++ b/apps/backend/src/presentation/routes/sites.py @@ -8,7 +8,9 @@ from src.application.site_config import ( activate_script_config_version, create_script_config_version, create_target_site, + get_target_site, list_script_config_versions, + rollback_script_config_version, update_target_site, ) from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES @@ -36,6 +38,18 @@ def list_sites( return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()] +@router.get("/sites/{site_id}", response_model=TargetSiteConfigResponse) +def get_site( + site_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> TargetSiteConfigResponse: + try: + return get_target_site(repository, site_id) + except LookupError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error + + @router.post( "/sites", response_model=TargetSiteConfigResponse, @@ -107,7 +121,7 @@ def post_script_config_version( def post_activate_script_config_version( site_id: UUID, version_id: UUID, - _: CurrentUser = Depends(require_roles(ADMIN_ROLES)), + current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)), repository: BackendRepository = Depends(get_repository), ) -> ScriptConfigVersionResponse: try: @@ -115,6 +129,28 @@ def post_activate_script_config_version( repository, site_id=site_id, version_id=version_id, + current_user=current_user, + ) + 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}/rollback", + response_model=ScriptConfigVersionResponse, +) +def post_rollback_script_config_version( + site_id: UUID, + version_id: UUID, + current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> ScriptConfigVersionResponse: + try: + return rollback_script_config_version( + repository, + site_id=site_id, + version_id=version_id, + current_user=current_user, ) 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 index 7418fa2..991f1a8 100644 --- a/apps/backend/tests/integration/test_auth_authorization_public_api.py +++ b/apps/backend/tests/integration/test_auth_authorization_public_api.py @@ -121,6 +121,35 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase): version_activate_response.text, ) + def test_editor_cannot_rollback_script_config_version(self) -> None: + site_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("rollback_denied_blog"), + ) + self.assertEqual(201, site_response.status_code, site_response.text) + site_id = site_response.json()["site"]["id"] + + first_version = 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, first_version.status_code, first_version.text) + version_id = first_version.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_EDITOR_EMAIL}, + ) + rollback_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions/{version_id}/rollback", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + + self.assertEqual(403, activate_response.status_code, activate_response.text) + self.assertEqual(403, rollback_response.status_code, rollback_response.text) + def test_admin_can_create_and_activate_script_config_version(self) -> None: site_response = self.client.post( "/api/sites", @@ -147,6 +176,121 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase): self.assertEqual(200, activate_response.status_code, activate_response.text) self.assertEqual("ACTIVE", activate_response.json()["version"]["status"]) + def test_admin_can_get_site_by_id(self) -> None: + site_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("admin_get_site_by_id_blog"), + ) + self.assertEqual(201, site_response.status_code, site_response.text) + site_id = site_response.json()["site"]["id"] + + get_response = self.client.get( + f"/api/sites/{site_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + + self.assertEqual(200, get_response.status_code, get_response.text) + self.assertEqual(site_id, get_response.json()["site"]["id"]) + + def test_admin_can_create_version_with_rollback_target(self) -> None: + site_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("rollback_target_blog"), + ) + self.assertEqual(201, site_response.status_code, site_response.text) + site_id = site_response.json()["site"]["id"] + + baseline = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._script_version_payload(diff={"summary": "baseline"}), + ) + self.assertEqual(201, baseline.status_code, baseline.text) + baseline_id = baseline.json()["version"]["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( + diff={"summary": "rollback-aware"}, + rollback_target_version_id=baseline_id, + activate=True, + ), + ) + self.assertEqual(201, create_response.status_code, create_response.text) + created = create_response.json()["version"] + + self.assertEqual("ACTIVE", created["status"]) + self.assertEqual("rollback-aware", created["diff"].get("summary")) + self.assertEqual(baseline_id, created["rollback_target_version_id"]) + + versions_response = self.client.get( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, versions_response.status_code, versions_response.text) + self.assertEqual(2, len(versions_response.json()["versions"])) + + def test_admin_can_rollback_script_config_version(self) -> None: + site_response = self.client.post( + "/api/sites", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._site_payload("rollback_action_blog"), + ) + self.assertEqual(201, site_response.status_code, site_response.text) + site_id = site_response.json()["site"]["id"] + + active = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._script_version_payload( + diff={"summary": "initial active"}, + activate=True, + ), + ) + self.assertEqual(201, active.status_code, active.text) + active_id = active.json()["version"]["id"] + + next_version = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + json=self._script_version_payload(diff={"summary": "next version"}), + ) + self.assertEqual(201, next_version.status_code, next_version.text) + next_id = next_version.json()["version"]["id"] + + self.client.post( + f"/api/sites/{site_id}/publishing-config/versions/{next_id}/activate", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + ) + + rollback_response = self.client.post( + f"/api/sites/{site_id}/publishing-config/versions/{active_id}/rollback", + headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}, + ) + self.assertEqual(200, rollback_response.status_code, rollback_response.text) + self.assertEqual(active_id, rollback_response.json()["version"]["id"]) + self.assertEqual( + "ACTIVE", + rollback_response.json()["version"]["status"], + ) + self.assertEqual( + next_id, + rollback_response.json()["version"]["rollback_target_version_id"], + ) + + get_response = self.client.get( + f"/api/sites/{site_id}", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + ) + self.assertEqual(200, get_response.status_code, get_response.text) + self.assertEqual( + active_id, + get_response.json()["site"]["active_script_config_version_id"], + ) + def test_editor_can_create_articles_and_approve_plan_review(self) -> None: sites_response = self.client.get( "/api/sites", @@ -215,12 +359,22 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase): }, } - def _script_version_payload(self) -> dict[str, object]: - return { + def _script_version_payload( + self, + diff: dict[str, object] | None = None, + rollback_target_version_id: str | None = None, + activate: bool = False, + ) -> dict[str, object]: + payload = { "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."}, + "diff": diff or {"summary": "Task 004 authorization fixture."}, } + if rollback_target_version_id is not None: + payload["rollback_target_version_id"] = rollback_target_version_id + if activate: + payload["activate"] = True + return payload if __name__ == "__main__": diff --git a/apps/backend/tests/integration/test_script_config_audit.py b/apps/backend/tests/integration/test_script_config_audit.py new file mode 100644 index 0000000..2988d89 --- /dev/null +++ b/apps/backend/tests/integration/test_script_config_audit.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +import tempfile +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +import unittest +from uuid import UUID + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(BACKEND_ROOT)) + + +@contextmanager +def repository_dsn() -> Iterator[str]: + configured = os.environ.get("PIPELINE_TEST_DATABASE_DSN") + if configured: + yield configured + return + + with tempfile.TemporaryDirectory() as directory: + yield f"sqlite:///{Path(directory) / 'pipeline-audit.db'}" + + +class ScriptConfigAuditTest(unittest.TestCase): + def test_rollback_writes_audit_event(self) -> None: + with repository_dsn() as dsn: + from src.application.auth import get_current_user_by_email + from src.application.seed_data import seed_reference_data + from src.application.site_config import ( + create_script_config_version, + create_target_site, + rollback_script_config_version, + ) + from src.domain.contracts import ( + PublishingRules, + ScriptConfigVersionCreateRequest, + TargetSiteConfigCreateRequest, + ) + from src.infrastructure.repositories import open_backend_repository + + repository = open_backend_repository(dsn) + repository.setup() + seed_reference_data(repository) + admin = get_current_user_by_email(repository, "admin@example.com") + site = create_target_site( + repository, + TargetSiteConfigCreateRequest( + name="Audit Test Site", + slug="audit-site", + publishing_type="git_next", + default_language="en", + brand_voice="Audit-focused publishing profile.", + audience="QA operations team.", + seo_rules={"primary_keyword_required": True}, + visual_rules={"hero_style": "technical"}, + source_rules={"minimum_sources": 1}, + publishing_rules=PublishingRules( + repository_url="git@github.com:example/audit-site.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", + ), + active_script_config_version_id=None, + ), + ).site + + initial = create_script_config_version( + repository, + site_id=site.id, + current_user=admin, + request=self._script_version_payload(summary="initial", activate=True), + ).version + candidate = create_script_config_version( + repository, + site_id=site.id, + current_user=admin, + request=self._script_version_payload( + summary="candidate", + activate=True, + rollback_target_version_id=initial.id, + ), + ).version + + rolled_back = rollback_script_config_version( + repository, + site_id=site.id, + version_id=initial.id, + current_user=admin, + ) + self.assertEqual(str(initial.id), str(rolled_back.version.id)) + + rollback_events = [ + event + for event in repository.script_config_version_events.list_for_site(site.id) + if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK" + ] + self.assertEqual(1, len(rollback_events)) + rollback_event = rollback_events[0] + self.assertEqual(str(site.id), str(rollback_event["target_site_id"])) + self.assertEqual(str(initial.id), str(rollback_event["version_id"])) + self.assertEqual(str(admin.id), str(rollback_event["actor_user_id"])) + self.assertEqual( + str(candidate.id), + rollback_event["payload"]["rollback_target_version_id"], + ) + + active_site = repository.target_sites.get_by_id(site.id) + self.assertEqual(initial.id, active_site.active_script_config_version_id) + + def _script_version_payload( + self, + summary: str, + activate: bool = False, + rollback_target_version_id: UUID | None = None, + ) -> ScriptConfigVersionCreateRequest: + payload = ScriptConfigVersionCreateRequest( + publishing_yaml="target:\n repo: audit\n", + transform_script="export function transformArticle(article) { return article; }", + diff={"summary": summary}, + activate=activate, + ) + if rollback_target_version_id is not None: + payload.rollback_target_version_id = rollback_target_version_id + return payload + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 8d0a68a..41b8229 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,7 @@ "build": "next build", "start": "next start", "test:roles": "node tests/role-navigation.test.mjs", - "test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs", + "test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/frontend/src/app/admin/scripts/page.tsx b/apps/frontend/src/app/admin/scripts/page.tsx new file mode 100644 index 0000000..f0da7c6 --- /dev/null +++ b/apps/frontend/src/app/admin/scripts/page.tsx @@ -0,0 +1,3 @@ +import AdminScriptsPage from "@/pages/admin-scripts"; + +export { AdminScriptsPage as default }; diff --git a/apps/frontend/src/app/admin/sites/page.tsx b/apps/frontend/src/app/admin/sites/page.tsx new file mode 100644 index 0000000..d912481 --- /dev/null +++ b/apps/frontend/src/app/admin/sites/page.tsx @@ -0,0 +1,3 @@ +import AdminSitesPage from "@/pages/admin-sites"; + +export default AdminSitesPage; diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 3c29e5f..7b1cd48 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -149,18 +149,68 @@ main { min-height: 120px; } +.codeArea { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + min-height: 120px; + white-space: pre; +} + .fieldError { margin: 0; color: #b42318; font-size: 12px; } +.formSuccess { + margin: 0; + color: #027a48; + font-weight: 600; +} + .formError { margin: 4px 0 0; color: #b42318; font-weight: 600; } +.inlineLinks { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.inlineLinks a { + text-decoration: none; + border: 1px solid #d0d5dd; + border-radius: 6px; + padding: 8px 12px; + background: #ffffff; + display: inline-flex; +} + +.inlineLinks .activeLink { + border-color: #1f4b99; + color: #1f4b99; + font-weight: 600; +} + +.table button { + border-radius: 6px; + border: 1px solid #1d2733; + background: #ffffff; + color: #1d2733; + min-height: 34px; + padding: 0 10px; + margin-right: 6px; +} + +.tableAction { + background: #eceff5 !important; +} + .formActions { margin-top: 8px; } diff --git a/apps/frontend/src/features/admin-scripts/model.ts b/apps/frontend/src/features/admin-scripts/model.ts new file mode 100644 index 0000000..80f9bd4 --- /dev/null +++ b/apps/frontend/src/features/admin-scripts/model.ts @@ -0,0 +1,97 @@ +import type { + ScriptConfigVersionCreateRequest, + ScriptConfigVersionSummary, +} from "@pipeline/shared"; + +export type AdminScriptVersionRow = { + id: string; + version: number; + status: string; + createdBy: string; + createdAt: string; + activatedAt: string | null; + publishingYamlHash: string; + transformScriptHash: string; + isActive: boolean; + rollbackTargetVersionId: string | null; + diff: string; +}; + +export type NewAdminScriptVersionDraft = { + publishingYaml: string; + transformScript: string; + diff: string; + activate: boolean; + rollbackTargetVersionId: string; +}; + +export type DraftValidationErrors = Partial>; + +const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}'; +const DEFAULT_PUBLISHING_YAML = "target: site\nrepository:\n branch: main\n"; +const DEFAULT_TRANSFORM_SCRIPT = "export function transformArticle(article) {\n return article;\n}\n"; + +export function buildAdminScriptVersionRows( + versions: readonly ScriptConfigVersionSummary[], +): AdminScriptVersionRow[] { + return versions.map((version) => ({ + id: version.id, + version: version.version, + status: version.status, + createdBy: version.created_by, + createdAt: version.created_at, + activatedAt: version.activated_at ?? null, + publishingYamlHash: version.publishing_yaml_hash, + transformScriptHash: version.transform_script_hash, + isActive: version.status === "ACTIVE", + rollbackTargetVersionId: version.rollback_target_version_id ?? null, + diff: JSON.stringify(version.diff), + })); +} + +export function defaultScriptVersionDraft(): NewAdminScriptVersionDraft { + return { + publishingYaml: DEFAULT_PUBLISHING_YAML, + transformScript: DEFAULT_TRANSFORM_SCRIPT, + diff: DEFAULT_DIFF, + activate: false, + rollbackTargetVersionId: "", + }; +} + +export function buildCreateScriptVersionPayload( + draft: NewAdminScriptVersionDraft, +): ScriptConfigVersionCreateRequest { + const parsedDiff = parseJsonObject(draft.diff) ?? {}; + + return { + publishing_yaml: draft.publishingYaml, + transform_script: draft.transformScript, + diff: parsedDiff, + activate: draft.activate, + rollback_target_version_id: draft.rollbackTargetVersionId + ? (draft.rollbackTargetVersionId as string) + : null, + version: null, + }; +} + +export function parseJsonObject(value: string): Record | null { + if (!value.trim()) { + return {}; + } + try { + const parsed = JSON.parse(value); + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + parsed !== null + ) { + return parsed as Record; + } + } catch { + return null; + } + return null; +} diff --git a/apps/frontend/src/features/admin-scripts/ui.tsx b/apps/frontend/src/features/admin-scripts/ui.tsx new file mode 100644 index 0000000..16d3142 --- /dev/null +++ b/apps/frontend/src/features/admin-scripts/ui.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useMemo, useState, type FormEvent } from "react"; + +import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared"; + +import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api"; +import { + buildAdminScriptVersionRows, + buildCreateScriptVersionPayload, + defaultScriptVersionDraft, + parseJsonObject, + type DraftValidationErrors, + type NewAdminScriptVersionDraft, +} from "./model"; + +type AdminScriptVersionsPanelProps = { + site: TargetSiteConfig; + initialVersions: readonly ScriptConfigVersionSummary[]; +}; + +function parseOptionalError(value: string): string | null { + if (value.trim().length === 0) { + return "Required value"; + } + return null; +} + +export function AdminScriptVersionsPanel({ + site, + initialVersions, +}: AdminScriptVersionsPanelProps) { + const [versions, setVersions] = useState( + initialVersions, + ); + const [draft, setDraft] = useState( + defaultScriptVersionDraft(), + ); + const [errors, setErrors] = useState({}); + const [formError, setFormError] = useState(""); + const [actionMessage, setActionMessage] = useState(""); + const [isSubmitting, setSubmitting] = useState(false); + + const rows = useMemo( + () => + buildAdminScriptVersionRows(versions), + [versions], + ); + + const hasVersions = rows.length > 0; + const canSubmit = !isSubmitting; + const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id); + + async function refreshVersions() { + const latest = await fetchScriptConfigVersions( + site.id, + DEMO_ADMIN_EMAIL, + ); + setVersions(latest.versions); + } + + function updateField( + field: K, + value: NewAdminScriptVersionDraft[K], + ) { + setDraft((current) => ({ ...current, [field]: value })); + } + + function clearMessages() { + setFormError(""); + setActionMessage(""); + } + + async function handleCreateVersion(event: FormEvent) { + event.preventDefault(); + const nextErrors: DraftValidationErrors = {}; + + const publishingYamlError = parseOptionalError(draft.publishingYaml); + const transformScriptError = parseOptionalError(draft.transformScript); + const diffObject = parseJsonObject(draft.diff); + + if (publishingYamlError) { + nextErrors.publishingYaml = publishingYamlError; + } + if (transformScriptError) { + nextErrors.transformScript = transformScriptError; + } + if (diffObject === null) { + nextErrors.diff = "Invalid JSON object"; + } + + if (draft.rollbackTargetVersionId) { + const validRollback = rows.some((row) => row.id === draft.rollbackTargetVersionId); + if (!validRollback) { + nextErrors.rollbackTargetVersionId = "Select a valid version ID"; + } + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + return; + } + + setErrors({}); + clearMessages(); + setSubmitting(true); + + try { + const payload = buildCreateScriptVersionPayload(draft); + await createScriptConfigVersion(site.id, payload, DEMO_ADMIN_EMAIL); + setActionMessage("New version created"); + await refreshVersions(); + } catch (error) { + if (error instanceof ApiError) { + setFormError(error.message || "Request failed"); + } else { + setFormError("Unable to create publishing version"); + } + } finally { + setSubmitting(false); + } + } + + async function handleActivate(versionId: string) { + setSubmitting(true); + clearMessages(); + try { + await activateScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL); + await refreshVersions(); + setActionMessage("Version activated"); + } catch (error) { + if (error instanceof ApiError) { + setFormError(error.message || "Request failed"); + } else { + setFormError("Unable to activate version"); + } + } finally { + setSubmitting(false); + } + } + + async function handleRollback(versionId: string) { + setSubmitting(true); + clearMessages(); + try { + await rollbackScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL); + await refreshVersions(); + setActionMessage("Rollback applied"); + } catch (error) { + if (error instanceof ApiError) { + setFormError(error.message || "Request failed"); + } else { + setFormError("Unable to rollback version"); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+

{site.name} script versions

+ {formError ?

Error: {formError}

: null} + {actionMessage ?

{actionMessage}

: null} + +

Create new version

+
+ +