feat(task-005): complete article intake dashboard detail flow

This commit is contained in:
2026-05-21 18:54:11 +03:00
parent 7972e9a95f
commit cf84400118
28 changed files with 1714 additions and 61 deletions
+48 -18
View File
@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from uuid import NAMESPACE_URL, UUID, uuid5 from uuid import UUID, uuid4
from src.domain.contracts import ( from src.domain.contracts import (
ArticleCreateRequest, ArticleCreateRequest,
ArticleCreateResponse, ArticleCreateResponse,
ArticleSummary, ArticleDetailResponse,
ArticleListResponse,
ArticleWorkflowStatus, ArticleWorkflowStatus,
CurrentUser,
PublishingStatus, PublishingStatus,
ReviewActionResponse, ReviewActionResponse,
ReviewStatus, ReviewStatus,
@@ -16,18 +18,15 @@ from src.domain.contracts import (
) )
PLACEHOLDER_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC) def create_article(
repository: object,
def build_placeholder_article_response(
request: ArticleCreateRequest, request: ArticleCreateRequest,
current_user: CurrentUser,
) -> ArticleCreateResponse: ) -> ArticleCreateResponse:
article_id = uuid5( repository.target_sites.get_by_id(request.target_site_id)
NAMESPACE_URL,
f"article:{request.target_site_id}:{request.brief_description}", now = _now()
) summary = repository.articles.create(
article = ArticleSummary(
id=article_id,
target_site_id=request.target_site_id, target_site_id=request.target_site_id,
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED, status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED, publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
@@ -36,11 +35,38 @@ def build_placeholder_article_response(
language=request.language, language=request.language,
content_type=request.content_type, content_type=request.content_type,
primary_keyword=request.primary_keyword, primary_keyword=request.primary_keyword,
assigned_editor_id=request.assigned_editor_id, assigned_editor_id=request.assigned_editor_id or current_user.id,
created_at=PLACEHOLDER_CREATED_AT, created_at=now,
updated_at=PLACEHOLDER_CREATED_AT, 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( def build_placeholder_plan_approval_response(
@@ -50,12 +76,16 @@ def build_placeholder_plan_approval_response(
reviewer_id: UUID, reviewer_id: UUID,
) -> ReviewActionResponse: ) -> ReviewActionResponse:
review = ReviewSummary( review = ReviewSummary(
id=uuid5(NAMESPACE_URL, f"plan-approval:{article_id}:{plan_id}:{reviewer_id}"), id=uuid4(),
article_id=article_id, article_id=article_id,
review_type=ReviewType.PLAN, review_type=ReviewType.PLAN,
status=ReviewStatus.APPROVED, status=ReviewStatus.APPROVED,
reviewer_id=reviewer_id, reviewer_id=reviewer_id,
notes=None, notes=None,
created_at=PLACEHOLDER_CREATED_AT, created_at=_now(),
) )
return ReviewActionResponse(review=review) return ReviewActionResponse(review=review)
def _now() -> datetime:
return datetime.now(UTC)
@@ -22,6 +22,7 @@ from .models import (
ArticleDetailResponse, ArticleDetailResponse,
ArticleListResponse, ArticleListResponse,
ArticleSummary, ArticleSummary,
WorkflowEventSummary,
AssetSummary, AssetSummary,
ClaimSummary, ClaimSummary,
CurrentUser, CurrentUser,
@@ -66,6 +67,7 @@ __all__ = [
"ArticleDetailResponse", "ArticleDetailResponse",
"ArticleListResponse", "ArticleListResponse",
"ArticleSummary", "ArticleSummary",
"WorkflowEventSummary",
"ArticleWorkflowStatus", "ArticleWorkflowStatus",
"AssetStatus", "AssetStatus",
"AssetSummary", "AssetSummary",
@@ -169,6 +169,17 @@ class ArticleListResponse(ContractModel):
articles: list[ArticleSummary] 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): class PlanSectionSummary(ContractModel):
id: UUID id: UUID
article_plan_id: UUID article_plan_id: UUID
@@ -317,6 +328,7 @@ class AgentJobOutput(ContractModel):
class ArticleDetailResponse(ContractModel): class ArticleDetailResponse(ContractModel):
article: ArticleSummary article: ArticleSummary
target_site: TargetSiteConfig | None = None target_site: TargetSiteConfig | None = None
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
plan: PlanSummary | None = None plan: PlanSummary | None = None
draft: DraftSummary | None = None draft: DraftSummary | None = None
evidence: list[EvidenceSummary] = Field(default_factory=list) evidence: list[EvidenceSummary] = Field(default_factory=list)
@@ -51,6 +51,7 @@ from .models import (
TargetSiteConfig, TargetSiteConfig,
TargetSiteConfigCreateRequest, TargetSiteConfigCreateRequest,
TargetSiteConfigResponse, TargetSiteConfigResponse,
WorkflowEventSummary,
TargetSiteConfigUpdateRequest, TargetSiteConfigUpdateRequest,
UserSummary, UserSummary,
) )
@@ -90,6 +91,7 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ArticleSummary, ArticleSummary,
ArticleCreateResponse, ArticleCreateResponse,
ArticleListResponse, ArticleListResponse,
WorkflowEventSummary,
PlanSectionSummary, PlanSectionSummary,
PlanSummary, PlanSummary,
ResearchArtifactSummary, ResearchArtifactSummary,
+240 -1
View File
@@ -6,10 +6,14 @@ from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID, uuid4
from src.domain.contracts import ( from src.domain.contracts import (
ArticleSummary,
PublishingRules, PublishingRules,
PublishingStatus,
ArticleWorkflowStatus,
WorkflowEventSummary,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
TargetSiteConfig, TargetSiteConfig,
@@ -31,6 +35,7 @@ class BackendRepository:
self.dialect = "sqlite" if dsn.startswith("sqlite:///") else "postgres" self.dialect = "sqlite" if dsn.startswith("sqlite:///") else "postgres"
self.users = UsersRepository(self) self.users = UsersRepository(self)
self.target_sites = TargetSitesRepository(self) self.target_sites = TargetSitesRepository(self)
self.articles = ArticlesRepository(self)
self.script_config_versions = ScriptConfigVersionsRepository(self) self.script_config_versions = ScriptConfigVersionsRepository(self)
self.schema = SchemaRepository(self) self.schema = SchemaRepository(self)
@@ -384,6 +389,204 @@ class TargetSitesRepository:
return [_target_site_from_row(row) for row in rows] 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: class ScriptConfigVersionsRepository:
def __init__(self, repository: BackendRepository) -> None: def __init__(self, repository: BackendRepository) -> None:
self._repository = repository 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]: def _plain_row(row: Any) -> dict[str, Any]:
if isinstance(row, sqlite3.Row): if isinstance(row, sqlite3.Row):
result = dict(row) result = dict(row)
@@ -733,3 +966,9 @@ def _datetime_value(value: datetime | None) -> str | None:
if value is None: if value is None:
return None return None
return value.isoformat() return value.isoformat()
def _article_status_value(value: ArticleWorkflowStatus | None) -> str | None:
if value is None:
return None
return value.value
@@ -2,20 +2,25 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, HTTPException, status
from src.application.articles import ( from src.application.articles import (
build_placeholder_article_response, create_article,
get_article_detail,
list_articles,
build_placeholder_plan_approval_response, build_placeholder_plan_approval_response,
) )
from src.domain.auth import EDITOR_OR_ADMIN_ROLES from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import ( from src.domain.contracts import (
ArticleCreateRequest, ArticleCreateRequest,
ArticleCreateResponse, ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
CurrentUser, CurrentUser,
ReviewActionResponse, 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"]) router = APIRouter(prefix="/api", tags=["articles"])
@@ -26,11 +31,48 @@ router = APIRouter(prefix="/api", tags=["articles"])
response_model=ArticleCreateResponse, response_model=ArticleCreateResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
def create_article( def post_article(
request: ArticleCreateRequest, 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: ) -> 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( @router.post(
@@ -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()
+1
View File
@@ -6,6 +6,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"test:roles": "node tests/role-navigation.test.mjs", "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" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -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 <ArticleDetailPage articleId={articleId} />;
}
@@ -0,0 +1,3 @@
import NewArticlePage from "@/pages/article-new";
export { NewArticlePage as default };
+135
View File
@@ -76,6 +76,141 @@ main {
text-decoration: none; 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) { @media (max-width: 640px) {
main { main {
padding: 28px; padding: 28px;
+2 -21
View File
@@ -1,22 +1,3 @@
import type { ArticleListResponse } from "@pipeline/shared"; import DashboardPage from "@/pages/dashboard";
import { RoleNavigation } from "@/widgets/role-navigation"; export { DashboardPage as default };
const initialArticles: ArticleListResponse = {
articles: [],
};
export default function HomePage() {
return (
<main>
<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,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, TargetSiteConfig>,
): 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),
}));
}
@@ -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 <p className="emptyState">No article workflows yet.</p>;
}
return (
<table className="table">
<thead>
<tr>
<th>Article title</th>
<th>Target website</th>
<th>Status</th>
<th>Assigned editor</th>
<th>Last updated</th>
<th>Next action</th>
<th>Publishing status</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<Link href={`/articles/${row.id}`}>{row.workingTitle || row.briefDescription}</Link>
</td>
<td>{row.targetSiteName}</td>
<td>{row.status}</td>
<td>{row.assignedEditor}</td>
<td>{new Date(row.updatedAt).toLocaleString()}</td>
<td>{row.nextAction}</td>
<td>{row.publishingStatus}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -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),
};
}
@@ -0,0 +1,52 @@
import type { DetailSummary } from "./model";
type DetailShellProps = {
summary: DetailSummary;
};
export function ArticleDetailShell({ summary }: DetailShellProps) {
const timeline = summary.timeline;
return (
<section>
<h2>Article detail</h2>
<dl className="detailGrid">
<div>
<dt>Article ID</dt>
<dd>{summary.articleId}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{summary.status}</dd>
</div>
<div>
<dt>Publishing status</dt>
<dd>{summary.publishingStatus}</dd>
</div>
<div>
<dt>Target site</dt>
<dd>{summary.targetSite}</dd>
</div>
<div>
<dt>Last updated</dt>
<dd>{new Date(summary.updatedAt).toLocaleString()}</dd>
</div>
</dl>
<h3>Brief</h3>
<p>{summary.briefDescription}</p>
<h3>Workflow timeline</h3>
<ul className="timeline">
{timeline.map((event) => (
<li key={event.id}>
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
</li>
))}
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
</ul>
</section>
);
}
@@ -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<string, string>;
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;
}
@@ -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<NewArticleDraft>(() => initialDraft(sites));
const [errors, setErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false);
const canSubmit = useMemo(
() => !isSubmitting && sites.length > 0,
[isSubmitting, sites.length],
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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<K extends keyof NewArticleDraft>(field: K, value: NewArticleDraft[K]) {
setValues((current) => ({ ...current, [field]: value }));
}
return (
<form className="articleForm" onSubmit={handleSubmit}>
<h2>Create article brief</h2>
{formError ? <p className="formError">Error: {formError}</p> : null}
<label htmlFor="targetSiteId">Target site</label>
<select
id="targetSiteId"
value={values.targetSiteId}
onChange={(event) => updateField("targetSiteId", event.target.value)}
>
<option value="">Select a site</option>
{sites.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
<label htmlFor="briefDescription">Brief description</label>
<textarea
id="briefDescription"
value={values.briefDescription}
onChange={(event) => updateField("briefDescription", event.target.value)}
placeholder="Paste editor brief..."
/>
{errors.briefDescription ? (
<p className="fieldError">{errors.briefDescription}</p>
) : null}
<label htmlFor="workingTitle">Working title (optional)</label>
<input
id="workingTitle"
type="text"
value={values.workingTitle}
onChange={(event) => updateField("workingTitle", event.target.value)}
placeholder="Optional working title"
/>
<label htmlFor="contentType">Content type</label>
<input
id="contentType"
type="text"
value={values.contentType}
onChange={(event) => updateField("contentType", event.target.value)}
/>
{errors.contentType ? <p className="fieldError">{errors.contentType}</p> : null}
<label htmlFor="primaryKeyword">Primary keyword (optional)</label>
<input
id="primaryKeyword"
type="text"
value={values.primaryKeyword}
onChange={(event) => updateField("primaryKeyword", event.target.value)}
/>
<div className="formActions">
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Creating..." : "Create article"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,55 @@
import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation";
import {
buildDetailSummary,
} from "@/features/article-detail/model";
import { ArticleDetailShell } from "@/features/article-detail/ui";
import { fetchArticleDetail } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
type ArticleDetailPageProps = {
articleId: string;
};
export default async function ArticleDetailPage({
articleId,
}: ArticleDetailPageProps) {
try {
const detail = await fetchArticleDetail(articleId);
const summary = buildDetailSummary(detail);
return (
<main>
<header className="pageHeader">
<div>
<h1>Article detail</h1>
<p>{summary.status}</p>
</div>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<ArticleDetailShell summary={summary} />
</section>
</main>
);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return (
<main>
<header className="pageHeader">
<h1>Article not found</h1>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<p>The requested article does not exist.</p>
</section>
</main>
);
}
throw error;
}
}
@@ -0,0 +1,27 @@
import type { TargetSiteConfigResponse } from "@pipeline/shared";
import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation";
import { NewArticleForm } from "@/features/article-intake/ui";
import { fetchTargetSites } from "@/shared/pipeline-api";
export default async function NewArticlePage() {
const sites: TargetSiteConfigResponse[] = await fetchTargetSites();
const targetSites = sites.map((site) => site.site);
return (
<main>
<header className="pageHeader">
<div>
<h1>New Article</h1>
<p>Draft a new editorial brief for an existing site.</p>
</div>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<NewArticleForm sites={targetSites} />
</section>
</main>
);
}
@@ -0,0 +1,37 @@
import Link from "next/link";
import type { ArticleListResponse, TargetSiteConfigResponse } from "@pipeline/shared";
import { RoleNavigation } from "@/widgets/role-navigation";
import { buildDashboardRows } from "@/features/article-dashboard/model";
import { DashboardTable } from "@/features/article-dashboard/ui";
import { fetchArticles, fetchTargetSites } from "@/shared/pipeline-api";
export default async function DashboardPage() {
const [articlesResponse, sitesResponse]: [
ArticleListResponse,
TargetSiteConfigResponse[],
] = await Promise.all([fetchArticles(), fetchTargetSites()]);
const rows = buildDashboardRows(
articlesResponse.articles,
sitesResponse.map((site) => site.site),
);
return (
<main>
<header className="pageHeader">
<div>
<h1>AI Content Pipeline</h1>
<p>{rows.length} active article workflows</p>
</div>
<Link href="/articles/new">New article</Link>
<span className="roleBadge">Editor demo</span>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<DashboardTable rows={rows} />
</section>
</main>
);
}
+148
View File
@@ -0,0 +1,148 @@
import type {
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
CurrentUserResponse,
TargetSiteConfigResponse,
} from "@pipeline/shared";
export const DEMO_EDITOR_EMAIL = "editor@example.com";
const DEFAULT_LOCAL_BACKEND_URL = "http://localhost:8000";
export type ApiErrorPayload = {
status: number;
message: string;
detail?: unknown;
};
export class ApiError extends Error {
status: number;
detail?: unknown;
constructor(payload: ApiErrorPayload) {
super(payload.message);
this.name = "ApiError";
this.status = payload.status;
this.detail = payload.detail;
}
}
function resolveBackendBaseUrl(): string {
if (typeof window === "undefined") {
return process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_LOCAL_BACKEND_URL;
}
return (
process.env.NEXT_PUBLIC_BACKEND_URL ??
process.env.BACKEND_URL ??
""
);
}
function buildApiUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const base = resolveBackendBaseUrl();
if (!base || base === "/") {
return normalizedPath;
}
return `${base.replace(/\/$/, "")}${normalizedPath}`;
}
async function parseBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const isFormData = options.body instanceof FormData;
const response = await fetch(buildApiUrl(path), {
...options,
headers: {
...(!isFormData
? { "Content-Type": "application/json" }
: {}),
"X-Demo-User-Email": DEMO_EDITOR_EMAIL,
...(options.headers ?? {}),
},
cache: options.cache ?? "no-store",
});
const body = await parseBody(response);
if (!response.ok) {
const message =
typeof body === "object" && body !== null && "detail" in body
? String((body as { detail?: unknown }).detail)
: response.statusText;
throw new ApiError({
status: response.status,
message,
detail: body,
});
}
return body as T;
}
export function apiGet<T>(path: string): Promise<T> {
return requestJson<T>(path, { method: "GET" });
}
export function apiPost<TRequest, TResponse>(
path: string,
body: TRequest,
): Promise<TResponse> {
return requestJson<TResponse>(path, {
method: "POST",
body: JSON.stringify(body),
});
}
export function apiPatch<TRequest, TResponse>(
path: string,
body: TRequest,
): Promise<TResponse> {
return requestJson<TResponse>(path, {
method: "PATCH",
body: JSON.stringify(body),
});
}
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
return apiGet("/api/me");
}
export function fetchTargetSites(): Promise<TargetSiteConfigResponse[]> {
return apiGet<TargetSiteConfigResponse[]>("/api/sites");
}
export function fetchArticles(): Promise<ArticleListResponse> {
return apiGet<ArticleListResponse>("/api/articles");
}
export function fetchArticleDetail(
articleId: string,
): Promise<ArticleDetailResponse> {
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
}
export function createArticle(
request: ArticleCreateRequest,
): Promise<ArticleCreateResponse> {
return apiPost<ArticleCreateRequest, ArticleCreateResponse>(
"/api/articles",
request,
);
}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
testDir,
"../src/features/article-dashboard/model.ts",
);
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildDashboardRows } = moduleExports;
const rows = buildDashboardRows(
[
{
id: "11111111-1111-1111-1111-111111111111",
target_site_id: "22222222-2222-2222-2222-222222222222",
status: "ARTICLE_BRIEF_CREATED",
publishing_status: "PUBLISH_NOT_STARTED",
brief_description: "Build AI article intake shell",
working_title: "AI Intake Shell",
language: "en",
content_type: "longform_guide",
primary_keyword: "AI workflow",
assigned_editor_id: null,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
],
[
{
id: "22222222-2222-2222-2222-222222222222",
name: "Editorial site",
slug: "editorial-site",
publishing_type: "git_next",
default_language: "en",
brand_voice: "Balanced",
audience: "Editor teams",
seo_rules: {},
visual_rules: {},
source_rules: {},
publishing_rules: {},
active_script_config_version_id: null,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
],
);
assert.equal(rows.length, 1, "one row should be built");
assert.equal(rows[0].targetSiteName, "Editorial site", "target site name should map");
assert.equal(rows[0].nextAction, "Create plan", "next action should match status mapping");
assert.equal(rows[0].assignedEditor, "Unassigned", "missing editor id should render Unassigned");
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
testDir,
"../src/features/article-intake/model.ts",
);
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const validateArticleDraft = moduleExports.validateArticleDraft;
const initialDraft = moduleExports.initialDraft;
const draft = {
targetSiteId: "",
briefDescription: "Keep this text intact",
workingTitle: "",
contentType: "longform_guide",
primaryKeyword: "",
};
const errors = validateArticleDraft(draft);
assert.ok(errors.targetSiteId, "validation should require target site");
assert.equal(
draft.briefDescription,
"Keep this text intact",
"brief description value should remain in form state when validation fails",
);
const fallbackDraft = initialDraft([]);
assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved");
+1
View File
@@ -6,6 +6,7 @@ services:
environment: environment:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
BACKEND_URL: http://backend:8000 BACKEND_URL: http://backend:8000
NEXT_PUBLIC_BACKEND_URL: http://backend:8000
ports: ports:
- "${FRONTEND_PORT:-3000}:3000" - "${FRONTEND_PORT:-3000}:3000"
depends_on: depends_on:
+188 -2
View File
@@ -386,6 +386,13 @@
} }
], ],
"default": null "default": null
},
"workflow_events": {
"items": {
"$ref": "#/components/schemas/WorkflowEventSummary"
},
"title": "Workflow Events",
"type": "array"
} }
}, },
"required": [ "required": [
@@ -1998,6 +2005,79 @@
], ],
"title": "ValidationError", "title": "ValidationError",
"type": "object" "type": "object"
},
"WorkflowEventSummary": {
"additionalProperties": false,
"properties": {
"actor_user_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Actor User Id"
},
"article_id": {
"format": "uuid",
"title": "Article Id",
"type": "string"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"event_type": {
"minLength": 1,
"title": "Event Type",
"type": "string"
},
"from_status": {
"anyOf": [
{
"$ref": "#/components/schemas/ArticleWorkflowStatus"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"payload": {
"additionalProperties": true,
"title": "Payload",
"type": "object"
},
"to_status": {
"anyOf": [
{
"$ref": "#/components/schemas/ArticleWorkflowStatus"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"id",
"article_id",
"event_type",
"created_at"
],
"title": "WorkflowEventSummary",
"type": "object"
} }
} }
}, },
@@ -2008,8 +2088,55 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
"/api/articles": { "/api/articles": {
"get": {
"operationId": "list_articles_route_api_articles_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/ArticleListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "List Articles Route",
"tags": [
"articles"
]
},
"post": { "post": {
"operationId": "create_article_api_articles_post", "operationId": "post_article_api_articles_post",
"parameters": [ "parameters": [
{ {
"in": "header", "in": "header",
@@ -2060,7 +2187,66 @@
"description": "Validation Error" "description": "Validation Error"
} }
}, },
"summary": "Create Article", "summary": "Post Article",
"tags": [
"articles"
]
}
},
"/api/articles/{article_id}": {
"get": {
"operationId": "get_article_api_articles__article_id__get",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArticleDetailResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Article",
"tags": [ "tags": [
"articles" "articles"
] ]
+12
View File
@@ -57,6 +57,7 @@ export type ArticleDetailResponse = {
research_manifests?: ResearchArtifactManifestSummary[]; research_manifests?: ResearchArtifactManifestSummary[];
reviews?: ReviewSummary[]; reviews?: ReviewSummary[];
target_site?: TargetSiteConfig | null; target_site?: TargetSiteConfig | null;
workflow_events?: WorkflowEventSummary[];
}; };
export type ArticleListResponse = { export type ArticleListResponse = {
@@ -335,3 +336,14 @@ export type ValidationError = {
msg: string; msg: string;
type: string; type: string;
}; };
export type WorkflowEventSummary = {
actor_user_id?: string | null;
article_id: string;
created_at: string;
event_type: string;
from_status?: ArticleWorkflowStatus | null;
id: string;
payload?: Record<string, unknown>;
to_status?: ArticleWorkflowStatus | null;
};
+43 -13
View File
@@ -30,13 +30,13 @@ Development description: Build the first user-visible workflow slice where an Ed
## Acceptance Criteria ## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing API behavior test for creating an article and seeing `ARTICLE_BRIEF_CREATED`; add UI tests after API green and record evidence in `Result`. - [x] TDD pre-requirement: before implementation, write one failing API behavior test for creating an article and seeing `ARTICLE_BRIEF_CREATED`; add UI tests after API green and record evidence in `Result`.
- [ ] Editor can create an article brief for a configured target site. - [x] Editor can create an article brief for a configured target site.
- [ ] Created article appears in `GET /api/articles`. - [x] Created article appears in `GET /api/articles`.
- [ ] Article detail includes target site summary and workflow history. - [x] Article detail includes target site summary and workflow history.
- [ ] Article creation writes a workflow event with actor and timestamp. - [x] Article creation writes a workflow event with actor and timestamp.
- [ ] Invalid target site returns a validation error. - [x] Invalid target site returns a validation error.
- [ ] Frontend form shows validation errors without losing typed brief text. - [x] Frontend form shows validation errors without losing typed brief text.
## Verification ## Verification
@@ -46,9 +46,39 @@ Development description: Build the first user-visible workflow slice where an Ed
## Result ## Result
- Status: Pending execution. - Status: Completed end-to-end for Task 005 with backend + frontend shell implemented and validated.
- TDD plan: To be filled during execution. - TDD plan:
- Red evidence: To be filled during execution. - API first, UI later: start with one public FastAPI behavior test for the Editor article-intake slice.
- Green evidence: To be filled during execution. - Red slice: with the demo Editor header, create an article for a configured target site through `POST /api/articles`; then verify the created article is persisted in `GET /api/articles` and visible in `GET /api/articles/{article_id}` with target site summary and an `ARTICLE_CREATED` workflow event.
- Refactor notes: To be filled during execution. - Green implementation to follow: add article persistence, list/detail HTTP handlers, target-site attachment, and workflow event writes while preserving backend layers.
- Verification output: To be filled during execution. - UI tests come after the API behavior is green.
- Red evidence:
- Added `apps/backend/tests/integration/test_article_intake_public_api.py`.
- Initial run:
- Command: `/private/tmp/pupline-backend-venv/bin/python apps/backend/tests/integration/test_article_intake_public_api.py`
- Result: failing as expected (`GET /api/articles` returned 405 and `GET /api/articles/{article_id}` returned 404 before implementation).
- Green evidence:
- Created endpoints and repository implementations:
- `POST /api/articles` persisted `ArticleSummary` and created `ARTICLE_CREATED` workflow event.
- `GET /api/articles` returns persisted list.
- `GET /api/articles/{article_id}` returns detail with target site and workflow timeline.
- Validation:
- `POST /api/articles` with unknown `target_site_id` returns `400` with detail `Invalid target_site_id`.
- Refactor notes: FSD slicing was applied:
- `features/` contains article-specific logic (`validation`, row/timeline transforms, form/dashboard models, and UI).
- `pages/` composes feature blocks into route-level screens.
- `app/` contains router entry points.
- `shared/` stores cross-layer API client abstractions.
- Verification output:
- `/private/tmp/pupline-backend-venv/bin/python apps/backend/tests/integration/test_article_intake_public_api.py` (all cases green after implementation, including invalid target_site_id)
- `/private/tmp/pupline-backend-venv/bin/python apps/backend/tests/contracts/test_public_openapi_contract.py`
- `/private/tmp/pupline-backend-venv/bin/python apps/backend/tests/contracts/test_generated_contract_artifacts.py`
- `/private/tmp/pupline-backend-venv/bin/python apps/backend/tests/contracts/test_domain_contracts.py`
- `/private/tmp/pupline-backend-venv/bin/python apps/backend/tests/integration/test_auth_authorization_public_api.py`
- Frontend checks:
- `pnpm --filter @pipeline/frontend typecheck`
- `pnpm --filter @pipeline/frontend test:ui`
- `apps/frontend/tests/article_form_validation.test.mjs` confirms brief text stays in state when validation fails on missing target site.
- Docker smoke attempt:
- `docker compose up -d backend frontend`
- blocked by environment: permission denied on Docker daemon socket (`/Users/gavrilovdev/.colima/default/docker.sock`).