From b8937d2864fb7c1cefd9b228e261c646342c5b7a Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 22 May 2026 20:31:17 +0300 Subject: [PATCH] fix(task-024): make demo browser flow reachable --- .../src/infrastructure/repositories.py | 4 +- apps/backend/src/presentation/main.py | 17 ++++++ .../test_demo_browser_cors_public_api.py | 56 +++++++++++++++++++ apps/frontend/next.config.mjs | 9 +++ apps/frontend/src/app/admin/jobs/page.tsx | 6 +- apps/frontend/src/app/admin/scripts/page.tsx | 6 +- apps/frontend/src/app/admin/sites/page.tsx | 6 +- .../frontend/src/app/admin/workflows/page.tsx | 6 +- .../[articleId]/boundary-questions/page.tsx | 8 ++- .../articles/[articleId]/evidence/page.tsx | 8 ++- .../src/app/articles/[articleId]/page.tsx | 15 +++-- .../app/articles/[articleId]/plans/page.tsx | 8 ++- .../articles/[articleId]/research/page.tsx | 8 ++- apps/frontend/src/app/articles/new/page.tsx | 6 +- apps/frontend/src/app/layout.tsx | 2 + apps/frontend/src/app/page.tsx | 6 +- docker-compose.yml | 1 - tasks/024-demo-browser-api-reachability.md | 30 ++++++++++ 18 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 apps/backend/tests/integration/test_demo_browser_cors_public_api.py create mode 100644 tasks/024-demo-browser-api-reachability.md diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index 8e8ce5e..4b2266c 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -483,8 +483,6 @@ class ArticlesRepository: {placeholder}, {placeholder}, {placeholder}, - {placeholder}, - {placeholder}, {placeholder}{json_cast}, {placeholder}, {placeholder}, @@ -493,6 +491,8 @@ class ArticlesRepository: {placeholder}, {placeholder}, {placeholder}, + {placeholder}, + {placeholder}, {placeholder} ) """ diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index f182486..407d57c 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -1,8 +1,10 @@ +import os from typing import Any from fastapi import FastAPI from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware from src.domain.contracts.openapi import inject_contract_schemas from src.presentation.routes.agent_jobs import ( @@ -24,6 +26,21 @@ from src.presentation.routes.workflow_templates import router as workflow_templa app = FastAPI(title="AI Content Pipeline Backend") +_allowed_origins = [ + origin.strip() + for origin in os.getenv( + "PIPELINE_CORS_ALLOW_ORIGINS", + "http://localhost:3000,http://localhost:13300", + ).split(",") + if origin.strip() +] +app.add_middleware( + CORSMiddleware, + allow_origins=_allowed_origins, + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(auth_router) app.include_router(articles_router) app.include_router(assets_router) diff --git a/apps/backend/tests/integration/test_demo_browser_cors_public_api.py b/apps/backend/tests/integration/test_demo_browser_cors_public_api.py new file mode 100644 index 0000000..d3d5914 --- /dev/null +++ b/apps/backend/tests/integration/test_demo_browser_cors_public_api.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(BACKEND_ROOT)) + +from src.application.seed_data import seed_reference_data # noqa: E402 +from src.infrastructure.repositories import open_backend_repository # noqa: E402 +from src.presentation.dependencies import get_repository # noqa: E402 +from src.presentation.main import app # noqa: E402 + + +class DemoBrowserCorsPublicApiTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-browser-cors.db'}" + self.repository = open_backend_repository(dsn) + self.repository.setup() + seed_reference_data(self.repository) + app.dependency_overrides[get_repository] = lambda: self.repository + self.client = TestClient(app) + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.tmp_dir.cleanup() + + def test_demo_frontend_origin_can_call_backend_with_demo_user_header(self) -> None: + response = self.client.options( + "/api/admin/workflows", + headers={ + "Origin": "http://localhost:13300", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "X-Demo-User-Email", + }, + ) + + self.assertEqual(200, response.status_code, response.text) + self.assertEqual( + "http://localhost:13300", + response.headers.get("access-control-allow-origin"), + ) + self.assertIn( + "X-Demo-User-Email", + response.headers.get("access-control-allow-headers", ""), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs index 3025cc3..4fcd94a 100644 --- a/apps/frontend/next.config.mjs +++ b/apps/frontend/next.config.mjs @@ -7,6 +7,15 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const nextConfig = { reactStrictMode: true, outputFileTracingRoot: repoRoot, + async rewrites() { + const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000"; + return [ + { + source: "/api/:path*", + destination: `${backendUrl}/api/:path*`, + }, + ]; + }, turbopack: { root: repoRoot, }, diff --git a/apps/frontend/src/app/admin/jobs/page.tsx b/apps/frontend/src/app/admin/jobs/page.tsx index f99ac85..34fced4 100644 --- a/apps/frontend/src/app/admin/jobs/page.tsx +++ b/apps/frontend/src/app/admin/jobs/page.tsx @@ -1,3 +1,7 @@ import AdminJobsPage from "@/pages/admin-jobs"; -export default AdminJobsPage; +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} diff --git a/apps/frontend/src/app/admin/scripts/page.tsx b/apps/frontend/src/app/admin/scripts/page.tsx index f0da7c6..88951f8 100644 --- a/apps/frontend/src/app/admin/scripts/page.tsx +++ b/apps/frontend/src/app/admin/scripts/page.tsx @@ -1,3 +1,7 @@ import AdminScriptsPage from "@/pages/admin-scripts"; -export { AdminScriptsPage as default }; +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} diff --git a/apps/frontend/src/app/admin/sites/page.tsx b/apps/frontend/src/app/admin/sites/page.tsx index d912481..6c8eec3 100644 --- a/apps/frontend/src/app/admin/sites/page.tsx +++ b/apps/frontend/src/app/admin/sites/page.tsx @@ -1,3 +1,7 @@ import AdminSitesPage from "@/pages/admin-sites"; -export default AdminSitesPage; +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} diff --git a/apps/frontend/src/app/admin/workflows/page.tsx b/apps/frontend/src/app/admin/workflows/page.tsx index 9bc4239..31fa600 100644 --- a/apps/frontend/src/app/admin/workflows/page.tsx +++ b/apps/frontend/src/app/admin/workflows/page.tsx @@ -1,3 +1,7 @@ import AdminWorkflowsPage from "@/pages/admin-workflows"; -export { AdminWorkflowsPage as default }; +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} diff --git a/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx b/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx index 80d14f9..7f6d96d 100644 --- a/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx +++ b/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx @@ -1,14 +1,16 @@ import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions"; +export const dynamic = "force-dynamic"; + type DynamicArticleBoundaryQuestionsPageProps = { - params: { + params: Promise<{ articleId: string; - }; + }>; }; export default async function DynamicArticleBoundaryQuestionsPage({ params, }: DynamicArticleBoundaryQuestionsPageProps) { - const { articleId } = params; + const { articleId } = await params; return ; } diff --git a/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx b/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx index 68596fe..29bac9a 100644 --- a/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx +++ b/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx @@ -1,14 +1,16 @@ import ArticleEvidencePage from "@/pages/article-evidence"; +export const dynamic = "force-dynamic"; + type DynamicArticleEvidencePageProps = { - params: { + params: Promise<{ articleId: string; - }; + }>; }; export default async function DynamicArticleEvidencePage({ params, }: DynamicArticleEvidencePageProps) { - const { articleId } = params; + const { articleId } = await params; return ; } diff --git a/apps/frontend/src/app/articles/[articleId]/page.tsx b/apps/frontend/src/app/articles/[articleId]/page.tsx index 837208a..2f82280 100644 --- a/apps/frontend/src/app/articles/[articleId]/page.tsx +++ b/apps/frontend/src/app/articles/[articleId]/page.tsx @@ -1,19 +1,22 @@ import ArticleDetailPage from "@/pages/article-detail"; +export const dynamic = "force-dynamic"; + type DynamicArticleDetailPageProps = { - params: { + params: Promise<{ articleId: string; - }; - searchParams?: { + }>; + searchParams?: Promise<{ role?: string; - }; + }>; }; export default async function DynamicArticleDetailPage({ params, searchParams, }: DynamicArticleDetailPageProps) { - const { articleId } = params; - const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor"; + const { articleId } = await params; + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor"; return ; } diff --git a/apps/frontend/src/app/articles/[articleId]/plans/page.tsx b/apps/frontend/src/app/articles/[articleId]/plans/page.tsx index 5068fe8..2797bef 100644 --- a/apps/frontend/src/app/articles/[articleId]/plans/page.tsx +++ b/apps/frontend/src/app/articles/[articleId]/plans/page.tsx @@ -1,14 +1,16 @@ import ArticlePlansPage from "@/pages/article-plans"; +export const dynamic = "force-dynamic"; + type DynamicArticlePlansPageProps = { - params: { + params: Promise<{ articleId: string; - }; + }>; }; export default async function DynamicArticlePlansPage({ params, }: DynamicArticlePlansPageProps) { - const { articleId } = params; + const { articleId } = await params; return ; } diff --git a/apps/frontend/src/app/articles/[articleId]/research/page.tsx b/apps/frontend/src/app/articles/[articleId]/research/page.tsx index ad7825c..f59eede 100644 --- a/apps/frontend/src/app/articles/[articleId]/research/page.tsx +++ b/apps/frontend/src/app/articles/[articleId]/research/page.tsx @@ -1,14 +1,16 @@ import ArticleResearchPage from "@/pages/article-research"; +export const dynamic = "force-dynamic"; + type DynamicArticleResearchPageProps = { - params: { + params: Promise<{ articleId: string; - }; + }>; }; export default async function DynamicArticleResearchPage({ params, }: DynamicArticleResearchPageProps) { - const { articleId } = params; + const { articleId } = await params; return ; } diff --git a/apps/frontend/src/app/articles/new/page.tsx b/apps/frontend/src/app/articles/new/page.tsx index 40f7d7a..26bad12 100644 --- a/apps/frontend/src/app/articles/new/page.tsx +++ b/apps/frontend/src/app/articles/new/page.tsx @@ -1,3 +1,7 @@ import NewArticlePage from "@/pages/article-new"; -export { NewArticlePage as default }; +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index ea66226..c7ccc8f 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -6,6 +6,8 @@ export const metadata = { description: "Internal editorial workflow foundation", }; +export const dynamic = "force-dynamic"; + export default function RootLayout({ children, }: Readonly<{ diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index 6476610..6464e1e 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,3 +1,7 @@ import DashboardPage from "@/pages/dashboard"; -export { DashboardPage as default }; +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} diff --git a/docker-compose.yml b/docker-compose.yml index 385d47f..7fb32e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: environment: NEXT_TELEMETRY_DISABLED: "1" BACKEND_URL: http://backend:8000 - NEXT_PUBLIC_BACKEND_URL: http://backend:8000 ports: - "${FRONTEND_PORT:-3000}:3000" depends_on: diff --git a/tasks/024-demo-browser-api-reachability.md b/tasks/024-demo-browser-api-reachability.md new file mode 100644 index 0000000..746f604 --- /dev/null +++ b/tasks/024-demo-browser-api-reachability.md @@ -0,0 +1,30 @@ +# Task 024: Demo Browser API Reachability + +Development description: Make the Docker Compose demo browser actions call the backend through a browser-reachable URL with CORS enabled, so Admin workflow editing and Editor article creation work during a live demo. + +## Acceptance Criteria + +- [x] Frontend server-side rendering still uses the internal Docker backend URL. +- [x] Browser-side frontend actions use a localhost backend URL mapped by Docker Compose. +- [x] Backend allows the demo frontend origin and `X-Demo-User-Email` header. +- [x] Admin workflow `Open` action works in the browser demo stack. +- [x] Editor article creation works in the browser demo stack. + +## Result + +- Status: Implemented. +- Added backend CORS middleware for demo frontend origins, configurable through `PIPELINE_CORS_ALLOW_ORIGINS`. +- Updated Docker Compose so `NEXT_PUBLIC_BACKEND_URL` points to `http://localhost:${BACKEND_PORT:-8000}` while `BACKEND_URL` remains `http://backend:8000` for server-side rendering. +- Switched browser-side API calls to same-origin `/api/*` through Next rewrites, keeping `BACKEND_URL=http://backend:8000` inside Docker. +- Fixed the Postgres article insert path by applying JSON casting to the workflow template snapshot column, not `publishing_status`. +- Updated Next 16 dynamic route adapters to await `params` and marked live-data pages dynamic. +- Added a public API integration test covering preflight for `http://localhost:13300` with `X-Demo-User-Email`. +- Verification: + - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 1 test ... OK`. + - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 5 tests ... OK`. + - `pnpm typecheck` -> passed. + - `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh` -> `public health smoke ok`. + - Browser smoke on Docker demo stack: + - `/admin/workflows` -> Open seeded workflow -> stage editor loaded with 7 stages and no error. + - `/articles/new` -> create article with active workflow -> redirect to article detail. + - Article detail -> shows workflow template name, slug, version, 7-stage snapshot, and timeline.