diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index 27aefb4..f4bce40 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -1,13 +1,15 @@ from __future__ import annotations from datetime import UTC, datetime -from uuid import NAMESPACE_URL, UUID, uuid5 +from uuid import UUID, uuid4 from src.domain.contracts import ( ArticleCreateRequest, ArticleCreateResponse, - ArticleSummary, + ArticleDetailResponse, + ArticleListResponse, ArticleWorkflowStatus, + CurrentUser, PublishingStatus, ReviewActionResponse, ReviewStatus, @@ -16,18 +18,15 @@ from src.domain.contracts import ( ) -PLACEHOLDER_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC) - - -def build_placeholder_article_response( +def create_article( + repository: object, request: ArticleCreateRequest, + current_user: CurrentUser, ) -> ArticleCreateResponse: - article_id = uuid5( - NAMESPACE_URL, - f"article:{request.target_site_id}:{request.brief_description}", - ) - article = ArticleSummary( - id=article_id, + repository.target_sites.get_by_id(request.target_site_id) + + now = _now() + summary = repository.articles.create( target_site_id=request.target_site_id, status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED, publishing_status=PublishingStatus.PUBLISH_NOT_STARTED, @@ -36,11 +35,38 @@ def build_placeholder_article_response( language=request.language, content_type=request.content_type, primary_keyword=request.primary_keyword, - assigned_editor_id=request.assigned_editor_id, - created_at=PLACEHOLDER_CREATED_AT, - updated_at=PLACEHOLDER_CREATED_AT, + assigned_editor_id=request.assigned_editor_id or current_user.id, + created_at=now, + updated_at=now, + ) + repository.articles.create_workflow_event( + article_id=summary.id, + event_type="ARTICLE_CREATED", + from_status=None, + to_status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED, + actor_user_id=current_user.id, + payload={"brief_description_length": len(request.brief_description)}, + created_at=now, + ) + return ArticleCreateResponse(article=summary) + + +def list_articles(repository: object) -> ArticleListResponse: + return ArticleListResponse(articles=repository.articles.list()) + + +def get_article_detail( + repository: object, + article_id: UUID, +) -> ArticleDetailResponse: + article = repository.articles.get(article_id) + target_site = repository.target_sites.get_by_id(article.target_site_id) + workflow_events = repository.articles.list_workflow_events(article_id) + return ArticleDetailResponse( + article=article, + target_site=target_site, + workflow_events=workflow_events, ) - return ArticleCreateResponse(article=article) def build_placeholder_plan_approval_response( @@ -50,12 +76,16 @@ def build_placeholder_plan_approval_response( reviewer_id: UUID, ) -> ReviewActionResponse: review = ReviewSummary( - id=uuid5(NAMESPACE_URL, f"plan-approval:{article_id}:{plan_id}:{reviewer_id}"), + id=uuid4(), article_id=article_id, review_type=ReviewType.PLAN, status=ReviewStatus.APPROVED, reviewer_id=reviewer_id, notes=None, - created_at=PLACEHOLDER_CREATED_AT, + created_at=_now(), ) return ReviewActionResponse(review=review) + + +def _now() -> datetime: + return datetime.now(UTC) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index f303b61..8ceb4c9 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -22,6 +22,7 @@ from .models import ( ArticleDetailResponse, ArticleListResponse, ArticleSummary, + WorkflowEventSummary, AssetSummary, ClaimSummary, CurrentUser, @@ -66,6 +67,7 @@ __all__ = [ "ArticleDetailResponse", "ArticleListResponse", "ArticleSummary", + "WorkflowEventSummary", "ArticleWorkflowStatus", "AssetStatus", "AssetSummary", diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index 35181fb..c9eba0d 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -169,6 +169,17 @@ class ArticleListResponse(ContractModel): articles: list[ArticleSummary] +class WorkflowEventSummary(ContractModel): + id: UUID + article_id: UUID + event_type: str = Field(min_length=1) + from_status: ArticleWorkflowStatus | None = None + to_status: ArticleWorkflowStatus | None = None + actor_user_id: UUID | None = None + payload: JsonObject = Field(default_factory=dict) + created_at: datetime + + class PlanSectionSummary(ContractModel): id: UUID article_plan_id: UUID @@ -317,6 +328,7 @@ class AgentJobOutput(ContractModel): class ArticleDetailResponse(ContractModel): article: ArticleSummary target_site: TargetSiteConfig | None = None + workflow_events: list[WorkflowEventSummary] = Field(default_factory=list) plan: PlanSummary | None = None draft: DraftSummary | None = None evidence: list[EvidenceSummary] = Field(default_factory=list) diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index 11ebd7a..4a0726f 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -51,6 +51,7 @@ from .models import ( TargetSiteConfig, TargetSiteConfigCreateRequest, TargetSiteConfigResponse, + WorkflowEventSummary, TargetSiteConfigUpdateRequest, UserSummary, ) @@ -90,6 +91,7 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( ArticleSummary, ArticleCreateResponse, ArticleListResponse, + WorkflowEventSummary, PlanSectionSummary, PlanSummary, ResearchArtifactSummary, diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 59c8cc9..198902e 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -6,10 +6,14 @@ from collections.abc import Iterator from contextlib import contextmanager from datetime import datetime from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 from src.domain.contracts import ( + ArticleSummary, PublishingRules, + PublishingStatus, + ArticleWorkflowStatus, + WorkflowEventSummary, Role, ScriptConfigVersionStatus, TargetSiteConfig, @@ -31,6 +35,7 @@ class BackendRepository: self.dialect = "sqlite" if dsn.startswith("sqlite:///") else "postgres" self.users = UsersRepository(self) self.target_sites = TargetSitesRepository(self) + self.articles = ArticlesRepository(self) self.script_config_versions = ScriptConfigVersionsRepository(self) self.schema = SchemaRepository(self) @@ -384,6 +389,204 @@ class TargetSitesRepository: return [_target_site_from_row(row) for row in rows] +class ArticlesRepository: + def __init__(self, repository: BackendRepository) -> None: + self._repository = repository + + def create( + self, + *, + target_site_id: UUID, + status: ArticleWorkflowStatus, + publishing_status: PublishingStatus, + brief_description: str, + working_title: str | None, + language: str, + content_type: str, + primary_keyword: str | None, + assigned_editor_id: UUID | None, + created_at: datetime, + updated_at: datetime, + ) -> ArticleSummary: + article_id = uuid4() + placeholder = self._repository.placeholder() + sql = f""" + INSERT INTO articles ( + id, + target_site_id, + status, + publishing_status, + brief_description, + working_title, + language, + content_type, + primary_keyword, + assigned_editor_id, + created_at, + updated_at + ) + VALUES ( + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder} + ) + """ + with self._repository.connection() as connection: + connection.execute( + sql, + ( + str(article_id), + str(target_site_id), + status.value, + publishing_status.value, + brief_description, + working_title, + language, + content_type, + primary_keyword, + _uuid_value(assigned_editor_id), + _datetime_value(created_at), + _datetime_value(updated_at), + ), + ) + + return self.get(article_id) + + def list(self) -> list[ArticleSummary]: + with self._repository.connection() as connection: + rows = connection.execute( + """ + SELECT + id, + target_site_id, + status, + publishing_status, + brief_description, + working_title, + language, + content_type, + primary_keyword, + assigned_editor_id, + created_at, + updated_at + FROM articles + ORDER BY updated_at DESC + """ + ).fetchall() + + return [_article_summary_from_row(row) for row in rows] + + def get(self, article_id: UUID) -> ArticleSummary: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + row = connection.execute( + f""" + SELECT + id, + target_site_id, + status, + publishing_status, + brief_description, + working_title, + language, + content_type, + primary_keyword, + assigned_editor_id, + created_at, + updated_at + FROM articles + WHERE id = {placeholder} + """, + (str(article_id),), + ).fetchone() + + if row is None: + raise LookupError(f"Article not found: {article_id}") + return _article_summary_from_row(row) + + def create_workflow_event( + self, + *, + article_id: UUID, + event_type: str, + from_status: ArticleWorkflowStatus | None, + to_status: ArticleWorkflowStatus | None, + actor_user_id: UUID | None, + payload: dict[str, Any], + created_at: datetime, + ) -> None: + placeholder = self._repository.placeholder() + json_cast = self._repository.json_cast() + sql = f""" + INSERT INTO workflow_events ( + id, + article_id, + event_type, + from_status, + to_status, + actor_user_id, + payload, + created_at + ) + VALUES ( + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}, + {placeholder}{json_cast}, + {placeholder} + ) + """ + with self._repository.connection() as connection: + connection.execute( + sql, + ( + str(uuid4()), + str(article_id), + event_type, + _article_status_value(from_status), + _article_status_value(to_status), + _uuid_value(actor_user_id), + _json_value(payload), + _datetime_value(created_at), + ), + ) + + def list_workflow_events(self, article_id: UUID) -> list[WorkflowEventSummary]: + placeholder = self._repository.placeholder() + with self._repository.connection() as connection: + rows = connection.execute( + f""" + SELECT + id, + article_id, + event_type, + from_status, + to_status, + actor_user_id, + payload, + created_at + FROM workflow_events + WHERE article_id = {placeholder} + ORDER BY created_at + """, + (str(article_id),), + ).fetchall() + + return [_workflow_event_from_row(row) for row in rows] + + class ScriptConfigVersionsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository @@ -687,6 +890,36 @@ def _target_site_from_row(row: Any) -> TargetSiteConfig: ) +def _article_summary_from_row(row: Any) -> ArticleSummary: + return ArticleSummary( + id=_row_value(row, "id"), + target_site_id=_row_value(row, "target_site_id"), + status=_row_value(row, "status"), + publishing_status=_row_value(row, "publishing_status"), + brief_description=_row_value(row, "brief_description"), + working_title=_row_value(row, "working_title"), + language=_row_value(row, "language"), + content_type=_row_value(row, "content_type"), + primary_keyword=_row_value(row, "primary_keyword"), + assigned_editor_id=_row_value(row, "assigned_editor_id"), + created_at=_row_value(row, "created_at"), + updated_at=_row_value(row, "updated_at"), + ) + + +def _workflow_event_from_row(row: Any) -> WorkflowEventSummary: + return WorkflowEventSummary( + id=_row_value(row, "id"), + article_id=_row_value(row, "article_id"), + event_type=_row_value(row, "event_type"), + from_status=_row_value(row, "from_status"), + to_status=_row_value(row, "to_status"), + 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) @@ -733,3 +966,9 @@ def _datetime_value(value: datetime | None) -> str | None: if value is None: return None return value.isoformat() + + +def _article_status_value(value: ArticleWorkflowStatus | None) -> str | None: + if value is None: + return None + return value.value diff --git a/apps/backend/src/presentation/routes/articles.py b/apps/backend/src/presentation/routes/articles.py index 77ec0c2..e21102c 100644 --- a/apps/backend/src/presentation/routes/articles.py +++ b/apps/backend/src/presentation/routes/articles.py @@ -2,20 +2,25 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, status from src.application.articles import ( - build_placeholder_article_response, + create_article, + get_article_detail, + list_articles, build_placeholder_plan_approval_response, ) from src.domain.auth import EDITOR_OR_ADMIN_ROLES from src.domain.contracts import ( ArticleCreateRequest, ArticleCreateResponse, + ArticleDetailResponse, + ArticleListResponse, CurrentUser, ReviewActionResponse, ) -from src.presentation.dependencies import require_roles +from src.infrastructure.repositories import BackendRepository +from src.presentation.dependencies import get_repository, require_roles router = APIRouter(prefix="/api", tags=["articles"]) @@ -26,11 +31,48 @@ router = APIRouter(prefix="/api", tags=["articles"]) response_model=ArticleCreateResponse, status_code=status.HTTP_201_CREATED, ) -def create_article( +def post_article( request: ArticleCreateRequest, - _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), ) -> ArticleCreateResponse: - return build_placeholder_article_response(request) + try: + return create_article( + repository, + request=request, + current_user=current_user, + ) + except LookupError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid target_site_id", + ) from error + + +@router.get("/articles", response_model=ArticleListResponse) +def list_articles_route( + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> ArticleListResponse: + return list_articles(repository) + + +@router.get( + "/articles/{article_id}", + response_model=ArticleDetailResponse, +) +def get_article( + article_id: UUID, + _: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), + repository: BackendRepository = Depends(get_repository), +) -> ArticleDetailResponse: + try: + return get_article_detail(repository=repository, article_id=article_id) + except LookupError as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Not Found", + ) from error @router.post( diff --git a/apps/backend/tests/integration/test_article_intake_public_api.py b/apps/backend/tests/integration/test_article_intake_public_api.py new file mode 100644 index 0000000..6c45784 --- /dev/null +++ b/apps/backend/tests/integration/test_article_intake_public_api.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from typing import Any +from uuid import uuid4 + +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_USER_EMAIL_HEADER = "X-Demo-User-Email" + + +class ArticleIntakePublicApiTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(app) + + def test_editor_creates_article_and_reads_dashboard_detail_timeline(self) -> None: + headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL} + + editor_response = self.client.get("/api/me", headers=headers) + self.assertEqual(200, editor_response.status_code, editor_response.text) + editor_user_id = editor_response.json()["user"]["id"] + + sites_response = self.client.get("/api/sites", headers=headers) + self.assertEqual(200, sites_response.status_code, sites_response.text) + target_site = sites_response.json()[0]["site"] + + brief_description = ( + "Create an editorial control guide for AI content intake " + f"{uuid4()}." + ) + create_response = self.client.post( + "/api/articles", + headers=headers, + json={ + "target_site_id": target_site["id"], + "brief_description": brief_description, + "working_title": "AI Content Intake Controls", + "content_type": "longform_guide", + "primary_keyword": "AI content intake", + }, + ) + + self.assertEqual(201, create_response.status_code, create_response.text) + created_article = create_response.json()["article"] + self.assertEqual("ARTICLE_BRIEF_CREATED", created_article["status"]) + self.assertEqual(target_site["id"], created_article["target_site_id"]) + self.assertEqual(brief_description, created_article["brief_description"]) + + article_id = created_article["id"] + list_response = self.client.get("/api/articles", headers=headers) + detail_response = self.client.get(f"/api/articles/{article_id}", headers=headers) + + failures: list[str] = [] + if list_response.status_code != 200: + failures.append( + "GET /api/articles should return the persisted dashboard list; " + f"got {list_response.status_code}: {list_response.text}" + ) + else: + article = self._find_article(list_response.json(), article_id) + if article is None: + failures.append( + "Created article is missing from GET /api/articles dashboard list" + ) + else: + self._expect_equal( + failures, + "dashboard article status", + "ARTICLE_BRIEF_CREATED", + article.get("status"), + ) + self._expect_equal( + failures, + "dashboard article target_site_id", + target_site["id"], + article.get("target_site_id"), + ) + + if detail_response.status_code != 200: + failures.append( + "GET /api/articles/{article_id} should return the persisted detail " + f"shell; got {detail_response.status_code}: {detail_response.text}" + ) + else: + detail = detail_response.json() + if not isinstance(detail, dict): + failures.append("Article detail should return a JSON object") + detail = {} + + detail_article = detail.get("article", {}) + self._expect_equal( + failures, + "detail article id", + article_id, + detail_article.get("id"), + ) + self._expect_equal( + failures, + "detail article status", + "ARTICLE_BRIEF_CREATED", + detail_article.get("status"), + ) + + detail_target_site = detail.get("target_site") + if not isinstance(detail_target_site, dict): + failures.append("Article detail should include target_site summary") + else: + self._expect_equal( + failures, + "detail target_site id", + target_site["id"], + detail_target_site.get("id"), + ) + self._expect_equal( + failures, + "detail target_site slug", + target_site["slug"], + detail_target_site.get("slug"), + ) + + workflow_events = detail.get("workflow_events") + if not isinstance(workflow_events, list): + failures.append("Article detail should include workflow_events timeline") + else: + created_events = [ + event + for event in workflow_events + if event.get("event_type") == "ARTICLE_CREATED" + ] + if not created_events: + failures.append( + "Article detail workflow_events should contain ARTICLE_CREATED" + ) + else: + created_event = created_events[0] + self._expect_equal( + failures, + "ARTICLE_CREATED article_id", + article_id, + created_event.get("article_id"), + ) + self._expect_equal( + failures, + "ARTICLE_CREATED to_status", + "ARTICLE_BRIEF_CREATED", + created_event.get("to_status"), + ) + self._expect_equal( + failures, + "ARTICLE_CREATED actor_user_id", + editor_user_id, + created_event.get("actor_user_id"), + ) + if not created_event.get("created_at"): + failures.append("ARTICLE_CREATED should include created_at") + + self.assertEqual([], failures) + + def test_editor_cannot_create_article_with_unknown_target_site(self) -> None: + response = self.client.post( + "/api/articles", + headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, + json={ + "target_site_id": "00000000-0000-0000-0000-000000000000", + "brief_description": "Intake target-site guardrail check.", + "content_type": "longform_guide", + "primary_keyword": "integration", + }, + ) + + self.assertEqual(400, response.status_code, response.text) + self.assertEqual("Invalid target_site_id", response.json()["detail"]) + + def _find_article( + self, + response_body: Any, + article_id: str, + ) -> dict[str, Any] | None: + if not isinstance(response_body, dict): + return None + + articles = response_body.get("articles") + if not isinstance(articles, list): + return None + + return next( + ( + article + for article in articles + if isinstance(article, dict) and article.get("id") == article_id + ), + None, + ) + + def _expect_equal( + self, + failures: list[str], + label: str, + expected: object, + actual: object, + ) -> None: + if actual != expected: + failures.append(f"{label}: expected {expected!r}, got {actual!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 8c3e29b..8d0a68a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,6 +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", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/frontend/src/app/articles/[articleId]/page.tsx b/apps/frontend/src/app/articles/[articleId]/page.tsx new file mode 100644 index 0000000..e545f7a --- /dev/null +++ b/apps/frontend/src/app/articles/[articleId]/page.tsx @@ -0,0 +1,14 @@ +import ArticleDetailPage from "@/pages/article-detail"; + +type DynamicArticleDetailPageProps = { + params: { + articleId: string; + }; +}; + +export default async function DynamicArticleDetailPage({ + params, +}: DynamicArticleDetailPageProps) { + const { articleId } = params; + return ; +} diff --git a/apps/frontend/src/app/articles/new/page.tsx b/apps/frontend/src/app/articles/new/page.tsx new file mode 100644 index 0000000..40f7d7a --- /dev/null +++ b/apps/frontend/src/app/articles/new/page.tsx @@ -0,0 +1,3 @@ +import NewArticlePage from "@/pages/article-new"; + +export { NewArticlePage as default }; diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 2f422fc..3c29e5f 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -76,6 +76,141 @@ main { text-decoration: none; } +.panel { + max-width: 980px; +} + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 16px; + background: #ffffff; + border: 1px solid #d0d5dd; + border-radius: 8px; + overflow: hidden; +} + +.table th, +.table td { + border-bottom: 1px solid #d0d5dd; + padding: 10px; + text-align: left; + vertical-align: top; +} + +.table th { + background: #eef2f6; +} + +.table a { + font-weight: 600; + color: #1f4b99; + text-decoration: none; +} + +.table a:hover { + text-decoration: underline; +} + +.emptyState { + margin-top: 8px; + padding: 16px; + border: 1px dashed #b7c0cc; + border-radius: 8px; + background: #ffffff; +} + +.articleForm { + max-width: 620px; + display: grid; + gap: 8px; + padding: 16px; + border: 1px solid #d0d5dd; + border-radius: 8px; + background: #ffffff; +} + +.articleForm label { + font-size: 13px; + font-weight: 600; +} + +.articleForm input, +.articleForm select, +.articleForm textarea { + width: 100%; + border-radius: 6px; + border: 1px solid #d0d5dd; + padding: 10px; + font: inherit; +} + +.articleForm textarea { + min-height: 120px; +} + +.fieldError { + margin: 0; + color: #b42318; + font-size: 12px; +} + +.formError { + margin: 4px 0 0; + color: #b42318; + font-weight: 600; +} + +.formActions { + margin-top: 8px; +} + +.formActions button { + border-radius: 6px; + border: 1px solid #1d2733; + background: #1d2733; + color: #ffffff; + min-height: 40px; + padding: 0 14px; +} + +.detailGrid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 10px; +} + +.detailGrid div { + border: 1px solid #d0d5dd; + border-radius: 8px; + padding: 10px; + background: #ffffff; +} + +.detailGrid dt { + font-size: 12px; + text-transform: uppercase; + color: #667085; + margin-bottom: 6px; +} + +.detailGrid dd { + margin: 0; + font-weight: 600; +} + +.timeline { + margin: 0; + list-style: none; + padding: 0; + max-width: 980px; +} + +.timeline li { + border-bottom: 1px solid #d0d5dd; + padding: 10px 0; +} + @media (max-width: 640px) { main { padding: 28px; diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index ed5af1d..6476610 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,22 +1,3 @@ -import type { ArticleListResponse } from "@pipeline/shared"; +import DashboardPage from "@/pages/dashboard"; -import { RoleNavigation } from "@/widgets/role-navigation"; - -const initialArticles: ArticleListResponse = { - articles: [], -}; - -export default function HomePage() { - return ( -
-
-
-

AI Content Pipeline

-

{initialArticles.articles.length} active article workflows

-
- Editor demo -
- -
- ); -} +export { DashboardPage as default }; diff --git a/apps/frontend/src/features/article-dashboard/model.ts b/apps/frontend/src/features/article-dashboard/model.ts new file mode 100644 index 0000000..2ec7733 --- /dev/null +++ b/apps/frontend/src/features/article-dashboard/model.ts @@ -0,0 +1,59 @@ +import type { ArticleSummary, TargetSiteConfig } from "@pipeline/shared"; + +export type DashboardRow = { + id: string; + targetSiteName: string; + workingTitle: string | null; + briefDescription: string; + status: string; + publishingStatus: string; + assignedEditor: string; + updatedAt: string; + nextAction: string; +}; + +const EMPTY_ASSIGNED_EDITOR_LABEL = "Unassigned"; + +export function getNextActionForStatus(status: string): string { + if (status === "ARTICLE_BRIEF_CREATED") { + return "Create plan"; + } + if (status === "PLAN_DRAFTED") { + return "Run boundary questions"; + } + if (status === "PLAN_REVIEWED") { + return "Gather evidence"; + } + return "Continue workflow"; +} + +function getAssignedEditorLabel(article: ArticleSummary): string { + return article.assigned_editor_id ?? EMPTY_ASSIGNED_EDITOR_LABEL; +} + +function getTargetSiteName( + article: ArticleSummary, + siteById: Map, +): string { + const site = siteById.get(article.target_site_id); + return site?.name ?? "Unknown site"; +} + +export function buildDashboardRows( + articles: readonly ArticleSummary[], + sites: readonly TargetSiteConfig[], +): DashboardRow[] { + const siteById = new Map(sites.map((site) => [site.id, site])); + + return articles.map((article) => ({ + id: article.id, + targetSiteName: getTargetSiteName(article, siteById), + workingTitle: article.working_title ?? null, + briefDescription: article.brief_description, + status: article.status, + publishingStatus: article.publishing_status, + assignedEditor: getAssignedEditorLabel(article), + updatedAt: article.updated_at, + nextAction: getNextActionForStatus(article.status), + })); +} diff --git a/apps/frontend/src/features/article-dashboard/ui.tsx b/apps/frontend/src/features/article-dashboard/ui.tsx new file mode 100644 index 0000000..ad24b64 --- /dev/null +++ b/apps/frontend/src/features/article-dashboard/ui.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; + +import type { DashboardRow } from "./model"; + +type DashboardTableProps = { + rows: readonly DashboardRow[]; +}; + +export function DashboardTable({ rows }: DashboardTableProps) { + if (rows.length === 0) { + return

No article workflows yet.

; + } + + return ( + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
Article titleTarget websiteStatusAssigned editorLast updatedNext actionPublishing status
+ {row.workingTitle || row.briefDescription} + {row.targetSiteName}{row.status}{row.assignedEditor}{new Date(row.updatedAt).toLocaleString()}{row.nextAction}{row.publishingStatus}
+ ); +} diff --git a/apps/frontend/src/features/article-detail/model.ts b/apps/frontend/src/features/article-detail/model.ts new file mode 100644 index 0000000..b37c91f --- /dev/null +++ b/apps/frontend/src/features/article-detail/model.ts @@ -0,0 +1,42 @@ +import type { ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared"; + +export type ArticleTimelineItem = Pick< + WorkflowEventSummary, + "id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at" +> & { + articleId: string; +}; + +export type DetailSummary = { + articleId: string; + status: string; + publishingStatus: string; + briefDescription: string; + targetSite: string; + updatedAt: string; + timeline: ArticleTimelineItem[]; +}; + +export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] { + return (detail.workflow_events ?? []).map((event) => ({ + id: event.id, + event_type: event.event_type, + to_status: event.to_status, + from_status: event.from_status, + actor_user_id: event.actor_user_id, + created_at: event.created_at, + articleId: event.article_id, + })); +} + +export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary { + return { + articleId: detail.article.id, + status: detail.article.status, + publishingStatus: detail.article.publishing_status, + briefDescription: detail.article.brief_description, + targetSite: detail.target_site?.name ?? "Unknown", + updatedAt: detail.article.updated_at, + timeline: buildArticleTimeline(detail), + }; +} diff --git a/apps/frontend/src/features/article-detail/ui.tsx b/apps/frontend/src/features/article-detail/ui.tsx new file mode 100644 index 0000000..e784e20 --- /dev/null +++ b/apps/frontend/src/features/article-detail/ui.tsx @@ -0,0 +1,52 @@ +import type { DetailSummary } from "./model"; + +type DetailShellProps = { + summary: DetailSummary; +}; + +export function ArticleDetailShell({ summary }: DetailShellProps) { + const timeline = summary.timeline; + + return ( +
+

Article detail

+
+
+
Article ID
+
{summary.articleId}
+
+
+
Status
+
{summary.status}
+
+
+
Publishing status
+
{summary.publishingStatus}
+
+
+
Target site
+
{summary.targetSite}
+
+
+
Last updated
+
{new Date(summary.updatedAt).toLocaleString()}
+
+
+ +

Brief

+

{summary.briefDescription}

+ +

Workflow timeline

+
    + {timeline.map((event) => ( +
  • + {event.event_type} from {event.from_status ?? "—"} to{" "} + {event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()} + {event.actor_user_id ? • actor {event.actor_user_id} : null} +
  • + ))} + {timeline.length === 0 ?
  • No workflow events yet.
  • : null} +
+
+ ); +} diff --git a/apps/frontend/src/features/article-intake/model.ts b/apps/frontend/src/features/article-intake/model.ts new file mode 100644 index 0000000..9b0ac31 --- /dev/null +++ b/apps/frontend/src/features/article-intake/model.ts @@ -0,0 +1,43 @@ +import type { TargetSiteConfig } from "@pipeline/shared"; + +export type NewArticleDraft = { + targetSiteId: string; + briefDescription: string; + workingTitle: string; + contentType: string; + primaryKeyword: string; +}; + +export type FieldErrors = Record; + +export type NewArticleFormState = { + values: NewArticleDraft; + errors: FieldErrors; + formError?: string; +}; + +export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft { + return { + targetSiteId: sites.length > 0 ? sites[0].id : "", + briefDescription: "", + workingTitle: "", + contentType: "longform_guide", + primaryKeyword: "", + }; +} + +export function validateArticleDraft(values: NewArticleDraft): FieldErrors { + const errors: FieldErrors = {}; + + if (!values.targetSiteId) { + errors.targetSiteId = "Select a target site"; + } + if (!values.briefDescription.trim()) { + errors.briefDescription = "Brief description is required"; + } + if (!values.contentType.trim()) { + errors.contentType = "Content type is required"; + } + + return errors; +} diff --git a/apps/frontend/src/features/article-intake/ui.tsx b/apps/frontend/src/features/article-intake/ui.tsx new file mode 100644 index 0000000..0d41b0c --- /dev/null +++ b/apps/frontend/src/features/article-intake/ui.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useMemo, useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; + +import type { TargetSiteConfig } from "@pipeline/shared"; + +import { ApiError, createArticle } from "@/shared/pipeline-api"; +import { + initialDraft, + validateArticleDraft, + type FieldErrors, + type NewArticleDraft, +} from "./model"; + +type NewArticleFormProps = { + sites: readonly TargetSiteConfig[]; +}; + +export function NewArticleForm({ sites }: NewArticleFormProps) { + const router = useRouter(); + const [values, setValues] = useState(() => initialDraft(sites)); + const [errors, setErrors] = useState({}); + const [formError, setFormError] = useState(""); + const [isSubmitting, setSubmitting] = useState(false); + + const canSubmit = useMemo( + () => !isSubmitting && sites.length > 0, + [isSubmitting, sites.length], + ); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const nextErrors = validateArticleDraft(values); + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + return; + } + + setErrors({}); + setFormError(""); + setSubmitting(true); + try { + const response = await createArticle({ + target_site_id: values.targetSiteId, + brief_description: values.briefDescription, + working_title: values.workingTitle || null, + language: "en", + content_type: values.contentType, + primary_keyword: values.primaryKeyword || null, + }); + router.push(`/articles/${response.article.id}`); + } catch (error) { + if (error instanceof ApiError) { + setFormError(error.message || "Request failed"); + } else { + setFormError("Unable to create article"); + } + } finally { + setSubmitting(false); + } + } + + function updateField(field: K, value: NewArticleDraft[K]) { + setValues((current) => ({ ...current, [field]: value })); + } + + return ( +
+

Create article brief

+ {formError ?

Error: {formError}

: null} + + + {errors.targetSiteId ?

{errors.targetSiteId}

: null} + + +