fix(task-024): make demo browser flow reachable
This commit is contained in:
@@ -483,8 +483,6 @@ class ArticlesRepository:
|
|||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
|
||||||
{placeholder},
|
|
||||||
{placeholder}{json_cast},
|
{placeholder}{json_cast},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
@@ -493,6 +491,8 @@ class ArticlesRepository:
|
|||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
{placeholder}
|
{placeholder}
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.openapi.utils import get_openapi
|
from fastapi.openapi.utils import get_openapi
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from src.domain.contracts.openapi import inject_contract_schemas
|
from src.domain.contracts.openapi import inject_contract_schemas
|
||||||
from src.presentation.routes.agent_jobs import (
|
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")
|
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(auth_router)
|
||||||
app.include_router(articles_router)
|
app.include_router(articles_router)
|
||||||
app.include_router(assets_router)
|
app.include_router(assets_router)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -7,6 +7,15 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
outputFileTracingRoot: repoRoot,
|
outputFileTracingRoot: repoRoot,
|
||||||
|
async rewrites() {
|
||||||
|
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${backendUrl}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: repoRoot,
|
root: repoRoot,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
import AdminJobsPage from "@/pages/admin-jobs";
|
import AdminJobsPage from "@/pages/admin-jobs";
|
||||||
|
|
||||||
export default AdminJobsPage;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminJobsPage />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
import AdminScriptsPage from "@/pages/admin-scripts";
|
import AdminScriptsPage from "@/pages/admin-scripts";
|
||||||
|
|
||||||
export { AdminScriptsPage as default };
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminScriptsPage />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
import AdminSitesPage from "@/pages/admin-sites";
|
import AdminSitesPage from "@/pages/admin-sites";
|
||||||
|
|
||||||
export default AdminSitesPage;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminSitesPage />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
import AdminWorkflowsPage from "@/pages/admin-workflows";
|
import AdminWorkflowsPage from "@/pages/admin-workflows";
|
||||||
|
|
||||||
export { AdminWorkflowsPage as default };
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminWorkflowsPage />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
|
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DynamicArticleBoundaryQuestionsPageProps = {
|
type DynamicArticleBoundaryQuestionsPageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
articleId: string;
|
articleId: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DynamicArticleBoundaryQuestionsPage({
|
export default async function DynamicArticleBoundaryQuestionsPage({
|
||||||
params,
|
params,
|
||||||
}: DynamicArticleBoundaryQuestionsPageProps) {
|
}: DynamicArticleBoundaryQuestionsPageProps) {
|
||||||
const { articleId } = params;
|
const { articleId } = await params;
|
||||||
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
|
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import ArticleEvidencePage from "@/pages/article-evidence";
|
import ArticleEvidencePage from "@/pages/article-evidence";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DynamicArticleEvidencePageProps = {
|
type DynamicArticleEvidencePageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
articleId: string;
|
articleId: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DynamicArticleEvidencePage({
|
export default async function DynamicArticleEvidencePage({
|
||||||
params,
|
params,
|
||||||
}: DynamicArticleEvidencePageProps) {
|
}: DynamicArticleEvidencePageProps) {
|
||||||
const { articleId } = params;
|
const { articleId } = await params;
|
||||||
return <ArticleEvidencePage articleId={articleId} />;
|
return <ArticleEvidencePage articleId={articleId} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import ArticleDetailPage from "@/pages/article-detail";
|
import ArticleDetailPage from "@/pages/article-detail";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DynamicArticleDetailPageProps = {
|
type DynamicArticleDetailPageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
articleId: string;
|
articleId: string;
|
||||||
};
|
}>;
|
||||||
searchParams?: {
|
searchParams?: Promise<{
|
||||||
role?: string;
|
role?: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DynamicArticleDetailPage({
|
export default async function DynamicArticleDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: DynamicArticleDetailPageProps) {
|
}: DynamicArticleDetailPageProps) {
|
||||||
const { articleId } = params;
|
const { articleId } = await params;
|
||||||
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
|
||||||
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import ArticlePlansPage from "@/pages/article-plans";
|
import ArticlePlansPage from "@/pages/article-plans";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DynamicArticlePlansPageProps = {
|
type DynamicArticlePlansPageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
articleId: string;
|
articleId: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DynamicArticlePlansPage({
|
export default async function DynamicArticlePlansPage({
|
||||||
params,
|
params,
|
||||||
}: DynamicArticlePlansPageProps) {
|
}: DynamicArticlePlansPageProps) {
|
||||||
const { articleId } = params;
|
const { articleId } = await params;
|
||||||
return <ArticlePlansPage articleId={articleId} />;
|
return <ArticlePlansPage articleId={articleId} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import ArticleResearchPage from "@/pages/article-research";
|
import ArticleResearchPage from "@/pages/article-research";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DynamicArticleResearchPageProps = {
|
type DynamicArticleResearchPageProps = {
|
||||||
params: {
|
params: Promise<{
|
||||||
articleId: string;
|
articleId: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DynamicArticleResearchPage({
|
export default async function DynamicArticleResearchPage({
|
||||||
params,
|
params,
|
||||||
}: DynamicArticleResearchPageProps) {
|
}: DynamicArticleResearchPageProps) {
|
||||||
const { articleId } = params;
|
const { articleId } = await params;
|
||||||
return <ArticleResearchPage articleId={articleId} />;
|
return <ArticleResearchPage articleId={articleId} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
import NewArticlePage from "@/pages/article-new";
|
import NewArticlePage from "@/pages/article-new";
|
||||||
|
|
||||||
export { NewArticlePage as default };
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <NewArticlePage />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const metadata = {
|
|||||||
description: "Internal editorial workflow foundation",
|
description: "Internal editorial workflow foundation",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
import DashboardPage from "@/pages/dashboard";
|
import DashboardPage from "@/pages/dashboard";
|
||||||
|
|
||||||
export { DashboardPage as default };
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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:
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user