From f10a9c76db057a6ad2b82f3269d8d55ad6252fbd Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Mon, 22 Jun 2026 12:45:46 +0530 Subject: [PATCH 1/6] Fast Api --- .coveragerc | 4 + Dockerfile | 2 +- alembic/versions/025_pipeline_job_queue.py | 35 + docker-compose.prod.yml | 1 + docker-compose.pull.yml | 1 + docker-compose.yml | 3 + docker-entrypoint.sh | 24 +- requirements.txt | 1 + scripts/generate_openapi.py | 30 + scripts/local-prod.sh | 29 +- scripts/local-run.sh | 24 +- src/website_profiling/api/__init__.py | 0 src/website_profiling/api/deps.py | 19 + src/website_profiling/api/main.py | 110 + src/website_profiling/api/routers/__init__.py | 0 src/website_profiling/api/routers/alerts.py | 31 + src/website_profiling/api/routers/chat.py | 225 + src/website_profiling/api/routers/compare.py | 61 + src/website_profiling/api/routers/config.py | 263 + src/website_profiling/api/routers/content.py | 257 + src/website_profiling/api/routers/crawl.py | 40 + .../api/routers/dashboards.py | 147 + src/website_profiling/api/routers/filters.py | 53 + src/website_profiling/api/routers/health.py | 17 + .../api/routers/integrations.py | 550 ++ src/website_profiling/api/routers/issues.py | 148 + src/website_profiling/api/routers/keywords.py | 72 + src/website_profiling/api/routers/logs.py | 33 + .../api/routers/mcp_tools.py | 41 + src/website_profiling/api/routers/ollama.py | 65 + .../api/routers/page_coach.py | 48 + .../api/routers/page_markdown.py | 157 + src/website_profiling/api/routers/pipeline.py | 258 + .../api/routers/portfolio.py | 33 + .../api/routers/properties.py | 324 + src/website_profiling/api/routers/report.py | 83 + .../api/routers/report_audit_tool.py | 40 + .../api/routers/report_export.py | 106 + .../api/routers/report_portfolio.py | 54 + src/website_profiling/api/routers/schedule.py | 22 + src/website_profiling/api/schemas/__init__.py | 0 src/website_profiling/api/schemas/chat.py | 41 + src/website_profiling/api/schemas/pipeline.py | 155 + .../api/services/__init__.py | 0 .../api/services/portfolio_loader.py | 606 ++ .../api/services/report_loader.py | 382 ++ src/website_profiling/db/config_store.py | 39 + .../db/content_draft_store.py | 177 + src/website_profiling/db/dashboard_store.py | 116 + .../db/issue_status_store.py | 100 + src/website_profiling/db/markdown_store.py | 76 +- src/website_profiling/db/pipeline_jobs.py | 270 + src/website_profiling/db/portfolio_store.py | 36 + src/website_profiling/db/property_store.py | 216 + .../db/saved_filter_store.py | 58 + .../integrations/google/gsc_links_store.py | 31 + .../integrations/google/keyword_store.py | 48 +- .../google/page_snapshot_store.py | 75 +- .../integrations/google/store.py | 121 +- src/website_profiling/llm/ollama_catalog.py | 174 + .../tools/audit_tools/backlinks/backlinks.py | 8 +- src/website_profiling/worker/__init__.py | 0 src/website_profiling/worker/__main__.py | 7 + src/website_profiling/worker/loop.py | 52 + src/website_profiling/worker/runner.py | 134 + src/website_profiling/worker/signals.py | 34 + tests/README.md | 20 + tests/api/conftest.py | 56 + tests/api/test_api_integration.py | 357 + tests/api/test_content_drafts_list.py | 30 + tests/api/test_report_loader_list.py | 29 + tests/llm/test_ollama_catalog.py | 24 + tests/test_db_pipeline_jobs_unit.py | 301 + web/app/api/ai/fix-suggestion/route.ts | 82 +- web/app/api/alerts/check/route.ts | 65 +- web/app/api/app-settings/route.ts | 53 +- .../api/backlinks/competitor-import/route.ts | 70 +- .../api/backlinks/third-party-import/route.ts | 89 +- web/app/api/backlinks/velocity/route.ts | 38 +- web/app/api/chat/artifacts/[id]/route.ts | 94 +- web/app/api/chat/route.ts | 376 +- .../api/chat/sessions/[id]/messages/route.ts | 32 +- web/app/api/chat/sessions/[id]/route.ts | 53 +- web/app/api/chat/sessions/route.ts | 46 +- web/app/api/compare/export/route.ts | 68 +- web/app/api/content-drafts/[id]/route.ts | 71 +- web/app/api/content-drafts/route.ts | 44 +- web/app/api/content/analyze/route.ts | 95 +- web/app/api/content/score/route.ts | 86 +- web/app/api/content/wizard/route.ts | 81 +- web/app/api/crawl/browser-status/route.ts | 94 +- web/app/api/crawl/page-html/route.ts | 65 +- web/app/api/dashboards/[id]/route.ts | 94 +- web/app/api/dashboards/ai-generate/route.ts | 118 + web/app/api/dashboards/route.ts | 59 +- web/app/api/filters/route.ts | 51 +- web/app/api/health/route.ts | 16 +- web/app/api/integrations/bing/sync/route.ts | 63 +- .../integrations/google/credentials/route.ts | 55 +- .../google/credentials/upload/route.ts | 56 +- .../integrations/google/disconnect/route.ts | 16 +- .../google/keywords/by-page/route.ts | 80 +- .../google/keywords/expand/route.ts | 112 +- .../google/keywords/history/batch/route.ts | 100 +- .../google/keywords/history/route.ts | 52 +- .../google/keywords/planner/route.ts | 149 +- .../integrations/google/page-compare/route.ts | 222 +- .../google/page-data/history/route.ts | 68 +- .../integrations/google/page-data/route.ts | 58 +- .../google/page-live/history/route.ts | 50 +- .../integrations/google/page-live/route.ts | 112 +- .../integrations/google/properties/route.ts | 26 +- .../api/integrations/google/status/route.ts | 27 +- web/app/api/integrations/google/test/route.ts | 53 +- web/app/api/issues/action-plan/route.ts | 74 +- web/app/api/issues/fix-suggestion/route.ts | 79 +- web/app/api/issues/status/route.ts | 64 +- web/app/api/jobs/[id]/cancel/route.ts | 22 +- web/app/api/jobs/[id]/pause/route.ts | 20 +- web/app/api/jobs/[id]/resume/route.ts | 19 +- web/app/api/jobs/[id]/route.ts | 19 +- web/app/api/jobs/route.ts | 25 +- .../api/keywords/competitor-import/route.ts | 76 +- web/app/api/keywords/content-brief/route.ts | 63 +- web/app/api/links/page-coach/route.ts | 114 +- web/app/api/llm-config/route.ts | 63 +- web/app/api/logs/upload/route.ts | 74 +- web/app/api/mcp-tools/route.ts | 72 +- web/app/api/ollama/status/route.ts | 3 +- web/app/api/page-markdown/content/route.ts | 31 +- web/app/api/page-markdown/extract/route.ts | 45 +- web/app/api/page-markdown/route.ts | 52 +- web/app/api/page-markdown/runs/route.ts | 18 +- web/app/api/pipeline-config/route.ts | 112 +- web/app/api/portfolio/delete/route.ts | 46 +- .../api/properties/[id]/authorize/route.ts | 18 +- .../[id]/google/credentials/route.ts | 61 +- .../[id]/google/disconnect/route.ts | 23 +- .../[id]/google/links/import/route.ts | 109 +- .../[id]/google/links/status/route.ts | 74 +- .../[id]/google/properties/route.ts | 74 +- .../properties/[id]/google/status/route.ts | 41 +- .../api/properties/[id]/google/test/route.ts | 63 +- web/app/api/properties/[id]/ops/route.ts | 40 +- web/app/api/properties/[id]/preset/route.ts | 32 +- web/app/api/properties/resolve/route.ts | 28 +- web/app/api/properties/route.ts | 32 +- web/app/api/report/audit-tool/route.ts | 39 +- web/app/api/report/crawl-payload/route.ts | 18 +- web/app/api/report/export-sitemap/route.ts | 57 +- web/app/api/report/export-workbook/route.ts | 66 +- web/app/api/report/export/route.ts | 105 +- web/app/api/report/history/route.ts | 20 +- web/app/api/report/meta/route.ts | 14 +- web/app/api/report/mobile-delta/route.ts | 18 +- web/app/api/report/payload/route.ts | 41 +- web/app/api/report/portfolio/route.ts | 141 +- web/app/api/run/route.ts | 170 +- web/app/api/schedule/check/route.ts | 63 +- web/app/api/secrets/route.ts | 57 +- web/openapi.json | 6061 +++++++++++++++++ web/package-lock.json | 643 +- web/package.json | 7 +- web/src/client/client.gen.ts | 16 + web/src/client/client/client.gen.ts | 277 + web/src/client/client/index.ts | 27 + web/src/client/client/types.gen.ts | 218 + web/src/client/client/utils.gen.ts | 316 + web/src/client/core/auth.gen.ts | 48 + web/src/client/core/bodySerializer.gen.ts | 82 + web/src/client/core/params.gen.ts | 171 + web/src/client/core/pathSerializer.gen.ts | 171 + web/src/client/core/queryKeySerializer.gen.ts | 117 + web/src/client/core/serverSentEvents.gen.ts | 242 + web/src/client/core/types.gen.ts | 110 + web/src/client/core/utils.gen.ts | 140 + web/src/client/index.ts | 4 + web/src/client/sdk.gen.ts | 910 +++ web/src/client/types.gen.ts | 4097 +++++++++++ .../pagesMarkdown/ExtractorPanel.tsx | 16 +- .../components/pagesMarkdown/PreviewPanel.tsx | 20 +- web/src/lib/dashboard/ai/generate.test.ts | 214 + web/src/lib/dashboard/ai/generate.ts | 280 + .../lib/dashboard/builder/AiAssistModal.tsx | 301 + .../lib/dashboard/builder/DashboardGrid.tsx | 101 + .../dashboard/builder/DashboardSwitcher.tsx | 177 + .../lib/dashboard/builder/DashboardWidget.tsx | 143 + web/src/lib/dashboard/builder/FilterBar.tsx | 316 + .../lib/dashboard/builder/PresetPicker.tsx | 58 + .../dashboard/builder/WidgetConfigPanel.tsx | 449 ++ .../lib/dashboard/builder/WidgetPalette.tsx | 170 + web/src/lib/dashboard/catalog/catalog.test.ts | 84 + web/src/lib/dashboard/catalog/catalog.ts | 256 + .../dashboard/data/fetchWidgetData.test.ts | 78 + web/src/lib/dashboard/data/fetchWidgetData.ts | 197 + web/src/lib/dashboard/index.ts | 85 +- web/src/lib/dashboard/presets/presets.test.ts | 36 + .../lib/dashboard/script/dashScript.test.ts | 83 + web/src/lib/dashboard/script/eval.ts | 233 + web/src/lib/dashboard/script/lexer.ts | 92 + web/src/lib/dashboard/script/parser.ts | 209 + web/src/lib/dashboard/script/types.ts | 48 + web/src/lib/dashboard/types.ts | 200 +- web/src/lib/dashboard/viz/EmptyData.tsx | 3 + .../lib/dashboard/viz/VizErrorBoundary.tsx | 44 + web/src/lib/dashboard/viz/charts/BarViz.tsx | 66 + .../viz/charts/CustomChartViz.test.ts | 106 + .../dashboard/viz/charts/CustomChartViz.tsx | 162 + .../dashboard/viz/charts/DashboardChart.tsx | 175 + web/src/lib/dashboard/viz/charts/LineViz.tsx | 45 + web/src/lib/dashboard/viz/charts/PartViz.tsx | 86 + .../lib/dashboard/viz/data/MarkdownViz.tsx | 12 + web/src/lib/dashboard/viz/data/TableViz.tsx | 60 + web/src/lib/dashboard/viz/formatters.test.ts | 57 + web/src/lib/dashboard/viz/formatters.ts | 50 + web/src/lib/dashboard/viz/labels.ts | 29 + .../lib/dashboard/viz/metrics/GaugeViz.tsx | 32 + web/src/lib/dashboard/viz/metrics/KpiViz.tsx | 29 + .../dashboard/viz/metrics/SparklineViz.tsx | 26 + web/src/lib/dashboard/viz/registry.tsx | 43 + web/src/lib/dashboard/viz/series.test.ts | 117 + web/src/lib/dashboard/viz/series.ts | 168 + web/src/lib/dashboard/viz/types.ts | 12 + web/src/lib/loadReportDb.test.ts | 2 +- web/src/lib/loadReportDb.ts | 2 +- web/src/server/aiFixSuggestionRoute.test.ts | 54 +- web/src/server/alertsCheckRoute.test.ts | 47 +- web/src/server/appSettings.ts | 37 +- web/src/server/auditHistoryDb.ts | 158 - web/src/server/auditToolRoute.test.ts | 78 +- .../backlinksCompetitorImportRoute.test.ts | 71 +- .../backlinksThirdPartyImportRoute.test.ts | 76 +- web/src/server/bingSyncRoute.test.ts | 64 +- web/src/server/browserStatusRoute.test.ts | 64 +- web/src/server/chatDb.test.ts | 31 - web/src/server/chatDb.ts | 154 - web/src/server/compareExportRoute.test.ts | 126 +- web/src/server/contentDraftDb.ts | 186 - web/src/server/crawlPageHtmlRoute.test.ts | 80 +- .../server/dashboardsAiGenerateRoute.test.ts | 69 + web/src/server/dashboardsDb.ts | 124 - web/src/server/dashboardsRoute.test.ts | 166 +- web/src/server/db.ts | 50 - web/src/server/exportSitemapRoute.test.ts | 44 +- web/src/server/fastApiClient.ts | 73 + web/src/server/filtersRoute.test.ts | 46 +- web/src/server/googleAppSettings.ts | 130 +- web/src/server/gscLinksImportRoute.test.ts | 127 +- web/src/server/issueStatusDb.ts | 133 - web/src/server/issuesActionPlanRoute.test.ts | 67 +- .../server/issuesFixSuggestionRoute.test.ts | 49 +- web/src/server/issuesStatusRoute.test.ts | 99 +- web/src/server/jobsCancelRoute.test.ts | 40 +- web/src/server/jobsRoute.test.ts | 30 +- .../keywordsCompetitorImportRoute.test.ts | 86 +- web/src/server/llmConfig.ts | 178 +- web/src/server/logsUploadRoute.test.ts | 47 +- web/src/server/mobileDeltaDb.ts | 73 - web/src/server/pageDataHistoryRoute.test.ts | 42 +- web/src/server/pageGoogleData.ts | 2 +- web/src/server/pageMarkdownDb.ts | 158 - web/src/server/parsePythonJsonStdout.test.ts | 33 - web/src/server/pipelineConfig.ts | 104 +- web/src/server/pipelineJobs.test.ts | 151 - web/src/server/pipelineJobs.ts | 525 -- web/src/server/pipelineJobsDb.ts | 307 - web/src/server/pipelineSpawnEnv.test.ts | 26 - web/src/server/pipelineSpawnEnv.ts | 28 - web/src/server/propertiesDb.ts | 213 +- web/src/server/propertyOpsRoute.test.ts | 71 +- web/src/server/proxyToFastAPI.ts | 36 + web/src/server/reportCompareServer.ts | 11 +- web/src/server/reportDb.ts | 28 - web/src/server/resolvePython.ts | 116 - web/src/server/savedFiltersDb.ts | 56 - web/src/server/secretsRoute.test.ts | 54 +- web/src/server/spawnAuditTool.ts | 118 - web/src/types/dashboard.test.ts | 2 +- 278 files changed, 27003 insertions(+), 9682 deletions(-) create mode 100644 alembic/versions/025_pipeline_job_queue.py create mode 100644 scripts/generate_openapi.py create mode 100644 src/website_profiling/api/__init__.py create mode 100644 src/website_profiling/api/deps.py create mode 100644 src/website_profiling/api/main.py create mode 100644 src/website_profiling/api/routers/__init__.py create mode 100644 src/website_profiling/api/routers/alerts.py create mode 100644 src/website_profiling/api/routers/chat.py create mode 100644 src/website_profiling/api/routers/compare.py create mode 100644 src/website_profiling/api/routers/config.py create mode 100644 src/website_profiling/api/routers/content.py create mode 100644 src/website_profiling/api/routers/crawl.py create mode 100644 src/website_profiling/api/routers/dashboards.py create mode 100644 src/website_profiling/api/routers/filters.py create mode 100644 src/website_profiling/api/routers/health.py create mode 100644 src/website_profiling/api/routers/integrations.py create mode 100644 src/website_profiling/api/routers/issues.py create mode 100644 src/website_profiling/api/routers/keywords.py create mode 100644 src/website_profiling/api/routers/logs.py create mode 100644 src/website_profiling/api/routers/mcp_tools.py create mode 100644 src/website_profiling/api/routers/ollama.py create mode 100644 src/website_profiling/api/routers/page_coach.py create mode 100644 src/website_profiling/api/routers/page_markdown.py create mode 100644 src/website_profiling/api/routers/pipeline.py create mode 100644 src/website_profiling/api/routers/portfolio.py create mode 100644 src/website_profiling/api/routers/properties.py create mode 100644 src/website_profiling/api/routers/report.py create mode 100644 src/website_profiling/api/routers/report_audit_tool.py create mode 100644 src/website_profiling/api/routers/report_export.py create mode 100644 src/website_profiling/api/routers/report_portfolio.py create mode 100644 src/website_profiling/api/routers/schedule.py create mode 100644 src/website_profiling/api/schemas/__init__.py create mode 100644 src/website_profiling/api/schemas/chat.py create mode 100644 src/website_profiling/api/schemas/pipeline.py create mode 100644 src/website_profiling/api/services/__init__.py create mode 100644 src/website_profiling/api/services/portfolio_loader.py create mode 100644 src/website_profiling/api/services/report_loader.py create mode 100644 src/website_profiling/db/content_draft_store.py create mode 100644 src/website_profiling/db/dashboard_store.py create mode 100644 src/website_profiling/db/issue_status_store.py create mode 100644 src/website_profiling/db/pipeline_jobs.py create mode 100644 src/website_profiling/db/portfolio_store.py create mode 100644 src/website_profiling/db/saved_filter_store.py create mode 100644 src/website_profiling/llm/ollama_catalog.py create mode 100644 src/website_profiling/worker/__init__.py create mode 100644 src/website_profiling/worker/__main__.py create mode 100644 src/website_profiling/worker/loop.py create mode 100644 src/website_profiling/worker/runner.py create mode 100644 src/website_profiling/worker/signals.py create mode 100644 tests/api/conftest.py create mode 100644 tests/api/test_api_integration.py create mode 100644 tests/api/test_content_drafts_list.py create mode 100644 tests/api/test_report_loader_list.py create mode 100644 tests/llm/test_ollama_catalog.py create mode 100644 tests/test_db_pipeline_jobs_unit.py create mode 100644 web/app/api/dashboards/ai-generate/route.ts create mode 100644 web/openapi.json create mode 100644 web/src/client/client.gen.ts create mode 100644 web/src/client/client/client.gen.ts create mode 100644 web/src/client/client/index.ts create mode 100644 web/src/client/client/types.gen.ts create mode 100644 web/src/client/client/utils.gen.ts create mode 100644 web/src/client/core/auth.gen.ts create mode 100644 web/src/client/core/bodySerializer.gen.ts create mode 100644 web/src/client/core/params.gen.ts create mode 100644 web/src/client/core/pathSerializer.gen.ts create mode 100644 web/src/client/core/queryKeySerializer.gen.ts create mode 100644 web/src/client/core/serverSentEvents.gen.ts create mode 100644 web/src/client/core/types.gen.ts create mode 100644 web/src/client/core/utils.gen.ts create mode 100644 web/src/client/index.ts create mode 100644 web/src/client/sdk.gen.ts create mode 100644 web/src/client/types.gen.ts create mode 100644 web/src/lib/dashboard/ai/generate.test.ts create mode 100644 web/src/lib/dashboard/ai/generate.ts create mode 100644 web/src/lib/dashboard/builder/AiAssistModal.tsx create mode 100644 web/src/lib/dashboard/builder/DashboardGrid.tsx create mode 100644 web/src/lib/dashboard/builder/DashboardSwitcher.tsx create mode 100644 web/src/lib/dashboard/builder/DashboardWidget.tsx create mode 100644 web/src/lib/dashboard/builder/FilterBar.tsx create mode 100644 web/src/lib/dashboard/builder/PresetPicker.tsx create mode 100644 web/src/lib/dashboard/builder/WidgetConfigPanel.tsx create mode 100644 web/src/lib/dashboard/builder/WidgetPalette.tsx create mode 100644 web/src/lib/dashboard/catalog/catalog.test.ts create mode 100644 web/src/lib/dashboard/catalog/catalog.ts create mode 100644 web/src/lib/dashboard/data/fetchWidgetData.test.ts create mode 100644 web/src/lib/dashboard/data/fetchWidgetData.ts create mode 100644 web/src/lib/dashboard/presets/presets.test.ts create mode 100644 web/src/lib/dashboard/script/dashScript.test.ts create mode 100644 web/src/lib/dashboard/script/eval.ts create mode 100644 web/src/lib/dashboard/script/lexer.ts create mode 100644 web/src/lib/dashboard/script/parser.ts create mode 100644 web/src/lib/dashboard/script/types.ts create mode 100644 web/src/lib/dashboard/viz/EmptyData.tsx create mode 100644 web/src/lib/dashboard/viz/VizErrorBoundary.tsx create mode 100644 web/src/lib/dashboard/viz/charts/BarViz.tsx create mode 100644 web/src/lib/dashboard/viz/charts/CustomChartViz.test.ts create mode 100644 web/src/lib/dashboard/viz/charts/CustomChartViz.tsx create mode 100644 web/src/lib/dashboard/viz/charts/DashboardChart.tsx create mode 100644 web/src/lib/dashboard/viz/charts/LineViz.tsx create mode 100644 web/src/lib/dashboard/viz/charts/PartViz.tsx create mode 100644 web/src/lib/dashboard/viz/data/MarkdownViz.tsx create mode 100644 web/src/lib/dashboard/viz/data/TableViz.tsx create mode 100644 web/src/lib/dashboard/viz/formatters.test.ts create mode 100644 web/src/lib/dashboard/viz/formatters.ts create mode 100644 web/src/lib/dashboard/viz/labels.ts create mode 100644 web/src/lib/dashboard/viz/metrics/GaugeViz.tsx create mode 100644 web/src/lib/dashboard/viz/metrics/KpiViz.tsx create mode 100644 web/src/lib/dashboard/viz/metrics/SparklineViz.tsx create mode 100644 web/src/lib/dashboard/viz/registry.tsx create mode 100644 web/src/lib/dashboard/viz/series.test.ts create mode 100644 web/src/lib/dashboard/viz/series.ts create mode 100644 web/src/lib/dashboard/viz/types.ts delete mode 100644 web/src/server/auditHistoryDb.ts delete mode 100644 web/src/server/chatDb.test.ts delete mode 100644 web/src/server/chatDb.ts delete mode 100644 web/src/server/contentDraftDb.ts create mode 100644 web/src/server/dashboardsAiGenerateRoute.test.ts delete mode 100644 web/src/server/dashboardsDb.ts delete mode 100644 web/src/server/db.ts create mode 100644 web/src/server/fastApiClient.ts delete mode 100644 web/src/server/issueStatusDb.ts delete mode 100644 web/src/server/mobileDeltaDb.ts delete mode 100644 web/src/server/pageMarkdownDb.ts delete mode 100644 web/src/server/parsePythonJsonStdout.test.ts delete mode 100644 web/src/server/pipelineJobs.test.ts delete mode 100644 web/src/server/pipelineJobs.ts delete mode 100644 web/src/server/pipelineJobsDb.ts delete mode 100644 web/src/server/pipelineSpawnEnv.test.ts delete mode 100644 web/src/server/pipelineSpawnEnv.ts create mode 100644 web/src/server/proxyToFastAPI.ts delete mode 100644 web/src/server/reportDb.ts delete mode 100644 web/src/server/resolvePython.ts delete mode 100644 web/src/server/savedFiltersDb.ts delete mode 100644 web/src/server/spawnAuditTool.ts diff --git a/.coveragerc b/.coveragerc index d0d0448b..4872d665 100644 --- a/.coveragerc +++ b/.coveragerc @@ -19,6 +19,10 @@ omit = */website_profiling/llm_config.py */website_profiling/cli.py */website_profiling/commands/enrich_cmd.py + # FastAPI server — tested via integration tests, not unit tests + */website_profiling/api/* + # Worker process — requires running DB and subprocess for meaningful tests + */website_profiling/worker/* [report] show_missing = True diff --git a/Dockerfile b/Dockerfile index a8222a01..94d185bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -# WebsiteProfiling: Next.js web UI + Python pipeline (spawned from /api/run). +# WebsiteProfiling: Next.js UI + FastAPI (port 8001) + Python worker + pipeline. # Build from repository root: docker build -t website-profiling . # BuildKit cache mounts (default in Docker Desktop) reuse pip/npm downloads across rebuilds. diff --git a/alembic/versions/025_pipeline_job_queue.py b/alembic/versions/025_pipeline_job_queue.py new file mode 100644 index 00000000..8a20f20f --- /dev/null +++ b/alembic/versions/025_pipeline_job_queue.py @@ -0,0 +1,35 @@ +"""Add pipeline job queue columns for the Python worker. + +Adds: command, cancel_requested, pause_requested, worker_pid, status='pending'. + +Revision ID: 025_pipeline_job_queue +Revises: 014_pipeline_log_truncated +""" +from __future__ import annotations + +from alembic import op + +revision = "025_pipeline_job_queue" +down_revision = "024_app_settings" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE pipeline_jobs + ADD COLUMN IF NOT EXISTS command TEXT, + ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS pause_requested BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS worker_pid INTEGER; + """) + + +def downgrade() -> None: + op.execute(""" + ALTER TABLE pipeline_jobs + DROP COLUMN IF EXISTS worker_pid, + DROP COLUMN IF EXISTS pause_requested, + DROP COLUMN IF EXISTS cancel_requested, + DROP COLUMN IF EXISTS command; + """) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0ba7a67b..065faf2f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,6 +30,7 @@ services: AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET} AUTH_PASSWORD: ${AUTH_PASSWORD:-} NODE_ENV: production + FASTAPI_URL: http://127.0.0.1:8001 volumes: - profiling-data:/data healthcheck: diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml index b89e0767..9f7195f5 100644 --- a/docker-compose.pull.yml +++ b/docker-compose.pull.yml @@ -33,6 +33,7 @@ services: CHROME_PATH: /usr/bin/chromium LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu + FASTAPI_URL: http://127.0.0.1:8001 volumes: - profiling-data:/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index b4ecaa40..618900bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: condition: service_healthy ports: - "3000:3000" + - "8001:8001" environment: WEBSITE_PROFILING_ROOT: /app DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling @@ -32,6 +33,8 @@ services: CHROME_PATH: /usr/bin/chromium LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu + FASTAPI_URL: http://127.0.0.1:8001 + FASTAPI_ALLOWED_ORIGINS: "http://localhost:3000" volumes: - profiling-data:/data healthcheck: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 43f4b1e3..617c7c04 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -63,4 +63,26 @@ sys.exit(1) PY /opt/venv/bin/alembic upgrade head -cd /app/web && exec npm run start -- -H 0.0.0.0 -p 3000 + +WORKER_PID="" +UVICORN_PID="" +NPM_PID="" + +cleanup() { + [ -n "$WORKER_PID" ] && kill "$WORKER_PID" 2>/dev/null || true + [ -n "$UVICORN_PID" ] && kill "$UVICORN_PID" 2>/dev/null || true + [ -n "$NPM_PID" ] && kill "$NPM_PID" 2>/dev/null || true +} +trap cleanup TERM INT + +/opt/venv/bin/python -m website_profiling.worker & +WORKER_PID=$! + +/opt/venv/bin/uvicorn website_profiling.api.main:app \ + --host 0.0.0.0 --port 8001 --workers 1 & +UVICORN_PID=$! + +cd /app/web +npm run start -- -H 0.0.0.0 -p 3000 & +NPM_PID=$! +wait $NPM_PID diff --git a/requirements.txt b/requirements.txt index c7bc3f69..f8e7e966 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,6 +55,7 @@ tiktoken==0.13.0 mcp>=1.19,<2 uvicorn>=0.30 starlette>=0.38 +fastapi>=0.115 # SQL query validation (read-only chat tool) sqlglot==30.11.0 diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py new file mode 100644 index 00000000..ba11730a --- /dev/null +++ b/scripts/generate_openapi.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Write web/openapi.json from the FastAPI app (no running server required).""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from fastapi.openapi.utils import get_openapi # noqa: E402 + +from website_profiling.api.main import app # noqa: E402 + +OUT = ROOT / "web" / "openapi.json" + + +def main() -> None: + schema = get_openapi( + title=app.title, + version=app.version, + routes=app.routes, + ) + OUT.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/local-prod.sh b/scripts/local-prod.sh index 61cd1ab9..37892ad0 100755 --- a/scripts/local-prod.sh +++ b/scripts/local-prod.sh @@ -68,9 +68,34 @@ cmd_start() { log "DATA_DIR=$DATA_DIR" log "PYTHON=$PYTHON" log "NODE_ENV=$NODE_ENV" - cd "$WEB" + cd "$ROOT" export DATABASE_URL DATA_DIR PYTHON WEBSITE_PROFILING_ROOT PYTHONPATH NODE_ENV - exec npm run start + + WORKER_PID="" + UVICORN_PID="" + NPM_PID="" + + cleanup_prod() { + [ -n "$WORKER_PID" ] && kill "$WORKER_PID" 2>/dev/null || true + [ -n "$UVICORN_PID" ] && kill "$UVICORN_PID" 2>/dev/null || true + [ -n "$NPM_PID" ] && kill "$NPM_PID" 2>/dev/null || true + } + trap cleanup_prod INT TERM EXIT + + log "Starting pipeline worker" + "$ROOT/.venv/bin/python" -m website_profiling.worker & + WORKER_PID=$! + + log "Starting FastAPI on port 8001" + export FASTAPI_URL="http://127.0.0.1:8001" + "$ROOT/.venv/bin/uvicorn" website_profiling.api.main:app \ + --host 0.0.0.0 --port 8001 --workers 1 & + UVICORN_PID=$! + + cd "$WEB" + npm run start -- -H 0.0.0.0 -p 3000 & + NPM_PID=$! + wait $NPM_PID } cmd_help() { diff --git a/scripts/local-run.sh b/scripts/local-run.sh index 4f84fea5..4171e0da 100755 --- a/scripts/local-run.sh +++ b/scripts/local-run.sh @@ -134,12 +134,34 @@ cmd_start() { log "Ensuring migrations are up to date" "$VENV/bin/alembic" upgrade head cmd_web_deps + cd "$ROOT" + export DATABASE_URL DATA_DIR PYTHON WEBSITE_PROFILING_ROOT PYTHONPATH + + WORKER_PID="" + UVICORN_PID="" + + cleanup_local() { + [ -n "$WORKER_PID" ] && kill "$WORKER_PID" 2>/dev/null || true + [ -n "$UVICORN_PID" ] && kill "$UVICORN_PID" 2>/dev/null || true + } + trap cleanup_local INT TERM EXIT + + log "Starting pipeline worker" + "$VENV/bin/python" -m website_profiling.worker & + WORKER_PID=$! + + log "Starting FastAPI on port 8001" + export FASTAPI_URL="http://127.0.0.1:8001" + export FASTAPI_ALLOWED_ORIGINS="http://localhost:3000" + "$VENV/bin/uvicorn" website_profiling.api.main:app \ + --host 0.0.0.0 --port 8001 --workers 1 & + UVICORN_PID=$! + log "Starting Next.js dev server (Ctrl+C to stop)" log "DATABASE_URL=$DATABASE_URL" log "DATA_DIR=$DATA_DIR" log "PYTHON=$PYTHON" cd "$WEB" - export DATABASE_URL DATA_DIR PYTHON WEBSITE_PROFILING_ROOT PYTHONPATH exec npm run dev } diff --git a/src/website_profiling/api/__init__.py b/src/website_profiling/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/website_profiling/api/deps.py b/src/website_profiling/api/deps.py new file mode 100644 index 00000000..8675c831 --- /dev/null +++ b/src/website_profiling/api/deps.py @@ -0,0 +1,19 @@ +"""Shared FastAPI dependencies.""" +from __future__ import annotations + +from typing import Iterator + +from psycopg import Connection + +from website_profiling.db.pool import db_session + + +def get_db() -> Iterator[Connection]: + """Yield a synchronous psycopg connection from the pool. + + Declare route handlers as plain ``def`` (not ``async def``) so FastAPI + runs them in a thread pool automatically — this matches the existing + synchronous codebase and requires no pool migration. + """ + with db_session() as conn: + yield conn diff --git a/src/website_profiling/api/main.py b/src/website_profiling/api/main.py new file mode 100644 index 00000000..e3332327 --- /dev/null +++ b/src/website_profiling/api/main.py @@ -0,0 +1,110 @@ +"""FastAPI application entry point.""" +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .routers import ( + alerts, + chat, + compare, + config, + content, + crawl, + dashboards, + filters, + health, + integrations, + issues, + keywords, + logs, + mcp_tools, + ollama, + page_coach, + page_markdown, + pipeline, + portfolio, + properties, + report, + report_audit_tool, + report_export, + report_portfolio, + schedule, +) + + +@asynccontextmanager +async def _lifespan(app: FastAPI) -> AsyncIterator[None]: + yield + # Close the psycopg connection pool on shutdown. + try: + from website_profiling.db.pool import close_db_pool + + close_db_pool() + except Exception: + pass + + +app = FastAPI( + title="Website Profiling API", + version="1.0.0", + lifespan=_lifespan, +) + +# CORS — only added when FASTAPI_ALLOWED_ORIGINS is set (local Swagger in dev). +_origins_raw = os.getenv("FASTAPI_ALLOWED_ORIGINS", "").strip() +if _origins_raw: + app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in _origins_raw.split(",") if o.strip()], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# ── Core routes ─────────────────────────────────────────────────────────────── +app.include_router(health.router, prefix="/api") +app.include_router(report.router, prefix="/api") + +# ── Batch B: Pipeline jobs ──────────────────────────────────────────────────── +app.include_router(pipeline.router, prefix="/api") + +# ── Batch C: Chat (SSE + sessions) ─────────────────────────────────────────── +app.include_router(chat.router, prefix="/api") + +# ── Batch D: Crawl ─────────────────────────────────────────────────────────── +app.include_router(crawl.router, prefix="/api") + +# ── Batch E: Config (pipeline, LLM, secrets, app-settings) ─────────────────── +app.include_router(config.router, prefix="/api") + +# ── Batch F: Properties ────────────────────────────────────────────────────── +app.include_router(properties.router, prefix="/api") + +# ── Batch G: Dashboards + Filters ──────────────────────────────────────────── +app.include_router(dashboards.router, prefix="/api") +app.include_router(filters.router, prefix="/api") + +# ── Batch H: Google + Bing integrations ────────────────────────────────────── +app.include_router(integrations.router, prefix="/api") + +# ── Batch I: Issues, keywords, content, page markdown, long-tail ───────────── +app.include_router(issues.router, prefix="/api") +app.include_router(keywords.router, prefix="/api") +app.include_router(content.router, prefix="/api") +app.include_router(page_markdown.router, prefix="/api") +app.include_router(ollama.router, prefix="/api") +app.include_router(mcp_tools.router, prefix="/api") +app.include_router(portfolio.router, prefix="/api") +app.include_router(alerts.router, prefix="/api") +app.include_router(schedule.router, prefix="/api") +app.include_router(logs.router, prefix="/api") +app.include_router(compare.router, prefix="/api") +app.include_router(page_coach.router, prefix="/api") +app.include_router(report_audit_tool.router, prefix="/api") +app.include_router(report_export.router, prefix="/api") +app.include_router(report_portfolio.router, prefix="/api") diff --git a/src/website_profiling/api/routers/__init__.py b/src/website_profiling/api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/website_profiling/api/routers/alerts.py b/src/website_profiling/api/routers/alerts.py new file mode 100644 index 00000000..d951b881 --- /dev/null +++ b/src/website_profiling/api/routers/alerts.py @@ -0,0 +1,31 @@ +"""Property alert checks — /api/alerts/*.""" +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(tags=["alerts"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +@router.post("/alerts/check") +def alerts_check( + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + if not propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + try: + from website_profiling.tools.alerts_runner import run_alerts_for_property + + return run_alerts_for_property(conn, propertyId) + except ImportError: + pass + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"ok": True, "checked": 0} diff --git a/src/website_profiling/api/routers/chat.py b/src/website_profiling/api/routers/chat.py new file mode 100644 index 00000000..1c1d1de5 --- /dev/null +++ b/src/website_profiling/api/routers/chat.py @@ -0,0 +1,225 @@ +"""Chat routers — /api/chat, /api/chat/sessions/*, /api/chat/artifacts/*.""" +from __future__ import annotations + +import json +import queue +import re +import threading +from typing import Annotated, Any, Generator, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from psycopg import Connection + +from ..deps import get_db +from ..schemas.chat import ArtifactUpdateBody, ChatRequest, ChatSessionCreate + +router = APIRouter(prefix="/chat", tags=["chat"]) + +DbDep = Annotated[Connection, Depends(get_db)] + +_FIRST_SENTENCE_RE = re.compile(r"^(.{8,80}[.!?])", re.DOTALL) + + +def _messages_for_agent_context( + rows: list[dict[str, Any]], max_turns: int = 20 +) -> list[dict[str, str]]: + """Port of messagesForAgentContext from chatDb.ts.""" + relevant = [m for m in rows if m.get("role") in ("user", "assistant")] + sliced = relevant[-(max_turns * 2):] + return [{"role": m["role"], "content": str(m.get("content") or "")} for m in sliced] + + +def _derive_title(text: str) -> str | None: + text = text.strip() + if not text: + return None + m = _FIRST_SENTENCE_RE.match(text) + raw = m.group(1).strip() if m else text[:60].strip() + return raw[:80] if raw else None + + +# ── POST /api/chat (SSE streaming) ──────────────────────────────────────────── + +@router.post("/") +def chat_turn(body: ChatRequest, conn: DbDep) -> StreamingResponse: + from website_profiling.db.chat_store import ( + append_message, + get_messages, + get_session, + update_session_title, + ) + from website_profiling.llm.agent import run_agent_turn + from website_profiling.tools.audit_tools import AuditToolContext + + # Validate session + session = get_session(conn, body.sessionId) + if not session or session["property_id"] != body.propertyId: + raise HTTPException(status_code=404, detail="session not found") + + # Persist user message + append_message(conn, body.sessionId, "user", body.message) + + # Build agent context + history = get_messages(conn, body.sessionId) + agent_messages = _messages_for_agent_context(history) + context = AuditToolContext( + property_id=body.propertyId, + report_id=body.reportId, + ) + + q: queue.Queue[dict[str, Any] | None] = queue.Queue() + assistant_parts: list[str] = [] + tool_events: list[dict[str, Any]] = [] + result_holder: list[dict[str, Any]] = [] + + def on_event(event: dict[str, Any]) -> None: + if event.get("type") == "token": + assistant_parts.append(str(event.get("text") or "")) + elif event.get("type") == "tool_end": + tool_events.append(event) + q.put(event) + + def run_agent() -> None: + try: + result = run_agent_turn(agent_messages, context, on_event=on_event) + result_holder.append(result) + except Exception as exc: + q.put({"type": "error", "message": str(exc)}) + finally: + q.put(None) # sentinel + + thread = threading.Thread(target=run_agent, daemon=True) + thread.start() + + def generate() -> Generator[str, None, None]: + while True: + item = q.get() + if item is None: + break + event_type = str(item.get("type") or "message") + yield f"event: {event_type}\ndata: {json.dumps(item)}\n\n" + + thread.join(timeout=5) + + # Persist assistant response + assistant_text = "".join(assistant_parts).strip() + if assistant_text: + try: + append_message(conn, body.sessionId, "assistant", assistant_text) + # Auto-title from first user message if session title is default + if session.get("title") in ("New chat", "", None): + derived = _derive_title(body.message) or _derive_title(assistant_text) + if derived: + update_session_title(conn, body.sessionId, derived) + except Exception: + pass + + return StreamingResponse(generate(), media_type="text/event-stream") + + +# ── Session CRUD ────────────────────────────────────────────────────────────── + +@router.get("/sessions") +def list_sessions( + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + from website_profiling.db.chat_store import list_sessions as _list + + if not propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + sessions = _list(conn, propertyId) + return {"sessions": sessions} + + +@router.post("/sessions") +def create_session(body: ChatSessionCreate, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.chat_store import create_session as _create + + if not body.propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + session_id = _create(conn, body.propertyId, body.title) + return {"id": session_id, "propertyId": body.propertyId, "title": body.title} + + +@router.get("/sessions/{session_id}") +def get_session_route(session_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.chat_store import get_session + + session = get_session(conn, session_id) + if not session: + raise HTTPException(status_code=404, detail="session not found") + return {"session": session} + + +@router.delete("/sessions/{session_id}") +def delete_session_route( + session_id: int, + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + from website_profiling.db.chat_store import delete_session, get_session + + session = get_session(conn, session_id) + if not session or session["property_id"] != propertyId: + raise HTTPException(status_code=404, detail="session not found") + deleted = delete_session(conn, session_id) + if not deleted: + raise HTTPException(status_code=404, detail="session not found") + return {"ok": True} + + +@router.get("/sessions/{session_id}/messages") +def get_session_messages( + session_id: int, + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + from website_profiling.db.chat_store import get_messages, get_session + + session = get_session(conn, session_id) + if not session or session["property_id"] != propertyId: + raise HTTPException(status_code=404, detail="session not found") + messages = get_messages(conn, session_id) + return {"messages": messages} + + +# ── Artifacts ──────────────────────────────────────────────────────────────── + +@router.get("/artifacts/{artifact_id}") +def get_artifact(artifact_id: str) -> Any: + import base64 + import re as _re + + if not _re.match(r"^[a-f0-9\-]{36}$", artifact_id): + raise HTTPException(status_code=400, detail="Invalid artifact id") + + try: + from website_profiling.tools.export_artifacts import read_artifact_bytes + + result = read_artifact_bytes(artifact_id) + except ImportError: + raise HTTPException(status_code=500, detail="Artifact module unavailable") + + if not result: + raise HTTPException(status_code=404, detail="Artifact not found") + + meta, data = result + filename = meta.get("filename") or "export.bin" + mime_type = meta.get("mime_type") or "application/octet-stream" + ascii_name = re.sub(r'[^\x20-\x7e]', '_', filename) + ascii_name = re.sub(r'["\\/]', '_', ascii_name) or "export.bin" + + from fastapi import Response + + return Response( + content=data, + media_type=mime_type, + headers={ + "Content-Disposition": ( + f'attachment; filename="{ascii_name}"; ' + f"filename*=UTF-8''{ascii_name}" + ), + }, + ) diff --git a/src/website_profiling/api/routers/compare.py b/src/website_profiling/api/routers/compare.py new file mode 100644 index 00000000..14264d9f --- /dev/null +++ b/src/website_profiling/api/routers/compare.py @@ -0,0 +1,61 @@ +"""Report comparison export — /api/compare/*.""" +from __future__ import annotations + +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from psycopg import Connection +from pydantic import BaseModel + +from ..deps import get_db + +router = APIRouter(tags=["compare"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class CompareExportBody(BaseModel): + reportIdA: Optional[int] = None + reportIdB: Optional[int] = None + + +def _csv_escape(val: str) -> str: + if any(c in val for c in ('",\n')): + return f'"{val.replace(chr(34), chr(34) + chr(34))}"' + return val + + +@router.post("/compare/export") +def compare_export(body: CompareExportBody, conn: DbDep) -> Response: + if not body.reportIdA or not body.reportIdB: + raise HTTPException(status_code=400, detail="reportIdA and reportIdB required") + + from website_profiling.db.report_store import read_report_payload + + payload_a = read_report_payload(conn, body.reportIdA) + payload_b = read_report_payload(conn, body.reportIdB) + if not payload_a or not payload_b: + raise HTTPException(status_code=404, detail="One or both reports not found") + + lines = ["Category,Issue Title,Priority,Change\n"] + cats_a = {c.get("id") or c.get("name"): c for c in (payload_a.get("categories") or [])} + cats_b = {c.get("id") or c.get("name"): c for c in (payload_b.get("categories") or [])} + for key in set(list(cats_a.keys()) + list(cats_b.keys())): + cat_a = cats_a.get(key) or {} + cat_b = cats_b.get(key) or {} + issues_a = {i.get("title"): i for i in (cat_a.get("issues") or [])} + issues_b = {i.get("title"): i for i in (cat_b.get("issues") or [])} + for title in set(list(issues_a.keys()) + list(issues_b.keys())): + in_a = title in issues_a + in_b = title in issues_b + change = "removed" if in_a and not in_b else "added" if not in_a and in_b else "unchanged" + priority = (issues_b.get(title) or issues_a.get(title) or {}).get("priority", "") + lines.append(f"{_csv_escape(str(key))},{_csv_escape(str(title))},{priority},{change}\n") + + csv_content = "".join(lines) + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=compare_export.csv"}, + ) diff --git a/src/website_profiling/api/routers/config.py b/src/website_profiling/api/routers/config.py new file mode 100644 index 00000000..6ee76b40 --- /dev/null +++ b/src/website_profiling/api/routers/config.py @@ -0,0 +1,263 @@ +"""Config routes: pipeline-config, llm-config, secrets, app-settings.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import Connection +from pydantic import BaseModel + +from ..deps import get_db + +router = APIRouter(tags=["config"]) + +_MASK = "*" + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _mask_secrets(data: dict[str, Any]) -> dict[str, Any]: + """Return a copy of *data* with secret-ish values replaced by ``'*'``.""" + masked: dict[str, Any] = {} + for k, v in data.items(): + val_str = str(v) if v is not None else "" + if val_str and (_is_secret_key(k)): + masked[k] = _MASK + else: + masked[k] = v + return masked + + +def _is_secret_key(key: str) -> bool: + key_lower = key.lower() + return ( + key_lower.endswith("_secret") + or key_lower.endswith("_api_key") + or key_lower.endswith("_key") + or "api_key" in key_lower + or "secret" in key_lower + or "password" in key_lower + or "token" in key_lower + ) + + +def _read_llm_config_full(conn: Connection) -> list[dict[str, Any]]: + from website_profiling.db.config_store import read_llm_config_full + return read_llm_config_full(conn) + + +def _read_app_setting(conn: Connection, key: str) -> Optional[str]: + from website_profiling.db.config_store import read_app_setting + return read_app_setting(conn, key) + + +def _write_app_setting(conn: Connection, key: str, value: str) -> None: + from website_profiling.db.config_store import write_app_setting + write_app_setting(conn, key, value) + + +# --------------------------------------------------------------------------- +# pipeline-config +# --------------------------------------------------------------------------- + + +@router.get("/pipeline-config") +def get_pipeline_config(conn: Annotated[Connection, Depends(get_db)]) -> dict[str, Any]: + from website_profiling.db.config_store import read_pipeline_config + + state, unknown_keys = read_pipeline_config(conn) + return {"state": state, "unknownKeys": unknown_keys, "source": "db"} + + +class PipelineConfigBody(BaseModel): + state: dict[str, Any] + unknownKeys: Optional[list[dict[str, str]]] = None + + +@router.put("/pipeline-config") +def put_pipeline_config( + body: PipelineConfigBody, + conn: Annotated[Connection, Depends(get_db)], +) -> dict[str, Any]: + from website_profiling.db.config_store import write_pipeline_config + + coerced: dict[str, str] = {str(k): str(v) for k, v in body.state.items()} + unknown_keys: list[dict[str, str]] = body.unknownKeys or [] + write_pipeline_config(conn, coerced, unknown_keys) + conn.commit() + return {"ok": True, "source": "db"} + + +# --------------------------------------------------------------------------- +# llm-config +# --------------------------------------------------------------------------- + + +@router.get("/llm-config") +def get_llm_config(conn: Annotated[Connection, Depends(get_db)]) -> dict[str, Any]: + rows = _read_llm_config_full(conn) + state: dict[str, Any] = {} + for row in rows: + k = str(row["key"]) + v = str(row["value"]) + is_secret = bool(row.get("is_secret")) + state[k] = _MASK if (is_secret and v) else v + return {"state": state, "source": "db"} + + +class LlmConfigBody(BaseModel): + state: dict[str, Any] + + +@router.put("/llm-config") +def put_llm_config( + body: LlmConfigBody, + conn: Annotated[Connection, Depends(get_db)], +) -> dict[str, Any]: + from website_profiling.db.config_store import write_llm_config + + # Preserve existing secret values when client sends "*" (masked sentinel) + existing_rows = _read_llm_config_full(conn) + existing: dict[str, str] = {str(r["key"]): str(r["value"]) for r in existing_rows} + existing_secrets: set[str] = {str(r["key"]) for r in existing_rows if r.get("is_secret")} + + entries: dict[str, str] = {} + secret_keys: set[str] = set() + + for k, v in body.state.items(): + val = str(v) if v is not None else "" + is_masked_sentinel = val.strip() in (_MASK, "••••") or ( + val.strip().startswith("*") and len(val.strip()) <= 4 + ) + if is_masked_sentinel and k in existing: + # Keep original value + entries[k] = existing[k] + else: + entries[k] = val + + if k in existing_secrets or _is_secret_key(k): + secret_keys.add(k) + + write_llm_config(conn, entries, secret_keys) + conn.commit() + return {"ok": True} + + +# --------------------------------------------------------------------------- +# secrets +# --------------------------------------------------------------------------- + + +@router.get("/secrets") +def get_secrets(conn: Annotated[Connection, Depends(get_db)]) -> dict[str, Any]: + from website_profiling.db.google_app_store import read_google_app_settings + + llm_rows = _read_llm_config_full(conn) + state: dict[str, Any] = {} + for row in llm_rows: + k = str(row["key"]) + v = str(row["value"]) + is_secret = bool(row.get("is_secret")) or _is_secret_key(k) + if is_secret and v: + state[k] = _MASK + state[f"{k}_masked"] = True + elif v: + state[k] = v + + google = read_google_app_settings(conn) + for field in ("client_id", "client_secret", "developer_token", "login_customer_id"): + raw = str(google.get(field) or "") + if raw: + state[f"google_{field}"] = _MASK if _is_secret_key(field) else raw + if _is_secret_key(field): + state[f"google_{field}_masked"] = True + state["google_has_service_account"] = bool(google.get("service_account_json")) + + return {"state": state, "source": "db"} + + +class SecretsBody(BaseModel): + state: dict[str, Any] + + +@router.put("/secrets") +def put_secrets( + body: SecretsBody, + conn: Annotated[Connection, Depends(get_db)], +) -> dict[str, Any]: + from website_profiling.db.config_store import read_llm_config, write_llm_config + from website_profiling.db.google_app_store import read_google_app_settings, save_google_app_settings + + existing_llm = read_llm_config(conn) + existing_rows = _read_llm_config_full(conn) + existing_secrets_set: set[str] = {str(r["key"]) for r in existing_rows if r.get("is_secret")} + + llm_updates: dict[str, str] = dict(existing_llm) + llm_secret_keys: set[str] = set(existing_secrets_set) + google_patch: dict[str, Any] = {} + + for k, v in body.state.items(): + if k.endswith("_masked") or k == "google_has_service_account": + continue + + val = str(v) if v is not None else "" + is_masked_sentinel = val.strip() in (_MASK, "••••") or ( + val.strip().startswith("*") and len(val.strip()) <= 4 + ) + + if k.startswith("google_"): + field = k[len("google_"):] + if field in ("client_id", "client_secret", "developer_token", "login_customer_id"): + if not is_masked_sentinel: + google_patch[field] = val + else: + if is_masked_sentinel: + # Preserve existing + pass + else: + llm_updates[k] = val + if _is_secret_key(k): + llm_secret_keys.add(k) + + write_llm_config(conn, llm_updates, llm_secret_keys) + conn.commit() + + if google_patch: + save_google_app_settings(conn, google_patch) + + return {"ok": True} + + +# --------------------------------------------------------------------------- +# app-settings +# --------------------------------------------------------------------------- + + +@router.get("/app-settings") +def get_app_setting( + conn: Annotated[Connection, Depends(get_db)], + key: str = Query(..., description="Settings key to retrieve"), +) -> dict[str, Any]: + if not key or not key.strip(): + raise HTTPException(status_code=400, detail="Missing key query parameter") + value = _read_app_setting(conn, key.strip()) + return {"key": key.strip(), "value": value} + + +class AppSettingBody(BaseModel): + key: str + value: str + + +@router.put("/app-settings") +def put_app_setting( + body: AppSettingBody, + conn: Annotated[Connection, Depends(get_db)], +) -> dict[str, Any]: + if not body.key or not body.key.strip(): + raise HTTPException(status_code=400, detail="key must not be empty") + _write_app_setting(conn, body.key.strip(), body.value) + return {"ok": True} diff --git a/src/website_profiling/api/routers/content.py b/src/website_profiling/api/routers/content.py new file mode 100644 index 00000000..aa281200 --- /dev/null +++ b/src/website_profiling/api/routers/content.py @@ -0,0 +1,257 @@ +"""Content routers — /api/content/* and /api/backlinks/* and /api/content-drafts/*.""" +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db +from website_profiling.db import content_draft_store +from website_profiling.integrations.google.gsc_links_store import list_backlinks_velocity + +router = APIRouter(tags=["content"]) + +DbDep = Annotated[Connection, Depends(get_db)] + +_VALID_WIZARD_STEPS = {"intents", "content_types", "tones", "titles", "outline", "draft", "research"} + + +# ── GET /api/backlinks/velocity ────────────────────────────────────────────── + +@router.get("/backlinks/velocity") +def backlinks_velocity( + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + if not propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + return {"snapshots": list_backlinks_velocity(conn, propertyId)} + + +# ── POST /api/backlinks/competitor-import ──────────────────────────────────── + +@router.post("/backlinks/competitor-import") +def backlinks_competitor_import( + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + competitor = str(body.get("competitor") or "").strip() + csv_text = str(body.get("csvText") or "") + our_domains = body.get("ourDomains") or [] + + if not competitor or not csv_text.strip(): + raise HTTPException(status_code=400, detail="competitor and csvText required") + + try: + from website_profiling.integrations.google.competitor_links import ( # type: ignore[import] + parse_referring_domains_from_csv, + build_competitor_domain_gap, + ) + + refs = parse_referring_domains_from_csv(csv_text) + gap = build_competitor_domain_gap(set(our_domains), competitor, refs) + return {"gap": gap} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Competitor backlink import failed: {exc}") + + +# ── POST /api/backlinks/third-party-import ─────────────────────────────────── + +@router.post("/backlinks/third-party-import") +def backlinks_third_party_import( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + property_id = int(body.get("propertyId") or 0) + provider = str(body.get("provider") or "moz").strip().lower() + csv_text = str(body.get("csvText") or "") + our_domains = body.get("ourDomains") or [] + + if not property_id or not csv_text.strip(): + raise HTTPException(status_code=400, detail="propertyId and csvText required") + if provider not in ("moz", "majestic"): + raise HTTPException(status_code=400, detail="provider must be moz or majestic") + + try: + from website_profiling.integrations.links.third_party_csv import ( # type: ignore[import] + build_third_party_overlay, + ) + from website_profiling.integrations.google.gsc_links_store import ( # type: ignore[import] + import_third_party_links_overlay, + ) + + overlay = build_third_party_overlay(provider, csv_text, our_domains) + result = import_third_party_links_overlay(conn, property_id, overlay) + return result # type: ignore[return-value] + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Third-party backlink import failed: {exc}") + + +# ── POST /api/content/analyze ───────────────────────────────────────────────── + +@router.post("/content/analyze") +def content_analyze( + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + keyword = str(body.get("keyword") or "").strip() + if not keyword: + raise HTTPException(status_code=400, detail="keyword required") + + property_id_raw = body.get("propertyId") + property_id = int(property_id_raw) if property_id_raw else None + + try: + from website_profiling.content_studio.ai_suggest import analyze_content_draft # type: ignore[import] + + analysis = analyze_content_draft( + property_id, + keyword, + body.get("bodyHtml") or "", + body.get("titleTag") or "", + body.get("metaDescription") or "", + body.get("landingUrl") or None, + use_ai=bool(body.get("useAi")), + refresh=bool(body.get("refresh")), + title=body.get("title") or "", + ) + return {"analysis": analysis} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Content analyze failed: {exc}") + + +# ── POST /api/content/score ─────────────────────────────────────────────────── + +@router.post("/content/score") +def content_score( + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + keyword = str(body.get("keyword") or "").strip() + if not keyword: + raise HTTPException(status_code=400, detail="keyword required") + + property_id_raw = body.get("propertyId") + property_id = int(property_id_raw) if property_id_raw else None + + try: + from website_profiling.content_studio.score import score_content_draft # type: ignore[import] + + score = score_content_draft( + property_id, + keyword, + body.get("bodyHtml") or "", + body.get("titleTag") or "", + body.get("metaDescription") or "", + body.get("landingUrl") or None, + ) + return {"score": score} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Content score failed: {exc}") + + +# ── POST /api/content/wizard ────────────────────────────────────────────────── + +@router.post("/content/wizard") +def content_wizard( + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + step = str(body.get("step") or "").strip() + if step not in _VALID_WIZARD_STEPS: + raise HTTPException(status_code=400, detail="Invalid wizard step") + + payload = { + "keyword": str(body.get("keyword") or "").strip(), + "locale": str(body.get("locale") or "en-US"), + "intent": str(body.get("intent") or ""), + "contentType": str(body.get("contentType") or ""), + "tone": str(body.get("tone") or ""), + "title": str(body.get("title") or ""), + "outline": body.get("outline") if isinstance(body.get("outline"), list) else [], + } + + try: + from website_profiling.content_studio.wizard import run_wizard_step # type: ignore[import] + + result = run_wizard_step(step, payload) + if isinstance(result, dict) and result.get("ok") is False: + raise HTTPException(status_code=400, detail=result.get("error") or "Wizard step failed") + return {"result": result} + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Wizard step failed: {exc}") + + +# ── GET /api/content-drafts ─────────────────────────────────────────────────── + +@router.get("/content-drafts") +def list_content_drafts_route( + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + if not propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + return {"drafts": content_draft_store.list_content_drafts(conn, propertyId)} + + +# ── POST /api/content-drafts ────────────────────────────────────────────────── + +@router.post("/content-drafts") +def create_content_draft_route( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + property_id = int(body.get("propertyId") or 0) + if not property_id: + raise HTTPException(status_code=400, detail="propertyId required") + + draft_id = content_draft_store.create_content_draft( + conn, + property_id, + title=str(body.get("title") or "Untitled draft"), + target_keyword=str(body.get("target_keyword") or ""), + landing_url=str(body.get("landing_url") or "").strip() or None, + status=str(body.get("status") or "draft"), + body_html=str(body.get("body_html") or ""), + title_tag=str(body.get("title_tag") or ""), + meta_description=str(body.get("meta_description") or ""), + ) + return {"id": draft_id, "propertyId": property_id} + + +# ── GET /api/content-drafts/{id} ───────────────────────────────────────────── + +@router.get("/content-drafts/{draft_id}") +def get_content_draft_route(conn: DbDep, draft_id: int) -> dict[str, Any]: + if not draft_id: + raise HTTPException(status_code=400, detail="invalid draft id") + draft = content_draft_store.get_content_draft(conn, draft_id) + if not draft: + raise HTTPException(status_code=404, detail="draft not found") + return {"draft": draft} + + +# ── PATCH /api/content-drafts/{id} ─────────────────────────────────────────── + +@router.patch("/content-drafts/{draft_id}") +def update_content_draft_route( + conn: DbDep, + draft_id: int, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + if not draft_id: + raise HTTPException(status_code=400, detail="invalid draft id") + draft = content_draft_store.update_content_draft(conn, draft_id, body) + if not draft: + raise HTTPException(status_code=404, detail="draft not found") + return {"draft": draft} + + +# ── DELETE /api/content-drafts/{id} ────────────────────────────────────────── + +@router.delete("/content-drafts/{draft_id}") +def delete_content_draft_route(conn: DbDep, draft_id: int) -> dict[str, Any]: + if not draft_id: + raise HTTPException(status_code=400, detail="invalid draft id") + if not content_draft_store.delete_content_draft(conn, draft_id): + raise HTTPException(status_code=404, detail="draft not found") + return {"ok": True} diff --git a/src/website_profiling/api/routers/crawl.py b/src/website_profiling/api/routers/crawl.py new file mode 100644 index 00000000..3638c8c5 --- /dev/null +++ b/src/website_profiling/api/routers/crawl.py @@ -0,0 +1,40 @@ +"""Crawl routes: /api/crawl/*""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(tags=["crawl"]) + + +@router.get("/crawl/browser-status") +def browser_status_check() -> dict[str, Any]: + """Return whether Playwright + Chromium are available.""" + from website_profiling.crawl.fetchers import ensure_browser_deps + + return ensure_browser_deps() + + +@router.get("/crawl/page-html") +def get_page_html( + conn: Annotated[Connection, Depends(get_db)], + url: str = Query(..., description="Page URL to retrieve stored HTML for"), + crawlRunId: Optional[int] = Query(None, description="Crawl run ID"), +) -> dict[str, Any]: + """Return stored HTML and metadata for a URL within a crawl run.""" + from website_profiling.db.html_store import read_page_html + + if not crawlRunId: + raise HTTPException(status_code=400, detail="crawlRunId is required") + + result = read_page_html(conn, crawlRunId, url) + if result is None: + raise HTTPException( + status_code=404, + detail=f"No stored HTML found for url={url!r} in crawlRunId={crawlRunId}", + ) + return result diff --git a/src/website_profiling/api/routers/dashboards.py b/src/website_profiling/api/routers/dashboards.py new file mode 100644 index 00000000..095767b5 --- /dev/null +++ b/src/website_profiling/api/routers/dashboards.py @@ -0,0 +1,147 @@ +"""Dashboards router — /api/dashboards/*""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from psycopg import Connection + +from ..deps import get_db +from website_profiling.db import dashboard_store + +router = APIRouter(tags=["dashboards"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class DashboardCreateBody(BaseModel): + propertyId: int + name: Optional[str] = None + layoutJson: Optional[Any] = None + + +class DashboardUpdateBody(BaseModel): + propertyId: int + name: Optional[str] = None + layoutJson: Optional[Any] = None + isDefault: Optional[bool] = None + + +@router.get("/dashboards") +def list_dashboards( + conn: DbDep, + propertyId: int = Query(..., description="Property ID"), +) -> dict[str, Any]: + return {"dashboards": dashboard_store.list_dashboards(conn, propertyId)} + + +@router.post("/dashboards", status_code=201) +def create_dashboard(body: DashboardCreateBody, conn: DbDep) -> dict[str, Any]: + name = (body.name or "Untitled dashboard").strip() or "Untitled dashboard" + layout = body.layoutJson if body.layoutJson is not None else {} + dashboard = dashboard_store.create_dashboard(conn, body.propertyId, name, layout) + return {"dashboard": dashboard} + + +@router.get("/dashboards/{dashboard_id}") +def get_dashboard( + dashboard_id: int, + conn: DbDep, + propertyId: int = Query(..., description="Property ID"), +) -> dict[str, Any]: + dashboard = dashboard_store.get_dashboard(conn, dashboard_id, propertyId) + if not dashboard: + raise HTTPException(status_code=404, detail="Not found") + return {"dashboard": dashboard} + + +@router.put("/dashboards/{dashboard_id}") +def update_dashboard(dashboard_id: int, body: DashboardUpdateBody, conn: DbDep) -> dict[str, Any]: + dashboard = dashboard_store.update_dashboard( + conn, + dashboard_id, + body.propertyId, + name=body.name.strip() if body.name is not None else None, + layout_json=body.layoutJson, + is_default=body.isDefault, + ) + if not dashboard: + raise HTTPException(status_code=404, detail="Not found") + return {"dashboard": dashboard} + + +@router.delete("/dashboards/{dashboard_id}") +def delete_dashboard( + dashboard_id: int, + conn: DbDep, + propertyId: int = Query(..., description="Property ID"), +) -> dict[str, Any]: + if not dashboard_store.delete_dashboard(conn, dashboard_id, propertyId): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +class DashboardAiGenerateBody(BaseModel): + mode: str + prompt: str + catalog: list[dict[str, Any]] + viz_types: dict[str, str] + dashscript_help: str + toolName: Optional[str] = None + propertyId: Optional[int] = None + reportId: Optional[int] = None + current: Optional[Any] = None + sample: Optional[dict[str, Any]] = None + + +def _truncate_tool_sample(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, val in data.items(): + out[key] = val[:2] if isinstance(val, list) else val + return out + + +@router.post("/dashboards/ai-generate") +def dashboards_ai_generate(body: DashboardAiGenerateBody, conn: DbDep) -> JSONResponse: + """Generate DashScript, a widget, or a full dashboard via LLM.""" + mode = str(body.mode or "widget").strip().lower() + if mode not in {"script", "widget", "dashboard"}: + raise HTTPException(status_code=400, detail="mode must be script, widget, or dashboard") + prompt = str(body.prompt or "").strip() + if not prompt: + raise HTTPException(status_code=400, detail="prompt required") + + payload: dict[str, Any] = { + "mode": mode, + "prompt": prompt, + "catalog": body.catalog, + "viz_types": body.viz_types, + "dashscript_help": body.dashscript_help, + "current": body.current, + } + + if body.sample is not None: + payload["sample"] = body.sample + elif body.toolName and body.propertyId and mode in ("script", "widget"): + try: + from website_profiling.tools.audit_tools import AuditToolContext + from website_profiling.tools.audit_tools.registry import dispatch_tool + + ctx = AuditToolContext(property_id=body.propertyId, report_id=body.reportId) + tool_result = dispatch_tool(body.toolName, {}, context=ctx, conn=conn) + if isinstance(tool_result, dict) and "error" not in tool_result: + payload["sample"] = _truncate_tool_sample(tool_result) + except Exception: + pass + + from website_profiling.db.config_store import read_llm_config + from website_profiling.llm.dashboard_ai import generate_dashboard_ai + + cfg = read_llm_config(conn) + result = generate_dashboard_ai(payload, cfg=cfg or None) + if result.get("ok") is False: + status = 503 if result.get("missing") else 500 + return JSONResponse(content=result, status_code=status) + return JSONResponse(content=result) diff --git a/src/website_profiling/api/routers/filters.py b/src/website_profiling/api/routers/filters.py new file mode 100644 index 00000000..09d3c9d6 --- /dev/null +++ b/src/website_profiling/api/routers/filters.py @@ -0,0 +1,53 @@ +"""Saved filters router — /api/filters""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from psycopg import Connection + +from ..deps import get_db +from website_profiling.db import saved_filter_store + +router = APIRouter(tags=["filters"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class FilterUpsertBody(BaseModel): + propertyId: int + name: str + filterJson: Optional[Any] = None + + +class FilterDeleteBody(BaseModel): + propertyId: int + name: str + + +@router.get("/filters") +def list_filters( + conn: DbDep, + propertyId: int = Query(..., description="Property ID"), +) -> dict[str, Any]: + return {"filters": saved_filter_store.list_saved_filters(conn, propertyId)} + + +@router.post("/filters") +def upsert_filter(body: FilterUpsertBody, conn: DbDep) -> dict[str, Any]: + name = (body.name or "").strip() + if not body.propertyId or not name: + raise HTTPException(status_code=400, detail="propertyId and name required") + filter_json = body.filterJson if isinstance(body.filterJson, dict) else {} + saved_filter_store.upsert_saved_filter(conn, body.propertyId, name, filter_json) + return {"ok": True} + + +@router.delete("/filters") +def delete_filter(body: FilterDeleteBody, conn: DbDep) -> dict[str, Any]: + name = (body.name or "").strip() + if not body.propertyId or not name: + raise HTTPException(status_code=400, detail="propertyId and name required") + saved_filter_store.delete_saved_filter(conn, body.propertyId, name) + return {"ok": True} diff --git a/src/website_profiling/api/routers/health.py b/src/website_profiling/api/routers/health.py new file mode 100644 index 00000000..fe00d023 --- /dev/null +++ b/src/website_profiling/api/routers/health.py @@ -0,0 +1,17 @@ +"""GET /api/health — liveness + DB check.""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def health_check(conn: Annotated[Connection, Depends(get_db)]) -> dict: + conn.execute("SELECT 1") + return {"ok": True, "database": "up"} diff --git a/src/website_profiling/api/routers/integrations.py b/src/website_profiling/api/routers/integrations.py new file mode 100644 index 00000000..b6432cc5 --- /dev/null +++ b/src/website_profiling/api/routers/integrations.py @@ -0,0 +1,550 @@ +"""Integrations routers — /api/integrations/google/* and /api/integrations/bing/*.""" +from __future__ import annotations + +import json +import subprocess +import sys +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(prefix="/integrations", tags=["integrations"]) + +DbDep = Annotated[Connection, Depends(get_db)] + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _google_public_status(conn: Connection) -> dict[str, Any]: + """Build a public status dict from google_app_settings.""" + from website_profiling.db.google_app_store import read_google_app_settings + + cfg = read_google_app_settings(conn) + has_client_id = bool(cfg.get("client_id")) + has_client_secret = bool(cfg.get("client_secret")) + has_service_account = bool(cfg.get("service_account_json")) + sa = cfg.get("service_account_json") or {} + return { + "hasClientId": has_client_id, + "hasClientSecret": has_client_secret, + "hasOAuthApp": has_client_id and has_client_secret, + "hasServiceAccount": has_service_account, + "serviceAccountEmail": sa.get("client_email") if has_service_account else None, + "dateRangeDays": cfg.get("default_date_range_days", 28), + "hasDeveloperToken": bool(cfg.get("developer_token")), + "hasLoginCustomerId": bool(cfg.get("login_customer_id")), + } + + +# ── GET /api/integrations/google/credentials ────────────────────────────────── + +@router.get("/google/credentials") +def get_google_credentials(conn: DbDep) -> dict[str, Any]: + """Full app-level Google OAuth settings (server-side / local admin only).""" + from website_profiling.db.google_app_store import read_google_app_settings + + cfg = read_google_app_settings(conn) + sa = cfg.get("service_account_json") + return { + "clientId": str(cfg.get("client_id") or "").strip(), + "clientSecret": str(cfg.get("client_secret") or "").strip(), + "serviceAccount": sa if isinstance(sa, dict) else None, + "dateRangeDays": int(cfg.get("default_date_range_days") or 28), + "developerToken": str(cfg.get("developer_token") or "").strip(), + "loginCustomerId": str(cfg.get("login_customer_id") or "").strip(), + } + + +# ── GET /api/integrations/google/status ─────────────────────────────────────── + +@router.get("/google/status") +def google_status(conn: DbDep) -> dict[str, Any]: + from website_profiling.integrations.google.store import read_last_google_fetched_at + + status = _google_public_status(conn) + status["lastFetchedAt"] = read_last_google_fetched_at(conn) + return status + + +# ── POST /api/integrations/google/credentials ───────────────────────────────── + +@router.post("/google/credentials") +def save_google_credentials( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + _PROPERTY_ONLY_MSG = ( + "Per-site settings (GSC, GA4, refresh token) must be saved via property " + "Integrations when a Site URL is set." + ) + if any(k in body for k in ("refreshToken", "gscSiteUrl", "ga4PropertyId")): + raise HTTPException(status_code=400, detail=_PROPERTY_ONLY_MSG) + + from website_profiling.db.google_app_store import save_google_app_settings + + patch: dict[str, Any] = {} + if isinstance(body.get("clientId"), str) and body["clientId"].strip(): + patch["client_id"] = body["clientId"].strip() + if isinstance(body.get("clientSecret"), str) and body["clientSecret"].strip(): + patch["client_secret"] = body["clientSecret"].strip() + if isinstance(body.get("dateRangeDays"), (int, float)) and body["dateRangeDays"] > 0: + patch["default_date_range_days"] = int(body["dateRangeDays"]) + if isinstance(body.get("developerToken"), str) and body["developerToken"].strip(): + patch["developer_token"] = body["developerToken"].strip() + if isinstance(body.get("loginCustomerId"), str) and body["loginCustomerId"].strip(): + patch["login_customer_id"] = body["loginCustomerId"].strip().replace("-", "") + + if not patch: + raise HTTPException(status_code=400, detail="No valid fields provided") + + save_google_app_settings(conn, patch) + return {"ok": True, "status": _google_public_status(conn)} + + +# ── POST /api/integrations/google/credentials/upload ────────────────────────── + +@router.post("/google/credentials/upload") +def upload_google_credentials( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + from website_profiling.db.google_app_store import save_google_app_settings + + raw = body.get("fileContent") + if not raw or not isinstance(raw, str): + raise HTTPException(status_code=400, detail="fileContent is required") + + try: + parsed = json.loads(raw) + except Exception: + raise HTTPException(status_code=400, detail="This doesn't look like a valid JSON file.") + + if ( + not isinstance(parsed, dict) + or parsed.get("type") != "service_account" + or not isinstance(parsed.get("client_email"), str) + or not isinstance(parsed.get("private_key"), str) + ): + raise HTTPException( + status_code=400, + detail=( + "This doesn't look like a Google service account key file. " + "Make sure you downloaded the JSON key from Google Cloud Console > " + "IAM & Admin > Service Accounts." + ), + ) + + save_google_app_settings(conn, {"service_account_json": parsed}) + return {"ok": True, "status": _google_public_status(conn)} + + +# ── POST /api/integrations/google/disconnect ────────────────────────────────── + +@router.post("/google/disconnect") +def google_disconnect(conn: DbDep) -> dict[str, Any]: + """Global disconnect is deprecated — use per-property disconnect.""" + return { + "ok": False, + "error": ( + "Disconnect Google per site: set Site URL, open Integrations, " + "and use Disconnect on that property." + ), + "status": _google_public_status(conn), + } + + +# ── GET /api/integrations/google/properties ─────────────────────────────────── + +@router.get("/google/properties") +def google_properties_deprecated( + property_id: Optional[int] = Query(None, alias="propertyId"), +) -> dict[str, Any]: + """Deprecated — use /api/properties/{id}/google/properties.""" + if not property_id: + raise HTTPException( + status_code=400, + detail="propertyId query parameter is required. Use /api/properties/{id}/google/properties instead.", + ) + raise HTTPException( + status_code=301, + detail=f"Use /api/properties/{property_id}/google/properties", + ) + + +# ── POST /api/integrations/google/test ──────────────────────────────────────── + +@router.post("/google/test") +def google_test() -> dict[str, Any]: + """Run `python -m src google --test` and return stdout log.""" + import subprocess + import sys + + try: + result = subprocess.run( + [sys.executable, "-m", "src", "google", "--test"], + capture_output=True, + text=True, + timeout=30, + ) + log = (result.stdout + result.stderr)[-28_000:] + return {"ok": result.returncode == 0, "log": log, "exitCode": result.returncode} + except subprocess.TimeoutExpired: + return {"ok": False, "log": "", "error": "Test timed out after 30s"} + except Exception as exc: + return {"ok": False, "log": "", "error": str(exc)} + + +# ── GET /api/integrations/google/page-data ──────────────────────────────────── + +@router.get("/google/page-data") +def google_page_data( + conn: DbDep, + url: str = Query(...), + googleSnapshotId: Optional[int] = Query(None), + propertyId: Optional[str] = Query(None), + domain: Optional[str] = Query(None), +) -> dict[str, Any]: + from website_profiling.db.property_store import resolve_property_id_for_page + from website_profiling.integrations.google.page_lookup import slice_from_google_row + from website_profiling.integrations.google.store import read_google_snapshot_row + + if not url: + raise HTTPException(status_code=400, detail="url parameter required") + + property_id = resolve_property_id_for_page(conn, url, propertyId, domain) + + _empty = { + "source": "snapshot", + "snapshotId": None, + "gsc": None, + "ga4": None, + "coverage": {"inCrawl": False, "inGsc": False, "inGa4": False}, + "siteBenchmarks": {"gsc": None, "ga4": None}, + "dateRange": {}, + "fetchedAt": None, + } + + if property_id is None: + return _empty + + snap = read_google_snapshot_row( + conn, + property_id, + snapshot_id=googleSnapshotId, + ) + if not snap: + return _empty + + slice_data = slice_from_google_row(snap["data"], url) + return { + **slice_data, + "snapshotId": snap["id"], + "fetchedAt": snap["fetchedAt"] or slice_data.get("fetchedAt"), + } + + +# ── GET /api/integrations/google/page-data/history ──────────────────────────── + +@router.get("/google/page-data/history") +def google_page_data_history( + conn: DbDep, + url: str = Query(...), + propertyId: Optional[str] = Query(None), + domain: Optional[str] = Query(None), +) -> dict[str, Any]: + from website_profiling.db.property_store import resolve_property_id_for_page + from website_profiling.integrations.google.page_lookup import ( + slice_from_google_row, + summary_from_slice, + ) + from website_profiling.integrations.google.store import list_google_snapshot_rows + + if not url: + raise HTTPException(status_code=400, detail="url parameter required") + + property_id = resolve_property_id_for_page(conn, url, propertyId, domain) + if property_id is None: + return {"url": url, "history": []} + + history: list[dict[str, Any]] = [] + for snap in list_google_snapshot_rows(conn, property_id, limit=10): + slice_data = slice_from_google_row(snap["data"], url) + if not slice_data.get("gsc") and not slice_data.get("ga4"): + continue + summary = summary_from_slice(slice_data.get("gsc"), slice_data.get("ga4")) + history.append({ + "id": snap["id"], + "fetchedAt": snap["fetchedAt"], + "type": "snapshot", + "gsc": summary.get("gsc"), + "ga4": summary.get("ga4"), + }) + + return {"url": url, "history": history} + + +# ── POST /api/integrations/google/page-live ─────────────────────────────────── + +@router.post("/google/page-live") +def google_page_live( + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + url = str(body.get("url") or "").strip() + if not url: + raise HTTPException(status_code=400, detail="url is required") + + try: + result = subprocess.run( + [sys.executable, "-m", "src", "page-live", "--url", url], + capture_output=True, + text=True, + timeout=45, + ) + combined = result.stdout + result.stderr + log = combined[-28_000:] + lines = [ln for ln in result.stdout.strip().splitlines() if ln] + last = lines[-1] if lines else "{}" + try: + data = json.loads(last) + except Exception: + data = {} + + if result.returncode != 0 and not data.get("ok") and not data.get("gsc") and not data.get("ga4"): + raise HTTPException( + status_code=500, + detail=data.get("error") or "Live fetch failed", + ) + import datetime + return {"ok": True, "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z", **data} + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="Live fetch timed out after 45s") + + +# ── GET /api/integrations/google/keywords/by-page ───────────────────────────── + +@router.get("/google/keywords/by-page") +def google_keywords_by_page( + conn: DbDep, + url: str = Query(..., alias="url"), + propertyId: Optional[str] = Query(None), + domain: Optional[str] = Query(None), +) -> dict[str, Any]: + from website_profiling.db.property_store import resolve_property_id_for_page + from website_profiling.integrations.google.keyword_store import read_latest_keyword_data + + page_url = url.strip() + if not page_url: + raise HTTPException(status_code=400, detail="url parameter is required") + + property_id = resolve_property_id_for_page(conn, page_url, propertyId, domain) + if property_id is None: + raise HTTPException(status_code=400, detail="propertyId or domain required") + + data = read_latest_keyword_data(conn, property_id) or {} + all_rows = data.get("rows") or [] + normalized_target = page_url.lower().rstrip("/") + + page_keywords = [ + r for r in all_rows + if _matches_url(r.get("gsc_url") or "", normalized_target) + ] + + cannib_raw = data.get("cannibalisation") or [] + cannib = [ + c for c in cannib_raw + if any( + (p.get("url") or "").lower().rstrip("/") == normalized_target + for p in (c.get("pages") or []) + ) + ] + + return { + "url": page_url, + "propertyId": property_id, + "keyword_count": len(page_keywords), + "keywords": page_keywords, + "cannibalisation": cannib, + "fetched_at": data.get("fetched_at"), + } + + +def _matches_url(candidate: str, target: str) -> bool: + u = candidate.lower().rstrip("/") + return u == target or u in target or target in u + + +# ── GET /api/integrations/google/keywords/history ──────────────────────────── + +@router.get("/google/keywords/history") +def google_keywords_history( + conn: DbDep, + keyword: str = Query(...), + propertyId: Optional[str] = Query(None), + domain: Optional[str] = Query(None), + limit: int = Query(30, ge=1, le=90), +) -> dict[str, Any]: + from website_profiling.db.property_store import resolve_property_id_for_page + from website_profiling.integrations.google.keyword_store import read_keyword_history + + keyword = keyword.strip() + if not keyword: + raise HTTPException(status_code=400, detail="keyword parameter is required") + + property_id = resolve_property_id_for_page(conn, "", propertyId, domain) + if property_id is None: + raise HTTPException(status_code=400, detail="propertyId or domain required") + + history = read_keyword_history(conn, keyword, limit, property_id=property_id) + return {"keyword": keyword, "propertyId": property_id, "history": history} + + +# ── POST /api/integrations/bing/sync ───────────────────────────────────────── + +@router.post("/bing/sync") +def bing_sync(conn: DbDep) -> dict[str, Any]: + """Fetch Bing Webmaster backlinks summary using config from DB.""" + from website_profiling.db.config_store import read_pipeline_config + + try: + state, _ = read_pipeline_config(conn) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + api_key = str(state.get("bing_webmaster_api_key") or "").strip() + site_url = str(state.get("start_url") or "").strip() + + if not api_key or not site_url: + raise HTTPException( + status_code=400, + detail="Set bing_webmaster_api_key and start_url in pipeline settings.", + ) + + try: + from website_profiling.integrations.bing.webmaster import fetch_bing_backlinks_summary + + result = fetch_bing_backlinks_summary(api_key, site_url) + return result # type: ignore[return-value] + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +# ── GET /api/integrations/google/page-compare ──────────────────────────────── + +@router.get("/google/page-compare") +def google_page_compare( + conn: DbDep, + url: str = Query(...), + currentType: str = Query("snapshot"), + currentId: int = Query(...), + baselineType: str = Query("snapshot"), + baselineId: int = Query(...), +) -> dict[str, Any]: + """Compare two page Google data snapshots.""" + from website_profiling.integrations.google.page_snapshot_store import read_page_snapshot_compare + + current = read_page_snapshot_compare(conn, currentId) + baseline = read_page_snapshot_compare(conn, baselineId) + if current is None: + raise HTTPException(status_code=404, detail="Current snapshot not found") + if baseline is None: + raise HTTPException(status_code=404, detail="Baseline snapshot not found") + return {"url": url, "current": current, "baseline": baseline} + + +# ── GET /api/integrations/google/page-live/history ──────────────────────────── + +@router.get("/google/page-live/history") +def google_page_live_history( + conn: DbDep, + url: str = Query(...), + limit: int = Query(15, ge=1, le=50), +) -> dict[str, Any]: + """Return history of page Google snapshots for a URL.""" + from website_profiling.integrations.google.page_snapshot_store import list_page_snapshot_api_history + + try: + history = list_page_snapshot_api_history(conn, url, limit=limit) + return {"url": url, "history": history} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +# ── POST /api/integrations/google/keywords/history/batch ───────────────────── + +@router.post("/google/keywords/history/batch") +def google_keywords_history_batch( + conn: DbDep, + body: dict[str, Any], +) -> dict[str, Any]: + """Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str }""" + from website_profiling.db.property_store import get_property_id_by_domain + from website_profiling.integrations.google.keyword_store import read_keyword_history_batch + + keywords_raw = body.get("keywords") or [] + if not isinstance(keywords_raw, list): + raise HTTPException(status_code=400, detail="keywords must be a list") + keywords = [str(k).strip() for k in keywords_raw[:100] if k] + limit = max(1, min(int(body.get("limit") or 30), 90)) + property_id = None + if body.get("propertyId"): + try: + property_id = int(body["propertyId"]) + except (TypeError, ValueError): + pass + elif body.get("domain"): + property_id = get_property_id_by_domain(conn, str(body["domain"])) + + if property_id is None: + raise HTTPException(status_code=400, detail="propertyId or domain required") + + results = read_keyword_history_batch( + conn, + keywords, + property_id=property_id, + limit=limit, + ) + return {"keywords": results, "propertyId": property_id} + + +# ── GET/POST /api/integrations/google/keywords/expand ──────────────────────── + +@router.post("/google/keywords/expand") +def google_keywords_expand( + conn: DbDep, + body: dict[str, Any], +) -> dict[str, Any]: + """Expand keyword ideas from Google Keyword Planner or suggest API.""" + keyword = str(body.get("keyword") or "").strip() + if not keyword: + raise HTTPException(status_code=400, detail="keyword required") + try: + from website_profiling.tools.keyword_suggestions import expand_keyword + result = expand_keyword(keyword, body.get("propertyId"), conn) + return result if isinstance(result, dict) else {"keywords": result} + except ImportError: + raise HTTPException(status_code=501, detail="Keyword expansion unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +# ── POST /api/integrations/google/keywords/planner ──────────────────────────── + +@router.post("/google/keywords/planner") +def google_keywords_planner( + conn: DbDep, + body: dict[str, Any], +) -> dict[str, Any]: + """Fetch keyword planner data from Google Ads API.""" + keywords_raw = body.get("keywords") or [] + if not isinstance(keywords_raw, list): + raise HTTPException(status_code=400, detail="keywords must be a list") + try: + from website_profiling.integrations.google.keyword_planner import fetch_keyword_ideas + result = fetch_keyword_ideas(conn, keywords_raw) + return result if isinstance(result, dict) else {"ideas": result} + except ImportError: + raise HTTPException(status_code=501, detail="Google Keyword Planner unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/issues.py b/src/website_profiling/api/routers/issues.py new file mode 100644 index 00000000..1f405ef8 --- /dev/null +++ b/src/website_profiling/api/routers/issues.py @@ -0,0 +1,148 @@ +"""Issues routers — /api/issues/* and /api/ai/*.""" +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db +from website_profiling.db import issue_status_store + +router = APIRouter(tags=["issues"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +# ── GET /api/issues/status ──────────────────────────────────────────────────── + +@router.get("/issues/status") +def list_issue_status_route( + conn: DbDep, + propertyId: int = Query(...), +) -> dict[str, Any]: + if not propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + return {"issues": issue_status_store.list_issue_status(conn, propertyId)} + + +# ── PUT /api/issues/status ──────────────────────────────────────────────────── + +@router.put("/issues/status") +def upsert_issue_status_route( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + property_id = int(body.get("propertyId") or 0) + message = str(body.get("message") or "").strip() + status = str(body.get("status") or "") + + if not property_id or not message or not status: + raise HTTPException( + status_code=400, + detail="propertyId, message, and valid status required", + ) + + report_id = body.get("reportId") + try: + issue = issue_status_store.upsert_issue_status( + conn, + property_id=property_id, + message=message, + status=status, + report_id=int(report_id) if report_id is not None else None, + url=str(body.get("url") or ""), + priority=str(body.get("priority") or "Medium"), + category_id=body.get("categoryId") or None, + assignee=body.get("assignee") or None, + note=body.get("note") or None, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"issue": issue} + + +# ── POST /api/issues/fix-suggestion ────────────────────────────────────────── + +@router.post("/issues/fix-suggestion") +def issues_fix_suggestion( + body: dict[str, Any] = Body(default={}), +) -> Any: + message = str(body.get("message") or "").strip() + if not message: + raise HTTPException(status_code=400, detail="message required") + + payload = { + "source": "issue", + "message": message, + "url": body.get("url"), + "priority": body.get("priority"), + "category": body.get("category"), + "recommendation": body.get("recommendation"), + "type": body.get("type"), + "refresh": body.get("refresh"), + } + + try: + from website_profiling.llm.fix_suggestions import generate_fix_suggestion # type: ignore[import] + + return generate_fix_suggestion(payload, refresh=bool(payload.get("refresh"))) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Fix suggestion failed: {exc}") + + +# ── POST /api/issues/action-plan ────────────────────────────────────────────── + +@router.post("/issues/action-plan") +def issues_action_plan( + body: dict[str, Any] = Body(default={}), +) -> Any: + domain = str(body.get("domain") or "").strip() + if not domain: + raise HTTPException(status_code=400, detail="domain required") + if not isinstance(body.get("issues"), list) or len(body["issues"]) == 0: + raise HTTPException(status_code=400, detail="issues required") + + payload = { + "domain": domain, + "issues": body["issues"], + "refresh": body.get("refresh"), + } + + try: + from website_profiling.llm.issues_action_plan import generate_issues_action_plan # type: ignore[import] + + return generate_issues_action_plan(payload, refresh=bool(payload.get("refresh"))) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Action plan failed: {exc}") + + +# ── POST /api/ai/fix-suggestion ────────────────────────────────────────────── + +@router.post("/ai/fix-suggestion") +def ai_fix_suggestion( + body: dict[str, Any] = Body(default={}), +) -> Any: + message = str(body.get("message") or "").strip() + if not message: + raise HTTPException(status_code=400, detail="message required") + + payload = { + "source": body.get("source") or "issue", + "message": message, + "url": body.get("url"), + "refresh": body.get("refresh"), + "context": body.get("context"), + "priority": body.get("priority"), + "category": body.get("category"), + "recommendation": body.get("recommendation"), + "type": body.get("type"), + } + + try: + from website_profiling.llm.fix_suggestions import generate_fix_suggestion # type: ignore[import] + + return generate_fix_suggestion(payload, refresh=bool(payload.get("refresh"))) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Fix suggestion failed: {exc}") diff --git a/src/website_profiling/api/routers/keywords.py b/src/website_profiling/api/routers/keywords.py new file mode 100644 index 00000000..4c676cf0 --- /dev/null +++ b/src/website_profiling/api/routers/keywords.py @@ -0,0 +1,72 @@ +"""Keywords routers — /api/keywords/*.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Body, Depends, HTTPException + +from ..deps import get_db +from psycopg import Connection + +router = APIRouter(prefix="/keywords", tags=["keywords"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +# ── POST /api/keywords/competitor-import ────────────────────────────────────── + +@router.post("/competitor-import") +def keywords_competitor_import( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + property_id = int(body.get("propertyId") or 0) + competitor = str(body.get("competitor") or "").strip() + csv_text = str(body.get("csvText") or "") + + if not property_id or not competitor or not csv_text.strip(): + raise HTTPException( + status_code=400, + detail="propertyId, competitor, and csvText required", + ) + + try: + from website_profiling.integrations.keywords.competitor_csv import ( # type: ignore[import] + parse_competitor_keyword_csv, + ) + from website_profiling.integrations.keywords.competitor_gap_store import ( # type: ignore[import] + merge_competitor_keyword_import, + ) + + rows = parse_competitor_keyword_csv(csv_text, competitor=competitor) + merged = merge_competitor_keyword_import(conn, property_id, competitor, rows) + return { + "count": len(rows), + "rows": rows[:500], + "mergedCount": len(merged), + "mergedRows": merged[:500], + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Competitor keyword import failed: {exc}") + + +# ── POST /api/keywords/content-brief ───────────────────────────────────────── + +@router.post("/content-brief") +def keywords_content_brief( + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + keyword = str(body.get("keyword") or "").strip() + if not keyword: + raise HTTPException(status_code=400, detail="keyword required") + + rows = body.get("rows") or [] + gaps = body.get("gaps") or [] + + try: + from website_profiling.llm.content_brief import generate_content_brief # type: ignore[import] + + brief = generate_content_brief(keyword, rows, gaps) + return {"brief": brief} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Content brief generation failed: {exc}") diff --git a/src/website_profiling/api/routers/logs.py b/src/website_profiling/api/routers/logs.py new file mode 100644 index 00000000..f8e472db --- /dev/null +++ b/src/website_profiling/api/routers/logs.py @@ -0,0 +1,33 @@ +"""Access log upload and analysis — /api/logs/*.""" +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(tags=["logs"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +@router.post("/logs/upload") +def logs_upload( + conn: DbDep, + propertyId: int = Form(...), + file: UploadFile = File(...), +) -> dict[str, Any]: + if not propertyId: + raise HTTPException(status_code=400, detail="propertyId required") + content = file.file.read().decode("utf-8", errors="replace") + try: + from website_profiling.tools.log_analysis import parse_and_store_access_log + + result = parse_and_store_access_log(conn, propertyId, content) + return result if isinstance(result, dict) else {"ok": True} + except ImportError: + raise HTTPException(status_code=501, detail="Log analysis module unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/mcp_tools.py b/src/website_profiling/api/routers/mcp_tools.py new file mode 100644 index 00000000..45cb6b49 --- /dev/null +++ b/src/website_profiling/api/routers/mcp_tools.py @@ -0,0 +1,41 @@ +"""MCP audit tool catalog — /api/mcp-tools.""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(tags=["mcp-tools"]) + + +@router.get("/mcp-tools") +def mcp_tools() -> dict[str, Any]: + try: + from website_profiling.tools.audit_tools.registry import ( + TOOL_DEFINITIONS, + get_tool_meta, + mcp_tool_names, + ) + from website_profiling.tools.audit_tools.tool_domains import ( + MCP_DOMAIN_BUNDLES, + classify_tool_domain, + ) + + bundle_sets = {b: mcp_tool_names(b) for b in MCP_DOMAIN_BUNDLES.keys()} + tools = [] + for spec in TOOL_DEFINITIONS: + name = spec.get("name", "") + if not name: + continue + meta = get_tool_meta(name) or {} + domain = meta.get("domain") or classify_tool_domain(name) + in_bundles = [b for b, names in bundle_sets.items() if name in names] + tools.append({ + "name": name, + "description": spec.get("description", ""), + "domain": domain, + "bundles": in_bundles, + }) + return {"tools": tools, "bundles": list(MCP_DOMAIN_BUNDLES.keys())} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/ollama.py b/src/website_profiling/api/routers/ollama.py new file mode 100644 index 00000000..315d64ec --- /dev/null +++ b/src/website_profiling/api/routers/ollama.py @@ -0,0 +1,65 @@ +"""Ollama LLM runtime status — /api/ollama/*.""" +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(tags=["ollama"]) + +DbDep = Annotated[Connection, Depends(get_db)] + +DEFAULT_BASE = "http://127.0.0.1:11434" + + +@router.get("/ollama/status") +def ollama_status(conn: DbDep) -> dict[str, Any]: + from website_profiling.db.config_store import read_llm_config + from website_profiling.llm.ollama_catalog import ( + fetch_ollama_models, + model_is_configured, + models_support_tools, + ) + + cfg = read_llm_config(conn) + base_url = str(cfg.get("llm_base_url") or DEFAULT_BASE).rstrip("/") + configured_model = str(cfg.get("llm_model") or "").strip() + + result = fetch_ollama_models(base_url) + if not result.get("ok"): + return { + "ok": False, + "baseUrl": result.get("baseUrl", base_url), + "configuredModel": configured_model, + "error": result.get("error") or "Cannot reach Ollama. Is it running?", + "models": [], + "cloudCatalogOk": False, + "localOk": False, + } + + models = result.get("models") or [] + model_installed = model_is_configured(models, configured_model) + configured_entry = next( + (m for m in models if str(m.get("name") or "").lower() == configured_model.lower()), + None, + ) + + return { + "ok": True, + "baseUrl": result.get("baseUrl", base_url), + "configuredModel": configured_model, + "modelInstalled": model_installed, + "supportsTools": ( + "tools" in (configured_entry.get("capabilities") or []) + if configured_entry + else models_support_tools(models) + ), + "cloudCatalogOk": result.get("cloudCatalogOk", False), + "localOk": result.get("localOk", False), + "catalogSource": "live", + "cloudModelCount": sum(1 for m in models if m.get("source") == "cloud"), + "models": models, + } diff --git a/src/website_profiling/api/routers/page_coach.py b/src/website_profiling/api/routers/page_coach.py new file mode 100644 index 00000000..3567175e --- /dev/null +++ b/src/website_profiling/api/routers/page_coach.py @@ -0,0 +1,48 @@ +"""Internal link page coach — /api/links/page-coach.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from psycopg import Connection +from pydantic import BaseModel + +from ..deps import get_db + +router = APIRouter(tags=["page-coach"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class PageCoachBody(BaseModel): + url: Optional[str] = None + refresh: bool = False + currentType: Optional[str] = None + currentId: Optional[int] = None + baselineType: Optional[str] = None + baselineId: Optional[int] = None + propertyId: Optional[int] = None + + +@router.post("/links/page-coach") +def page_coach(body: PageCoachBody, conn: DbDep) -> dict[str, Any]: + url = (body.url or "").strip() + if not url: + raise HTTPException(status_code=400, detail="url required") + try: + from website_profiling.tools.page_coach import run_page_coach + + return run_page_coach( + conn, + url=url, + refresh=body.refresh, + current_type=body.currentType, + current_id=body.currentId, + baseline_type=body.baselineType, + baseline_id=body.baselineId, + property_id=body.propertyId, + ) + except ImportError: + raise HTTPException(status_code=501, detail="Page coach module unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/page_markdown.py b/src/website_profiling/api/routers/page_markdown.py new file mode 100644 index 00000000..801b6dbe --- /dev/null +++ b/src/website_profiling/api/routers/page_markdown.py @@ -0,0 +1,157 @@ +"""Page markdown routers — /api/page-markdown/*.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db +from website_profiling.db.markdown_store import ( + delete_page_markdown_for_run, + list_markdown_crawl_runs, + list_page_markdown, + read_page_markdown, +) + +router = APIRouter(prefix="/page-markdown", tags=["page-markdown"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +@router.get("") +def list_page_markdown_route( + conn: DbDep, + crawlRunId: int = Query(...), + page: int = Query(1, ge=1), + limit: int = Query(25, ge=1, le=100), + q: Optional[str] = Query(None), +) -> dict[str, Any]: + if not crawlRunId: + raise HTTPException(status_code=400, detail="crawlRunId required") + + page = max(1, page) + page_size = min(100, max(1, limit)) + offset = (page - 1) * page_size + + try: + result = list_page_markdown( + conn, + crawlRunId, + limit=page_size, + offset=offset, + query=(q or "").strip(), + ) + items = [] + for row in result.get("items") or []: + extracted = row.get("extracted_at") + items.append({ + "url": row.get("url"), + "title": row.get("title"), + "word_count": row.get("word_count"), + "strategy": row.get("strategy"), + "extracted_at": str(extracted) if extracted else None, + }) + total = int(result.get("total") or 0) + return { + "items": items, + "total": total, + "page": page, + "pageSize": page_size, + "totalPages": max(1, -(-total // page_size)), + } + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.delete("") +def delete_page_markdown_route( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + crawl_run_id = int(body.get("crawlRunId") or 0) + if not crawl_run_id: + raise HTTPException(status_code=400, detail="crawlRunId required") + + try: + deleted = delete_page_markdown_for_run(conn, crawl_run_id) + return {"ok": True, "crawlRunId": crawl_run_id, "deletedRows": deleted} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/content") +def page_markdown_content_route( + conn: DbDep, + crawlRunId: int = Query(...), + url: str = Query(...), +) -> dict[str, Any]: + if not crawlRunId: + raise HTTPException(status_code=400, detail="crawlRunId required") + if not url: + raise HTTPException(status_code=400, detail="url required") + + try: + content = read_page_markdown(conn, crawlRunId, url) + if not content: + raise HTTPException(status_code=404, detail="Not found") + extracted = content.get("extracted_at") + return { + "content": { + "url": content.get("url"), + "title": content.get("title"), + "markdown": content.get("markdown"), + "word_count": content.get("word_count"), + "strategy": content.get("strategy"), + "source_byte_length": content.get("source_byte_length"), + "extracted_at": str(extracted) if extracted else None, + } + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/extract") +def page_markdown_extract( + conn: DbDep, + body: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + crawl_run_id = int(body.get("crawlRunId") or 0) + if not crawl_run_id: + raise HTTPException(status_code=400, detail="crawlRunId required") + + strategy = "full_body" if body.get("strategy") == "full_body" else "main_only" + overwrite = body.get("overwrite", True) + workers = min(16, max(1, int(body.get("workers") or 4))) + + command = f"page-markdown --crawl-run-id {crawl_run_id} --strategy {strategy} --workers {workers}" + if not overwrite: + command += " --no-overwrite" + + try: + from website_profiling.db.pipeline_jobs import enqueue_job + import uuid + + job_id = str(uuid.uuid4()) + ok = enqueue_job(conn, job_id, "page-markdown", command, None, None) + if not ok: + raise HTTPException(status_code=400, detail="A pipeline job is already running") + return {"jobId": job_id, "crawlRunId": crawl_run_id, "strategy": strategy, "overwrite": overwrite} + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/runs") +def page_markdown_runs_route( + conn: DbDep, + propertyId: Optional[int] = Query(None), +) -> dict[str, Any]: + try: + runs = list_markdown_crawl_runs(conn, propertyId) + return {"runs": runs} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/pipeline.py b/src/website_profiling/api/routers/pipeline.py new file mode 100644 index 00000000..bd30d04d --- /dev/null +++ b/src/website_profiling/api/routers/pipeline.py @@ -0,0 +1,258 @@ +"""Pipeline job routers — /api/run, /api/jobs.""" +from __future__ import annotations + +import re +import uuid +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db +from ..schemas.pipeline import ( + ALLOWED_COMMANDS, + CancelResponse, + JobResponse, + JobsListResponse, + PauseResponse, + ResumeResponse, + RunPostBody, + RunResponse, + coerce_llm_state, + coerce_pipeline_state, + validate_pipeline_run, +) + +router = APIRouter(tags=["pipeline"]) + +DbDep = Annotated[Connection, Depends(get_db)] + +_PAUSE_RUN_ID_RE = re.compile(r"CRAWL_RUN_ID=(\d+)") + + +def _get_pipeline_jobs_db(conn: Connection): + """Late import to avoid circular deps at startup.""" + from website_profiling.db.pipeline_jobs import ( + cancel_job_in_db, + check_flags, + enqueue_job, + get_active_job, + get_job, + list_jobs, + reconcile_stale_jobs, + set_cancel_flag, + set_pause_flag, + ) + return locals() + + +# ── POST /api/run ───────────────────────────────────────────────────────────── + +@router.post("/run", response_model=RunResponse) +def run_pipeline(body: RunPostBody, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.config_store import ( + read_pipeline_config, + read_llm_config, + write_llm_config, + write_pipeline_config, + ) + from website_profiling.db.pipeline_jobs import enqueue_job, reconcile_stale_jobs + from website_profiling.db.property_store import upsert_property_by_domain + + command = body.command or None + command_base = command.split()[0] if command else None + if command_base is not None and command_base not in { + c for c in ALLOWED_COMMANDS if c is not None and c + }: + raise HTTPException(status_code=400, detail=f"Invalid command: {command_base}") + + # Resolve state — fall back to saved config if not provided + raw_state = body.state + unknown_keys = [{"key": u.key, "value": u.value} for u in (body.unknownKeys or [])] + + if not raw_state: + try: + saved_state, saved_unknown = read_pipeline_config(conn) + raw_state = saved_state + unknown_keys = saved_unknown + except Exception as exc: + raise HTTPException( + status_code=400, + detail=f"Missing state and could not load config: {exc}", + ) + + if not raw_state: + raise HTTPException(status_code=400, detail="Missing state object") + + state = coerce_pipeline_state(raw_state) + + # Filter unknown keys + safe_unknown = [ + u for u in unknown_keys + if isinstance(u, dict) + and not str(u.get("key", "")).startswith("llm_") + and not str(u.get("key", "")).startswith("ml_") + ] + + # Resolve property ID from start_url + start_url = str(state.get("start_url") or "").strip() + property_id: int | None = body.propertyId + if start_url: + from urllib.parse import urlparse + hostname = urlparse(start_url).hostname or "" + if hostname: + try: + from website_profiling.db.property_store import ( + canonical_domain_from_start_url, + upsert_property_by_domain, + ) + domain = canonical_domain_from_start_url(start_url) + if domain: + property_id = upsert_property_by_domain( + conn, domain, domain, start_url + ) + except Exception: + pass + state["active_property_id"] = str(property_id or "") + + # Validate + errors = validate_pipeline_run(state, command) + if errors: + raise HTTPException(status_code=400, detail=" ".join(errors)) + + # Save pipeline config + str_state = {k: str(v) for k, v in state.items() if v is not None} + try: + write_pipeline_config(conn, str_state, safe_unknown) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to save config: {exc}") + + # Save LLM config if provided + if body.llmState and isinstance(body.llmState, dict): + llm_coerced = coerce_llm_state(body.llmState) + str_llm = {k: str(v) for k, v in llm_coerced.items() if not str(k).endswith("_masked")} + try: + write_llm_config(conn, str_llm) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to save LLM config: {exc}") + + # Enqueue job + job_id = str(uuid.uuid4()) + try: + ok = enqueue_job(conn, job_id, command_base or "full", command, property_id, None) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + if not ok: + raise HTTPException(status_code=400, detail="An audit job is already running") + + return {"jobId": job_id} + + +# ── GET /api/jobs ───────────────────────────────────────────────────────────── + +@router.get("/jobs", response_model=JobsListResponse) +def list_pipeline_jobs( + conn: DbDep, + limit: int = Query(20, ge=1, le=100), +) -> dict[str, Any]: + from website_profiling.db.pipeline_jobs import ( + get_active_job, + list_jobs, + reconcile_stale_jobs, + ) + + reconciled = reconcile_stale_jobs(conn) + active = get_active_job(conn) + jobs = list_jobs(conn, limit) + return {"jobs": jobs, "active": active, "reconciled": reconciled} + + +# ── GET /api/jobs/{id} ──────────────────────────────────────────────────────── + +@router.get("/jobs/{job_id}") +def get_pipeline_job(job_id: str, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.pipeline_jobs import get_job + + job = get_job(conn, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return { + "status": job["status"], + "exitCode": job["exitCode"], + "log": job["log"], + "error": job.get("error"), + "logTruncated": job.get("logTruncated", False), + } + + +# ── POST /api/jobs/{id}/cancel ──────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/cancel", response_model=CancelResponse) +def cancel_pipeline_job(job_id: str, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.pipeline_jobs import cancel_job_in_db, get_job, set_cancel_flag + + job = get_job(conn, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job["status"] not in ("pending", "running"): + raise HTTPException(status_code=409, detail="Job is not running") + + # Set the cancel flag — the worker will pick it up and kill the subprocess. + set_cancel_flag(conn, job_id) + return {"ok": True, "status": job["status"]} + + +# ── POST /api/jobs/{id}/pause ───────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/pause", response_model=PauseResponse) +def pause_pipeline_job(job_id: str, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.pipeline_jobs import get_job, set_pause_flag + + job = get_job(conn, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job["status"] != "running": + raise HTTPException(status_code=409, detail="Job is not running") + + set_pause_flag(conn, job_id) + return {"ok": True} + + +# ── POST /api/jobs/{id}/resume ──────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/resume", response_model=ResumeResponse) +def resume_pipeline_job(job_id: str, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.pipeline_jobs import enqueue_job, get_job + + job = get_job(conn, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job["status"] != "paused": + raise HTTPException(status_code=409, detail="Job is not paused") + + # Extract paused crawl run ID from log + log_text = str(job.get("log") or "") + m = _PAUSE_RUN_ID_RE.search(log_text) + if not m: + raise HTTPException(status_code=409, detail="No paused crawl run found for this job") + + paused_run_id = int(m.group(1)) + resume_command = f"--resume-run-id {paused_run_id}" + new_job_id = str(uuid.uuid4()) + + ok = enqueue_job( + conn, + new_job_id, + "crawl-resume", + resume_command, + job.get("propertyId"), + None, + ) + if not ok: + raise HTTPException(status_code=400, detail="An audit job is already running") + + return {"ok": True, "newJobId": new_job_id} diff --git a/src/website_profiling/api/routers/portfolio.py b/src/website_profiling/api/routers/portfolio.py new file mode 100644 index 00000000..f1ec2420 --- /dev/null +++ b/src/website_profiling/api/routers/portfolio.py @@ -0,0 +1,33 @@ +"""Portfolio item deletion — /api/portfolio/*.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from psycopg import Connection +from pydantic import BaseModel + +from ..deps import get_db +from website_profiling.db import portfolio_store + +router = APIRouter(tags=["portfolio"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class DeletePortfolioBody(BaseModel): + reportId: Optional[int] = None + crawlRunId: Optional[int] = None + + +@router.delete("/portfolio/delete") +def delete_portfolio_item(body: DeletePortfolioBody, conn: DbDep) -> dict[str, Any]: + if body.reportId is None and body.crawlRunId is None: + raise HTTPException(status_code=400, detail="reportId or crawlRunId required") + + portfolio_store.delete_portfolio_item( + conn, + report_id=body.reportId, + crawl_run_id=body.crawlRunId, + ) + return {"ok": True} diff --git a/src/website_profiling/api/routers/properties.py b/src/website_profiling/api/routers/properties.py new file mode 100644 index 00000000..43f68197 --- /dev/null +++ b/src/website_profiling/api/routers/properties.py @@ -0,0 +1,324 @@ +"""Properties router — /api/properties/*""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(tags=["properties"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class PropertyUpsertBody(BaseModel): + name: Optional[str] = None + canonical_domain: Optional[str] = None + site_url: Optional[str] = None + + +class OpsSettingsBody(BaseModel): + scheduleCron: Optional[str] = None + alertWebhookUrl: Optional[str] = None + alertEmail: Optional[str] = None + + +class PresetBody(BaseModel): + preset: Optional[str] = None + + +class GoogleCredentialsPatch(BaseModel): + refreshToken: Optional[str] = None + authMode: Optional[str] = None + gscSiteUrl: Optional[str] = None + ga4PropertyId: Optional[str] = None + dateRangeDays: Optional[int] = None + connectedEmail: Optional[str] = None + + +class GoogleCredentialsPostBody(BaseModel): + gscSiteUrl: Optional[str] = None + ga4PropertyId: Optional[str] = None + dateRangeDays: Optional[int] = None + refreshToken: Optional[str] = None + + +@router.get("/properties") +def list_properties(conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import list_properties_public + return {"properties": list_properties_public(conn)} + + +@router.post("/properties", status_code=201) +def create_property(body: PropertyUpsertBody, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import upsert_property_by_domain + + name = (body.name or "").strip() + domain = (body.canonical_domain or "").strip().lower() + if not name or not domain: + raise HTTPException(status_code=400, detail="name and canonical_domain required") + + site_url = (body.site_url or "").strip() or None + prop_id = upsert_property_by_domain(conn, name, domain, site_url) + return {"id": prop_id, "name": name, "canonical_domain": domain} + + +@router.get("/properties/resolve") +def resolve_property( + conn: DbDep, + startUrl: str = Query(..., description="Start URL to resolve a property from"), +) -> dict[str, Any]: + from website_profiling.db.property_store import ( + canonical_domain_from_start_url, + get_property_by_domain, + resolve_property_id_from_start_url, + ) + + start_url = startUrl.strip() + if not start_url: + raise HTTPException(status_code=400, detail="startUrl required") + + prop_id = resolve_property_id_from_start_url(conn, start_url) + domain = canonical_domain_from_start_url(start_url) + prop = get_property_by_domain(conn, domain) if domain else None + return { + "id": prop_id, + "canonical_domain": domain, + "default_crawl_preset": prop.get("default_crawl_preset") if prop else None, + } + + +@router.get("/properties/{property_id}") +def get_property(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + prop = get_property_by_id(conn, property_id) + if not prop: + raise HTTPException(status_code=404, detail="Property not found") + return prop + + +@router.delete("/properties/{property_id}") +def delete_property_route(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import delete_property + + if not delete_property(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + return {"ok": True} + + +@router.get("/properties/{property_id}/ops") +def get_property_ops_route(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_ops + + ops = get_property_ops(conn, property_id) + if not ops: + raise HTTPException(status_code=404, detail="Property not found") + return ops + + +@router.put("/properties/{property_id}/ops") +def update_property_ops_route(property_id: int, body: OpsSettingsBody, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id, update_property_ops + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + + update_property_ops( + conn, + property_id, + schedule_cron=body.scheduleCron, + alert_webhook_url=body.alertWebhookUrl, + alert_email=body.alertEmail, + ) + return {"ok": True} + + +@router.get("/properties/{property_id}/preset") +def get_property_preset(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + prop = get_property_by_id(conn, property_id) + if not prop: + raise HTTPException(status_code=404, detail="Property not found") + return {"default_crawl_preset": prop.get("default_crawl_preset")} + + +@router.put("/properties/{property_id}/preset") +def update_property_preset(property_id: int, body: PresetBody, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id, update_property_crawl_preset + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + + preset = (body.preset or "").strip() or None + update_property_crawl_preset(conn, property_id, preset) + return {"ok": True, "default_crawl_preset": preset} + + +@router.post("/properties/{property_id}/authorize") +def authorize_property_crawl_route(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import authorize_property_crawl, get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + authorize_property_crawl(conn, property_id) + return {"ok": True} + + +@router.get("/properties/{property_id}/google/status") +def property_google_status(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_google_status + + status = get_property_google_status(conn, property_id) + if not status: + raise HTTPException(status_code=404, detail="Property not found") + return status + + +@router.post("/properties/{property_id}/google/test") +def property_google_test(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + try: + from website_profiling.integrations.google.test import test_google_connection + result = test_google_connection(conn, property_id) + return result if isinstance(result, dict) else {"ok": True, "log": str(result)} + except ImportError: + raise HTTPException(status_code=501, detail="Google test unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/properties/{property_id}/google/properties") +def property_google_properties(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + try: + from website_profiling.integrations.google.discover import list_google_properties + result = list_google_properties(conn, property_id) + return result if isinstance(result, dict) else {"properties": result} + except ImportError: + raise HTTPException(status_code=501, detail="Google properties discovery unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/properties/{property_id}/google/links/status") +def property_google_links_status(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + try: + from website_profiling.integrations.google.gsc_links_store import read_gsc_links_status + return read_gsc_links_status(conn, property_id) + except Exception: + return {"hasData": False} + + +@router.post("/properties/{property_id}/google/links/import") +def property_google_links_import(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + try: + from website_profiling.integrations.google.links import import_gsc_links + result = import_gsc_links(conn, property_id) + return result if isinstance(result, dict) else {"ok": True, "imported": result} + except ImportError: + raise HTTPException(status_code=501, detail="GSC links import unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +def _apply_google_credentials_from_patch( + conn: Connection, + property_id: int, + body: GoogleCredentialsPatch, +) -> None: + from website_profiling.db.property_store import apply_property_google_credentials_patch + + fields_set: set[str] = set() + if body.gscSiteUrl is not None: + fields_set.add("gsc_site_url") + if body.ga4PropertyId is not None: + fields_set.add("ga4_property_id") + if body.dateRangeDays is not None: + fields_set.add("date_range_days") + if body.authMode is not None: + fields_set.add("auth_mode") + if body.connectedEmail is not None: + fields_set.add("connected_email") + if body.refreshToken is not None: + fields_set.add("refresh_token") + + try: + apply_property_google_credentials_patch( + conn, + property_id, + refresh_token=body.refreshToken, + auth_mode=body.authMode, + gsc_site_url=body.gscSiteUrl, + ga4_property_id=body.ga4PropertyId, + date_range_days=body.dateRangeDays, + connected_email=body.connectedEmail, + fields_set=frozenset(fields_set) if fields_set else None, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/properties/{property_id}/google/credentials") +def patch_property_google_credentials( + property_id: int, body: GoogleCredentialsPatch, conn: DbDep +) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + _apply_google_credentials_from_patch(conn, property_id, body) + return {"ok": True} + + +@router.post("/properties/{property_id}/google/credentials") +def post_property_google_credentials( + property_id: int, body: GoogleCredentialsPostBody, conn: DbDep +) -> dict[str, Any]: + from website_profiling.db.property_store import get_property_by_id, get_property_google_public_status + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + + patch = GoogleCredentialsPatch() + fields_set = body.model_fields_set + if "gscSiteUrl" in fields_set: + patch.gscSiteUrl = body.gscSiteUrl + if "ga4PropertyId" in fields_set: + patch.ga4PropertyId = body.ga4PropertyId + if "dateRangeDays" in fields_set and body.dateRangeDays is not None: + patch.dateRangeDays = body.dateRangeDays + if isinstance(body.refreshToken, str) and body.refreshToken.strip(): + patch.refreshToken = body.refreshToken.strip() + patch.authMode = "oauth" + + _apply_google_credentials_from_patch(conn, property_id, patch) + return {"ok": True, "status": get_property_google_public_status(conn, property_id)} + + +@router.post("/properties/{property_id}/google/disconnect") +def post_property_google_disconnect(property_id: int, conn: DbDep) -> dict[str, Any]: + from website_profiling.db.property_store import disconnect_property_google, get_property_by_id + + if not get_property_by_id(conn, property_id): + raise HTTPException(status_code=404, detail="Property not found") + disconnect_property_google(conn, property_id) + return {"ok": True} diff --git a/src/website_profiling/api/routers/report.py b/src/website_profiling/api/routers/report.py new file mode 100644 index 00000000..507c6aaf --- /dev/null +++ b/src/website_profiling/api/routers/report.py @@ -0,0 +1,83 @@ +"""Report data routers — /api/report/*.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db +from ..services.report_loader import ( + SECTION_KEYS, + get_crawl_preview_payload, + get_mobile_desktop_delta, + get_report_payload, + list_audit_history, + list_crawl_runs, + list_reports, +) + +router = APIRouter(prefix="/report", tags=["report"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +@router.get("/meta") +def report_meta(conn: DbDep) -> dict[str, Any]: + return { + "reports": list_reports(conn), + "crawlRuns": list_crawl_runs(conn), + } + + +@router.get("/payload") +def report_payload( + conn: DbDep, + reportId: Optional[int] = Query(None), + domain: Optional[str] = Query(None), + section: Optional[str] = Query(None), +) -> dict[str, Any]: + if section is not None and section not in SECTION_KEYS: + raise HTTPException(status_code=400, detail="Invalid section") + payload = get_report_payload(conn, reportId, domain, section) + if payload is None: + raise HTTPException(status_code=404, detail="Report not found") + if section: + return {"payload": payload, "section": section} + return {"payload": payload} + + +@router.get("/history") +def report_history( + conn: DbDep, + propertyId: Optional[int] = Query(None), + domain: Optional[str] = Query(None), + limit: int = Query(20, ge=1, le=100), +) -> dict[str, Any]: + history = list_audit_history(conn, propertyId, domain, limit) + return {"history": history} + + +@router.get("/crawl-payload") +def crawl_payload( + conn: DbDep, + crawlRunId: Optional[int] = Query(None), +) -> dict[str, Any]: + if not crawlRunId or crawlRunId <= 0: + raise HTTPException(status_code=400, detail="Invalid crawlRunId") + try: + payload = get_crawl_preview_payload(conn, crawlRunId) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return {"payload": payload} + + +@router.get("/mobile-delta") +def mobile_delta( + conn: DbDep, + id: Optional[int] = Query(None), +) -> dict[str, Any]: + if not id: + raise HTTPException(status_code=400, detail="id required") + deltas = get_mobile_desktop_delta(conn, id) + return {"deltas": deltas} diff --git a/src/website_profiling/api/routers/report_audit_tool.py b/src/website_profiling/api/routers/report_audit_tool.py new file mode 100644 index 00000000..53a11ab0 --- /dev/null +++ b/src/website_profiling/api/routers/report_audit_tool.py @@ -0,0 +1,40 @@ +"""Audit tool dispatch — POST /api/report/audit-tool.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from psycopg import Connection +from pydantic import BaseModel + +from ..deps import get_db + +router = APIRouter(prefix="/report", tags=["report-audit-tool"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +class AuditToolBody(BaseModel): + toolName: str + propertyId: int + reportId: Optional[int] = None + args: dict[str, Any] = {} + + +@router.post("/audit-tool") +def run_audit_tool(body: AuditToolBody, conn: DbDep) -> dict[str, Any]: + if not body.toolName or not body.propertyId: + raise HTTPException(status_code=400, detail="toolName and propertyId required") + + try: + from website_profiling.tools.audit_tools import AuditToolContext + from website_profiling.tools.audit_tools.registry import dispatch_tool + + context = AuditToolContext( + property_id=body.propertyId, + report_id=body.reportId, + ) + result = dispatch_tool(body.toolName, body.args, context=context, conn=conn) + return {"result": result} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/report_export.py b/src/website_profiling/api/routers/report_export.py new file mode 100644 index 00000000..579d4e7a --- /dev/null +++ b/src/website_profiling/api/routers/report_export.py @@ -0,0 +1,106 @@ +"""Report export downloads — /api/report/export*.""" +from __future__ import annotations + +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(prefix="/report", tags=["report-export"]) + +DbDep = Annotated[Connection, Depends(get_db)] + +EXPORT_FORMATS = {"csv", "json", "html", "pdf"} + + +@router.get("/export") +def export_report( + conn: DbDep, + format: str = Query("csv"), + reportId: Optional[int] = Query(None), +) -> Response: + if format not in EXPORT_FORMATS: + raise HTTPException(status_code=400, detail=f"Invalid format. Use one of {sorted(EXPORT_FORMATS)}") + + try: + if format == "csv": + from website_profiling.tools.export_audit import export_audit_csv as _export + content = _export(conn, reportId) + return Response( + content=content if isinstance(content, bytes) else content.encode(), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=report.csv"}, + ) + if format == "json": + import json + from website_profiling.tools.export_audit import export_audit_json as _export + content = _export(conn, reportId) + body = json.dumps(content) if not isinstance(content, (str, bytes)) else content + return Response( + content=body if isinstance(body, bytes) else body.encode(), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=report.json"}, + ) + if format == "html": + from website_profiling.tools.export_audit import export_audit_html as _export + content = _export(conn, reportId) + return Response( + content=content if isinstance(content, bytes) else content.encode(), + media_type="text/html", + headers={"Content-Disposition": "attachment; filename=report.html"}, + ) + if format == "pdf": + from website_profiling.tools.export_audit import export_audit_pdf as _export + content = _export(conn, reportId) + return Response( + content=content if isinstance(content, bytes) else content.encode("utf-8"), + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=report.pdf"}, + ) + except ImportError as exc: + raise HTTPException(status_code=501, detail=f"Export module unavailable: {exc}") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + raise HTTPException(status_code=500, detail="Export failed") + + +@router.get("/export-sitemap") +def export_sitemap( + conn: DbDep, + reportId: Optional[int] = Query(None), +) -> Response: + try: + from website_profiling.tools.export_sitemap import export_sitemap as _export + content = _export(conn, reportId) + return Response( + content=content if isinstance(content, bytes) else content.encode(), + media_type="application/xml", + headers={"Content-Disposition": "attachment; filename=sitemap.xml"}, + ) + except ImportError: + raise HTTPException(status_code=501, detail="Sitemap export unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/export-workbook") +def export_workbook( + conn: DbDep, + reportId: Optional[int] = Query(None), +) -> Response: + try: + from website_profiling.tools.export_workbook import export_workbook as _export + content = _export(conn, reportId) + return Response( + content=content if isinstance(content, bytes) else content.encode("utf-8"), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=report.xlsx"}, + ) + except ImportError: + raise HTTPException(status_code=501, detail="Workbook export unavailable") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/report_portfolio.py b/src/website_profiling/api/routers/report_portfolio.py new file mode 100644 index 00000000..28dac5a0 --- /dev/null +++ b/src/website_profiling/api/routers/report_portfolio.py @@ -0,0 +1,54 @@ +"""Portfolio report widget — GET /api/report/portfolio.""" +from __future__ import annotations + +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import Connection + +from ..deps import get_db +from ..services.portfolio_loader import get_portfolio_response + +router = APIRouter(prefix="/report", tags=["report-portfolio"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +@router.get("/portfolio") +def report_portfolio( + conn: DbDep, + widget: str = Query("full"), + ids: Optional[str] = Query(None), + reportId: Optional[int] = Query(None), + crawlRunId: Optional[int] = Query(None), +) -> dict[str, Any]: + """Return portfolio data — groups, crawl history, summary, or single card.""" + valid_widgets = {"full", "groups", "summary", "card"} + if widget not in valid_widgets: + raise HTTPException(status_code=400, detail="Invalid widget") + + if widget == "card" and reportId is None and crawlRunId is None: + raise HTTPException( + status_code=400, detail="reportId or crawlRunId required for card widget" + ) + + id_list: list[int] = [] + if ids: + for s in ids.split(","): + try: + n = int(s.strip()) + if n > 0: + id_list.append(n) + except ValueError: + pass + + try: + return get_portfolio_response( + conn, + widget=widget, + ids=id_list, + report_id=reportId, + crawl_run_id=crawlRunId, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/src/website_profiling/api/routers/schedule.py b/src/website_profiling/api/routers/schedule.py new file mode 100644 index 00000000..fef4b7c2 --- /dev/null +++ b/src/website_profiling/api/routers/schedule.py @@ -0,0 +1,22 @@ +"""Scheduled crawl checks — /api/schedule/*.""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(tags=["schedule"]) + + +@router.post("/schedule/check") +def schedule_check() -> dict[str, Any]: + try: + from website_profiling.tools import schedule_runner + + result = schedule_runner.run() + return result if isinstance(result, dict) else {"ok": True} + except ImportError: + pass + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"ok": True} diff --git a/src/website_profiling/api/schemas/__init__.py b/src/website_profiling/api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/website_profiling/api/schemas/chat.py b/src/website_profiling/api/schemas/chat.py new file mode 100644 index 00000000..d76b9f03 --- /dev/null +++ b/src/website_profiling/api/schemas/chat.py @@ -0,0 +1,41 @@ +"""Chat request/response Pydantic schemas.""" +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel + + +class ChatRequest(BaseModel): + sessionId: int + propertyId: int + message: str + reportId: Optional[int] = None + + +class ChatSessionCreate(BaseModel): + propertyId: int + title: str = "New chat" + + +class ChatSessionResponse(BaseModel): + id: int + property_id: int + title: str + created_at: str + updated_at: str + + +class ChatMessageResponse(BaseModel): + id: int + role: str + content: str + tool_name: Optional[str] = None + tool_args: Optional[dict[str, Any]] = None + tool_result: Optional[dict[str, Any]] = None + created_at: str + + +class ArtifactUpdateBody(BaseModel): + title: Optional[str] = None + pinned: Optional[bool] = None diff --git a/src/website_profiling/api/schemas/pipeline.py b/src/website_profiling/api/schemas/pipeline.py new file mode 100644 index 00000000..6580def9 --- /dev/null +++ b/src/website_profiling/api/schemas/pipeline.py @@ -0,0 +1,155 @@ +"""Pipeline job and config Pydantic schemas.""" +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, Field + +# ── Config field type registry (mirrors pipelineConfigSchema.ts) ───────────── + +# bool fields — coerce to Python bool +_BOOL_KEYS: frozenset[str] = frozenset({ + "run_crawl", "run_report", "run_keywords", "run_lighthouse", "run_plot", + "run_security", "run_enrich", "run_google", "run_page_markdown", + "ignore_robots", "allow_external", "store_outlinks", "store_content_excerpt", + "store_page_html", "run_content_analysis", "probe_image_inventory", + "compare_mobile_desktop", "lighthouse_run_mobile", "enable_ner", + "enable_rich_results_validation", "ner_only_top_pages", + "enable_hreflang_validation", "enable_crux_summary", + "enable_executive_summary", "enable_google_keyword_planner", + "enable_competitor_keywords", "export_csv", "export_json", "export_html", + "export_pdf", "enable_bing_backlinks", +}) + +# tristate fields — 'auto' | 'true' | 'false' +_TRISTATE_KEYS: frozenset[str] = frozenset({ + "crawl_render_mode_tristate", +}) + +# Keys written internally by the server (not shown in UI) +INTERNAL_PIPELINE_KEYS: frozenset[str] = frozenset({"active_property_id"}) + +ALLOWED_COMMANDS: frozenset[str | None] = frozenset({ + None, "", "crawl", "report", "plot", "lighthouse", "keywords", + "keywords --enrich-google", "warnings", "enrich", "google", "page-markdown", +}) + + +def coerce_pipeline_state(raw: dict[str, Any]) -> dict[str, Any]: + """Coerce raw state values to correct Python types, mirroring run/route.ts logic.""" + out: dict[str, Any] = {} + for key, val in raw.items(): + if key.startswith("llm_"): + continue + if key in _BOOL_KEYS: + out[key] = val is True or val == "true" + elif key in _TRISTATE_KEYS: + s = str(val or "auto").lower() + out[key] = "true" if s == "true" else "false" if s == "false" else "auto" + else: + out[key] = "" if val is None else str(val) + return out + + +def coerce_llm_state(raw: dict[str, Any]) -> dict[str, Any]: + """Coerce LLM config state, mirroring run/route.ts llm coercion.""" + # LLM fields that are booleans + _LLM_BOOL_KEYS = frozenset({ + "llm_chat_unlimited_tool_rounds", + "llm_reasoning_enabled", + }) + out: dict[str, Any] = {} + for key, val in raw.items(): + if key.endswith("_masked"): + continue + if key in _LLM_BOOL_KEYS: + out[key] = val is True or val == "true" + else: + out[key] = "" if val is None else str(val) + # preserve _masked flags + if raw.get(f"{key}_masked") is True: + out[f"{key}_masked"] = True + return out + + +def validate_pipeline_run(state: dict[str, Any], command: str | None) -> list[str]: + """Return validation error messages (empty list = OK).""" + errors: list[str] = [] + start_url = str(state.get("start_url") or "").strip() + + def needs_start_url() -> bool: + if command == "crawl": + return True + if command in ("report", "keywords"): + return True + if command is None: + run_crawl = state.get("run_crawl", True) + run_report = state.get("run_report", True) + if isinstance(run_crawl, str): + run_crawl = run_crawl.lower() == "true" + if isinstance(run_report, str): + run_report = run_report.lower() == "true" + return bool(run_crawl) or bool(run_report) + return False + + if needs_start_url() and not start_url: + errors.append("Site URL is required. Enter it in Audit settings before continuing.") + return errors + + +# ── Request / response models ───────────────────────────────────────────────── + +class UnknownKeyEntry(BaseModel): + key: str + value: str + + +class RunPostBody(BaseModel): + command: Optional[str] = None + state: Optional[dict[str, Any]] = None + unknownKeys: list[UnknownKeyEntry] = Field(default_factory=list) + llmState: Optional[dict[str, Any]] = None + propertyId: Optional[int] = None + python: Optional[str] = None + repoRoot: Optional[str] = None + + +class RunResponse(BaseModel): + jobId: str + + +class JobResponse(BaseModel): + id: str + jobType: str + status: str + exitCode: Optional[int] = None + log: str = "" + error: Optional[str] = None + logTruncated: bool = False + propertyId: Optional[int] = None + startedAt: Optional[str] = None + finishedAt: Optional[str] = None + command: Optional[str] = None + + +class JobsListResponse(BaseModel): + jobs: list[dict[str, Any]] + active: Optional[dict[str, Any]] = None + reconciled: int = 0 + + +class CancelResponse(BaseModel): + ok: bool + status: str + error: Optional[str] = None + + +class PauseResponse(BaseModel): + ok: bool + error: Optional[str] = None + + +class ResumeResponse(BaseModel): + ok: bool + newJobId: Optional[str] = None + error: Optional[str] = None diff --git a/src/website_profiling/api/services/__init__.py b/src/website_profiling/api/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/website_profiling/api/services/portfolio_loader.py b/src/website_profiling/api/services/portfolio_loader.py new file mode 100644 index 00000000..c449c487 --- /dev/null +++ b/src/website_profiling/api/services/portfolio_loader.py @@ -0,0 +1,606 @@ +"""Portfolio grouping for /api/report/portfolio — port of web/src/lib/homePortfolio.ts.""" +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any, Callable, Optional +from urllib.parse import urlparse + +from psycopg import Connection + +from website_profiling.db.report_store import read_report_payload + +from .report_loader import ( + list_crawl_run_summaries, + list_crawl_runs, + list_reports, + slice_payload_for_section, +) + +PORTFOLIO_CATEGORY_ORDER = ( + "technical_seo", + "performance", + "core_web_vitals", + "link_health", + "security", + "html_accessibility", + "mobile", + "intelligence", +) + +EMPTY_ISSUE_COUNTS = {"critical": 0, "high": 0, "medium": 0, "low": 0} + +DATA_SOURCE_IDS = frozenset({ + "crawl", + "lighthouse", + "search_console", + "analytics", + "backlinks", +}) + +UNKNOWN_BRAND = "Unknown property" +EM_DASH = "—" + + +def _extract_hostname(url: str | None) -> str: + if not url: + return "" + try: + host = urlparse(str(url)).hostname + return host.lower() if host else "" + except Exception: + return "" + + +def _slugify_domain(name: str | None) -> str: + if not name: + return "" + s = re.sub(r"[^a-z0-9]+", "-", str(name).strip().lower()).strip("-") + return s + + +def _canonical_domain_from_payload( + payload: dict[str, Any], + start_url_by_run_id: dict[int, str], +) -> str: + run_id = payload.get("crawl_run_id") + run_id = int(run_id) if run_id is not None else None + run_start = start_url_by_run_id.get(run_id, "") if run_id is not None else "" + top_pages = payload.get("top_pages") or [] + links = payload.get("links") or [] + fallback = "" + if top_pages and isinstance(top_pages[0], dict): + fallback = str(top_pages[0].get("url") or "") + if not fallback and links and isinstance(links[0], dict): + fallback = str(links[0].get("url") or "") + start_domain = _extract_hostname(run_start) + fallback_domain = _extract_hostname(fallback) + return (start_domain or fallback_domain or "").lower() + + +def _crawled_url_count(payload: dict[str, Any]) -> int: + scope = (payload.get("report_meta") or {}).get("crawl_scope") or {} + pages = scope.get("pages_crawled") + if pages is not None: + try: + n = int(pages) + if n > 0: + return n + except (TypeError, ValueError): + pass + summary = payload.get("summary") or {} + total = summary.get("total_urls") + if total is not None: + try: + n = int(total) + if n > 0: + return n + except (TypeError, ValueError): + pass + links = payload.get("links") or [] + return len(links) if links else 0 + + +def _score_from_categories(categories: list[dict[str, Any]]) -> int | None: + nums = [ + float(c["score"]) + for c in categories + if isinstance(c.get("score"), (int, float)) + ] + if not nums: + return None + return round(sum(nums) / len(nums)) + + +def _issue_counts_from_payload(payload: dict[str, Any]) -> tuple[dict[str, int], int]: + counts = dict(EMPTY_ISSUE_COUNTS) + for cat in payload.get("categories") or []: + for iss in cat.get("issues") or []: + p = str(iss.get("priority") or "Medium") + if p == "Critical": + counts["critical"] += 1 + elif p == "High": + counts["high"] += 1 + elif p == "Low": + counts["low"] += 1 + else: + counts["medium"] += 1 + total = sum(counts.values()) + return counts, total + + +def _category_score(payload: dict[str, Any], cat_id: str) -> int | None: + for cat in payload.get("categories") or []: + if cat.get("id") == cat_id and isinstance(cat.get("score"), (int, float)): + return round(float(cat["score"])) + return None + + +def _lh_scores(payload: dict[str, Any]) -> tuple[int | None, int | None]: + summary = payload.get("lighthouse_summary") + if not isinstance(summary, dict): + return None, None + mm = summary.get("median_metrics") or {} + cs = summary.get("category_scores") or {} + perf_raw = mm.get("performance_score") or cs.get("performance") + seo_raw = mm.get("seo_score") or cs.get("seo") + perf = round(float(perf_raw)) if isinstance(perf_raw, (int, float)) else None + seo = round(float(seo_raw)) if isinstance(seo_raw, (int, float)) else None + return perf, seo + + +def _category_snapshots(payload: dict[str, Any]) -> list[dict[str, Any]]: + cats = payload.get("categories") or [] + by_id = {str(c.get("id") or ""): c for c in cats} + out: list[dict[str, Any]] = [] + + def push(cat_id: str) -> None: + cat = by_id.get(cat_id) + if not cat or not isinstance(cat.get("score"), (int, float)): + return + out.append({ + "id": cat_id, + "name": str(cat.get("name") or cat_id), + "score": round(float(cat["score"])), + "issueCount": len(cat.get("issues") or []), + }) + + for cat_id in PORTFOLIO_CATEGORY_ORDER: + push(cat_id) + for cat in cats: + cat_id = str(cat.get("id") or "") + if not cat_id or any(r["id"] == cat_id for r in out): + continue + if not isinstance(cat.get("score"), (int, float)): + continue + out.append({ + "id": cat_id, + "name": str(cat.get("name") or cat_id), + "score": round(float(cat["score"])), + "issueCount": len(cat.get("issues") or []), + }) + return out + + +def _seo_signals(payload: dict[str, Any]) -> dict[str, int] | None: + s = payload.get("seo_health") + if not isinstance(s, dict): + return None + return { + "missingTitles": int(s.get("missing_title") or 0), + "missingMetaDesc": int(s.get("missing_meta_desc") or 0), + "thinContent": int(s.get("thin_content") or 0), + "h1Issues": int(s.get("h1_zero") or 0) + int(s.get("h1_multi") or 0), + } + + +def _median_word_count(payload: dict[str, Any]) -> int | None: + median = (payload.get("content_analytics") or {}).get("word_count_stats", {}).get("median") + return round(float(median)) if isinstance(median, (int, float)) else None + + +def _median_response_ms(payload: dict[str, Any]) -> int | None: + median = (payload.get("response_time_stats") or {}).get("p50") + return round(float(median)) if isinstance(median, (int, float)) else None + + +def _data_sources(payload: dict[str, Any]) -> list[str] | None: + raw = (payload.get("report_meta") or {}).get("data_sources") or [] + out = [str(s) for s in raw if str(s) in DATA_SOURCE_IDS] + return out or None + + +def _crawl_config_from_payload( + payload: dict[str, Any], + run_meta: dict[str, Any] | None, +) -> dict[str, Any] | None: + scope = (payload.get("report_meta") or {}).get("crawl_scope") + if not scope and not (run_meta or {}).get("render_mode") and not (run_meta or {}).get("discovery_mode"): + return None + cfg: dict[str, Any] = dict(scope) if isinstance(scope, dict) else {} + if run_meta: + if run_meta.get("render_mode") and "render_mode" not in cfg: + cfg["render_mode"] = run_meta["render_mode"] + if run_meta.get("discovery_mode"): + cfg["discovery_mode"] = run_meta["discovery_mode"] + return cfg or None + + +def _crawl_config_from_summary(row: dict[str, Any]) -> dict[str, Any] | None: + if not row.get("render_mode") and not row.get("discovery_mode") and not row.get("url_count"): + return None + return { + "pages_crawled": row.get("url_count"), + "render_mode": row.get("render_mode"), + "discovery_mode": row.get("discovery_mode"), + } + + +def _to_display_datetime(value: str | None) -> str: + if not value: + return "" + try: + if isinstance(value, datetime): + return value.isoformat() + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return dt.isoformat() + except Exception: + return str(value) + + +def _generated_at_ms(value: str | None) -> float: + if not value: + return 0.0 + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() * 1000 + except Exception: + return 0.0 + + +def _title_coverage_pct(with_title: int, url_count: int) -> int: + if url_count <= 0: + return 0 + return round((with_title / url_count) * 100) + + +def load_portfolio_maps(conn: Connection) -> dict[str, Any]: + crawl_rows = list_crawl_runs(conn) + start_url_by_run_id = {int(r["id"]): r["start_url"] for r in crawl_rows} + run_created_at_by_run_id = {int(r["id"]): r["created_at"] for r in crawl_rows} + run_meta_by_run_id = { + int(r["id"]): { + "render_mode": r.get("render_mode"), + "discovery_mode": r.get("discovery_mode"), + } + for r in crawl_rows + } + crawl_summaries = list_crawl_run_summaries(conn) + return { + "start_url_by_run_id": start_url_by_run_id, + "run_created_at_by_run_id": run_created_at_by_run_id, + "run_meta_by_run_id": run_meta_by_run_id, + "crawl_summaries": crawl_summaries, + } + + +def compute_domain_groups( + report_list: list[dict[str, Any]], + maps: dict[str, Any], + get_payload: Callable[[int], dict[str, Any] | None], +) -> list[dict[str, Any]]: + start_url_by_run_id: dict[int, str] = maps["start_url_by_run_id"] + run_created_at_by_run_id: dict[str, str] = maps["run_created_at_by_run_id"] + run_meta_by_run_id: dict[int, dict[str, Any]] = maps["run_meta_by_run_id"] + brand_map: dict[str, dict[str, Any]] = {} + + for r in report_list: + report_id = int(r["id"]) + payload = get_payload(report_id) + if not payload: + continue + + run_id = payload.get("crawl_run_id") + run_id_int = int(run_id) if run_id is not None else None + run_start_url = start_url_by_run_id.get(run_id_int, "") if run_id_int is not None else "" + top = payload.get("top_pages") or [] + links = payload.get("links") or [] + if top and isinstance(top[0], dict): + fallback_url = str(top[0].get("url") or "") + elif links and isinstance(links[0], dict): + fallback_url = str(links[0].get("url") or "") + else: + fallback_url = "" + crawl_url = (run_start_url or fallback_url or "").strip() + start_domain = _extract_hostname(run_start_url) + fallback_domain = _extract_hostname(crawl_url) + domain_name = start_domain or fallback_domain or str(payload.get("site_name") or UNKNOWN_BRAND) + brand_key = start_domain or (f"fallback:{fallback_domain}" if fallback_domain else f"report:{report_id}") + + summary = payload.get("summary") or {} + status_counts = { + "s2xx": int(summary.get("count_2xx") or 0), + "s3xx": int(summary.get("count_3xx") or 0), + "s4xx": int(summary.get("count_4xx") or 0), + "s5xx": int(summary.get("count_5xx") or 0), + "other": int(summary.get("count_error") or 0), + } + url_count = _crawled_url_count(payload) + success_pct = round((status_counts["s2xx"] / url_count) * 100) if url_count > 0 else 0 + health_score = _score_from_categories(payload.get("categories") or []) or 0 + run_created_at = run_created_at_by_run_id.get(run_id_int, "") if run_id_int is not None else "" + last_crawl = _to_display_datetime( + run_created_at or payload.get("crawl_run_created_at") or payload.get("report_generated_at") or r.get("generated_at") + ) + last_audit = _to_display_datetime(payload.get("report_generated_at") or r.get("generated_at")) + generated_at_ms = _generated_at_ms(r.get("generated_at")) + issue_counts, total_issues = _issue_counts_from_payload(payload) + perf_score, seo_score = _lh_scores(payload) + technical_seo_score = _category_score(payload, "technical_seo") + success_rate_raw = summary.get("success_rate") + success_rate = ( + round(float(success_rate_raw)) + if isinstance(success_rate_raw, (int, float)) + else (success_pct if url_count > 0 else None) + ) + crawl_duration_s = ( + round(float(summary["crawl_time_s"])) + if isinstance(summary.get("crawl_time_s"), (int, float)) + else None + ) + run_meta = run_meta_by_run_id.get(run_id_int) if run_id_int is not None else None + canonical_host = _canonical_domain_from_payload(payload, start_url_by_run_id) or _slugify_domain( + str(payload.get("site_name") or "") + ) + data_sources = _data_sources(payload) + + group = { + "domainName": domain_name, + "crawlUrl": crawl_url or EM_DASH, + "urlCount": url_count, + "healthScore": health_score, + "statusCounts": status_counts, + "lastCrawl": last_crawl, + "lastAudit": last_audit, + "totalIssues": total_issues, + "issueCounts": issue_counts, + "successRate": success_rate, + "titleCoverage": None, + "avgWordCount": None, + "thinPages": None, + "technicalSeoScore": technical_seo_score, + "perfScore": perf_score, + "seoScore": seo_score, + "crawlDurationS": crawl_duration_s, + "categorySnapshots": _category_snapshots(payload), + "seoSignals": _seo_signals(payload), + "securityFindings": len(payload.get("security_findings") or []), + "duplicateClusters": len(payload.get("content_duplicates") or []), + "medianWordCount": _median_word_count(payload), + "medianResponseMs": _median_response_ms(payload), + "reportId": report_id, + "crawlRunId": run_id_int, + "generatedAtMs": generated_at_ms, + "domainParam": canonical_host, + "crawlConfig": _crawl_config_from_payload(payload, run_meta), + "dataSources": data_sources, + } + + existing = brand_map.get(brand_key) + if not existing or generated_at_ms > existing["generatedAtMs"]: + brand_map[brand_key] = group + + return sorted(brand_map.values(), key=lambda g: g["generatedAtMs"], reverse=True) + + +def compute_crawl_only_groups( + crawl_summaries: list[dict[str, Any]], + report_groups: list[dict[str, Any]], +) -> list[dict[str, Any]]: + covered_domains = { + (g.get("domainParam") or _extract_hostname(g.get("crawlUrl")) or g.get("domainName", "")).lower() + for g in report_groups + if g.get("domainParam") or g.get("crawlUrl") or g.get("domainName") + } + covered_run_ids = { + int(g["crawlRunId"]) + for g in report_groups + if g.get("crawlRunId") is not None + } + + brand_map: dict[str, dict[str, Any]] = {} + for row in crawl_summaries: + crawl_run_id = int(row["crawl_run_id"]) + if crawl_run_id in covered_run_ids: + continue + start_url = str(row.get("start_url") or "").strip() + domain_name = _extract_hostname(start_url) or UNKNOWN_BRAND + domain_key = domain_name.lower() + if not domain_key or domain_key in covered_domains: + continue + + url_count = int(row.get("url_count") or 0) + with_title = int(row.get("with_title") or 0) + title_coverage = _title_coverage_pct(with_title, url_count) + avg_word_count = round(float(row.get("avg_word_count") or 0)) + thin_pages = int(row.get("thin_pages") or 0) + generated_at_ms = _generated_at_ms(row.get("created_at")) + + existing = brand_map.get(domain_key) + if existing and generated_at_ms <= existing["generatedAtMs"]: + continue + + brand_map[domain_key] = { + "domainName": domain_name, + "crawlUrl": start_url or EM_DASH, + "urlCount": url_count, + "healthScore": title_coverage, + "statusCounts": { + "s2xx": int(row.get("s2xx") or 0), + "s3xx": int(row.get("s3xx") or 0), + "s4xx": int(row.get("s4xx") or 0), + "s5xx": int(row.get("s5xx") or 0), + "other": int(row.get("other") or 0), + }, + "lastCrawl": _to_display_datetime(row.get("created_at")), + "lastAudit": "", + "totalIssues": 0, + "issueCounts": dict(EMPTY_ISSUE_COUNTS), + "successRate": None, + "titleCoverage": title_coverage, + "avgWordCount": avg_word_count, + "thinPages": thin_pages, + "technicalSeoScore": None, + "perfScore": None, + "seoScore": None, + "crawlDurationS": None, + "categorySnapshots": [], + "seoSignals": None, + "securityFindings": 0, + "duplicateClusters": 0, + "medianWordCount": avg_word_count or None, + "medianResponseMs": None, + "reportId": None, + "crawlRunId": crawl_run_id, + "crawlOnly": True, + "generatedAtMs": generated_at_ms, + "domainParam": domain_key, + "crawlConfig": _crawl_config_from_summary(row), + } + + return list(brand_map.values()) + + +def merge_portfolio_groups( + report_groups: list[dict[str, Any]], + crawl_only_groups: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return sorted( + report_groups + crawl_only_groups, + key=lambda g: g["generatedAtMs"], + reverse=True, + ) + + +def build_crawl_history_by_domain( + summaries: list[dict[str, Any]], +) -> dict[str, list[dict[str, Any]]]: + by_domain: dict[str, list[dict[str, Any]]] = {} + for row in summaries: + key = _extract_hostname(row.get("start_url")) + if not key: + continue + pages = int(row.get("url_count") or 0) + point = { + "pagesDiscovered": pages, + "titleCoverage": _title_coverage_pct(int(row.get("with_title") or 0), pages), + "avgWordCount": round(float(row.get("avg_word_count") or 0)), + "createdAtMs": _generated_at_ms(row.get("created_at")), + } + by_domain.setdefault(key, []).append(point) + + out: dict[str, list[dict[str, Any]]] = {} + for key, points in by_domain.items(): + out[key] = sorted(points, key=lambda p: p["createdAtMs"])[-8:] + return out + + +def compute_portfolio_summary(groups: list[dict[str, Any]]) -> dict[str, Any]: + total_brands = len(groups) + total_urls = sum(int(g.get("urlCount") or 0) for g in groups) + avg_health = ( + round(sum(int(g.get("healthScore") or 0) for g in groups) / total_brands) + if total_brands + else None + ) + return {"totalBrands": total_brands, "totalUrls": total_urls, "avgHealth": avg_health} + + +def build_portfolio_card( + conn: Connection, + report_list: list[dict[str, Any]], + maps: dict[str, Any], + *, + report_id: int | None = None, + crawl_run_id: int | None = None, +) -> dict[str, Any] | None: + def get_full_payload(rid: int) -> dict[str, Any] | None: + return read_report_payload(conn, rid) + + if report_id is not None: + row = next((r for r in report_list if int(r["id"]) == report_id), None) + if not row: + return None + groups = compute_domain_groups([row], maps, get_full_payload) + return groups[0] if groups else None + + if crawl_run_id is not None: + report_groups = compute_domain_groups(report_list, maps, get_full_payload) + from_report = next((g for g in report_groups if g.get("crawlRunId") == crawl_run_id), None) + if from_report: + return from_report + summary = next( + (s for s in maps["crawl_summaries"] if int(s["crawl_run_id"]) == crawl_run_id), + None, + ) + if not summary: + return None + crawl_only = compute_crawl_only_groups([summary], report_groups) + return crawl_only[0] if crawl_only else None + + return None + + +def build_groups_bundle( + conn: Connection, + report_list: list[dict[str, Any]], + *, + lite: bool, +) -> dict[str, Any]: + maps = load_portfolio_maps(conn) + + def get_payload(rid: int) -> dict[str, Any] | None: + payload = read_report_payload(conn, rid) + if payload is None: + return None + return slice_payload_for_section(payload, "core") if lite else payload + + report_groups = compute_domain_groups(report_list, maps, get_payload) + crawl_only = compute_crawl_only_groups(maps["crawl_summaries"], report_groups) + groups = merge_portfolio_groups(report_groups, crawl_only) + crawl_history = build_crawl_history_by_domain(maps["crawl_summaries"]) + return {"groups": groups, "crawlHistoryByDomain": crawl_history} + + +def get_portfolio_response( + conn: Connection, + *, + widget: str, + ids: list[int], + report_id: int | None = None, + crawl_run_id: int | None = None, +) -> dict[str, Any]: + all_reports = list_reports(conn) + id_set = set(ids) + report_list = [r for r in all_reports if r["id"] in id_set] if ids else all_reports + + if widget == "card": + maps = load_portfolio_maps(conn) + group = build_portfolio_card( + conn, + report_list, + maps, + report_id=report_id, + crawl_run_id=crawl_run_id, + ) + return {"group": group} + + lite = widget in ("groups", "summary") + bundle = build_groups_bundle(conn, report_list, lite=lite) + + if widget == "summary": + return compute_portfolio_summary(bundle["groups"]) + + return { + "groups": bundle["groups"], + "crawlHistoryByDomain": bundle["crawlHistoryByDomain"], + } diff --git a/src/website_profiling/api/services/report_loader.py b/src/website_profiling/api/services/report_loader.py new file mode 100644 index 00000000..18dcd440 --- /dev/null +++ b/src/website_profiling/api/services/report_loader.py @@ -0,0 +1,382 @@ +"""Report data loading service — DB queries for the /api/report/* routes.""" +from __future__ import annotations + +from typing import Any, Optional + +from psycopg import Connection + +from website_profiling.db._common import _parse_row_json, _row_field +from website_profiling.db.report_store import read_report_payload + +# ── Section slicing ───────────────────────────────────────────────────────── + +SECTION_FIELDS: dict[str, list[str]] = { + "core": [ + "site_name", "summary", "categories", "top_pages", "recommendations", + "seo_health", "social_coverage", "status_counts", "portfolio_benchmark", + "executive_summary", "crux_summary", "report_meta", "report_generated_at", + "crawl_only_preview", "crawl_run_id", "crawl_run_created_at", "site_level", + "ml_errors", + ], + "links": [ + "links", "link_edges", "link_rel_summary", "inlink_anchor_matrix", + "outbound_link_domains", "outlink_labels", "outlink_counts", + ], + "traffic": ["google"], + "keywords": [ + "keywords", "keyword_opportunities", "competitor_keyword_gap", + "semantic_keyword_clusters", + ], + "issues": ["issues", "redirects"], + "content": [ + "content_urls", "content_duplicates", "content_analytics", + "text_content_analysis", "response_time_stats", + ], + "lighthouse": [ + "lighthouse_summary", "lighthouse_by_url", "lighthouse_diagnostics", + "lighthouse_human_summary", + ], + "security": ["security_findings"], + "gsc-links": ["gsc_links", "bing_backlinks"], + "structure": ["graph_nodes", "graph_edges", "depth_distribution"], + "tech": ["tech_stack_summary", "subdomains", "contact_intelligence"], + "indexation": [ + "indexation_coverage", "hreflang_summary", "ner_site_summary", + "language_summary", "rich_results_validation", "url_fingerprints", + "rich_results_meta", + ], + "gallery": [ + "mime_labels", "mime_values", "title_labels", "title_counts", + "domain_labels", "domain_values", + ], +} + +SECTION_KEYS = list(SECTION_FIELDS.keys()) + + +def slice_payload_for_section( + payload: dict[str, Any], section: str +) -> dict[str, Any]: + fields = SECTION_FIELDS.get(section, []) + return {k: payload[k] for k in fields if k in payload} + + +# ── Report list ────────────────────────────────────────────────────────────── + +def list_reports(conn: Connection) -> list[dict[str, Any]]: + cur = conn.execute( + "SELECT id, canonical_domain, site_name, generated_at FROM report_payload ORDER BY id DESC" + ) + rows = cur.fetchall() + result = [] + for row in rows: + generated = _row_field(row, "generated_at") + result.append({ + "id": int(_row_field(row, "id")), + "canonical_domain": _row_field(row, "canonical_domain"), + "site_name": _row_field(row, "site_name"), + "generated_at": generated.isoformat() if hasattr(generated, "isoformat") else generated, + }) + return result + + +# ── Crawl runs ─────────────────────────────────────────────────────────────── + +def list_crawl_runs(conn: Connection) -> list[dict[str, Any]]: + try: + cur = conn.execute( + "SELECT id, start_url, created_at, render_mode, discovery_mode FROM crawl_runs ORDER BY id DESC" + ) + rows = cur.fetchall() + except Exception: + return [] + result = [] + for row in rows: + created = _row_field(row, "created_at") + result.append({ + "id": int(_row_field(row, "id")), + "start_url": str(_row_field(row, "start_url") or ""), + "created_at": created.isoformat() if hasattr(created, "isoformat") else str(created or ""), + "render_mode": _row_field(row, "render_mode"), + "discovery_mode": _row_field(row, "discovery_mode"), + }) + return result + + +def list_crawl_run_summaries(conn: Connection) -> list[dict[str, Any]]: + """Aggregate crawl run stats for portfolio cards and crawl history.""" + try: + cur = conn.execute( + """ + SELECT + cr.id AS crawl_run_id, + cr.start_url, + cr.created_at, + cr.render_mode, + cr.discovery_mode, + COUNT(crl.id)::int AS url_count, + COUNT(*) FILTER (WHERE crl.status LIKE '2%%')::int AS s2xx, + COUNT(*) FILTER (WHERE crl.status LIKE '3%%')::int AS s3xx, + COUNT(*) FILTER (WHERE crl.status LIKE '4%%')::int AS s4xx, + COUNT(*) FILTER (WHERE crl.status LIKE '5%%')::int AS s5xx, + COUNT(*) FILTER ( + WHERE crl.status IS NULL + OR crl.status = '' + OR crl.status !~ '^[2345]' + )::int AS other, + COUNT(*) FILTER ( + WHERE NULLIF(TRIM(COALESCE(crl.title, crl.data->>'title', '')), '') IS NOT NULL + )::int AS with_title, + COALESCE(ROUND(AVG(NULLIF((crl.data->>'word_count')::numeric, 0))), 0)::int AS avg_word_count, + COUNT(*) FILTER ( + WHERE COALESCE((crl.data->>'word_count')::int, 0) > 0 + AND COALESCE((crl.data->>'word_count')::int, 0) < 300 + )::int AS thin_pages + FROM crawl_runs cr + LEFT JOIN crawl_results crl ON crl.crawl_run_id = cr.id + GROUP BY cr.id, cr.start_url, cr.created_at, cr.render_mode, cr.discovery_mode + ORDER BY cr.id DESC + """ + ) + rows = cur.fetchall() + except Exception: + return [] + result = [] + for row in rows: + created = _row_field(row, "created_at") + result.append({ + "crawl_run_id": int(_row_field(row, "crawl_run_id")), + "start_url": str(_row_field(row, "start_url") or ""), + "created_at": created.isoformat() if hasattr(created, "isoformat") else str(created or ""), + "url_count": int(_row_field(row, "url_count") or 0), + "s2xx": int(_row_field(row, "s2xx") or 0), + "s3xx": int(_row_field(row, "s3xx") or 0), + "s4xx": int(_row_field(row, "s4xx") or 0), + "s5xx": int(_row_field(row, "s5xx") or 0), + "other": int(_row_field(row, "other") or 0), + "with_title": int(_row_field(row, "with_title") or 0), + "avg_word_count": int(_row_field(row, "avg_word_count") or 0), + "thin_pages": int(_row_field(row, "thin_pages") or 0), + "render_mode": _row_field(row, "render_mode"), + "discovery_mode": _row_field(row, "discovery_mode"), + }) + return result + + +# ── Report payload ─────────────────────────────────────────────────────────── + +def get_report_payload( + conn: Connection, + report_id: Optional[int] = None, + domain: Optional[str] = None, + section: Optional[str] = None, +) -> Optional[dict[str, Any]]: + resolved_id = report_id + + if resolved_id is None and domain: + domain_lower = domain.strip().lower() + reports = list_reports(conn) + match = next( + (r for r in reports if (r.get("canonical_domain") or "").lower() == domain_lower), + None, + ) + if match: + resolved_id = match["id"] + + payload = read_report_payload(conn, resolved_id) + if payload is None: + return None + + if section and section in SECTION_FIELDS: + return slice_payload_for_section(payload, section) + return payload + + +# ── Crawl preview ──────────────────────────────────────────────────────────── + +def get_crawl_preview_payload(conn: Connection, crawl_run_id: int) -> dict[str, Any]: + cur = conn.execute( + "SELECT id, start_url, created_at FROM crawl_runs WHERE id = %s", + (crawl_run_id,), + ) + run_row = cur.fetchone() + if not run_row: + raise ValueError("Crawl run not found") + + start_url = str(_row_field(run_row, "start_url") or "") + from urllib.parse import urlparse + try: + site_host = urlparse(start_url).hostname or "" + except Exception: + site_host = "" + + cur2 = conn.execute( + "SELECT url, data FROM crawl_results WHERE crawl_run_id = %s", + (crawl_run_id,), + ) + pages = [] + for row in cur2.fetchall(): + data = _parse_row_json(row, "data", index=1) + if not isinstance(data, dict): + data = {} + pages.append({"url": str(_row_field(row, "url") or ""), **data}) + + return { + "crawl_only_preview": True, + "crawl_run_id": crawl_run_id, + "site_name": site_host, + "top_pages": pages, + } + + +# ── Audit history ──────────────────────────────────────────────────────────── + +def _avg_score(categories: list[dict[str, Any]]) -> Optional[int]: + nums = [float(c["score"]) for c in categories if isinstance(c.get("score"), (int, float))] + if not nums: + return None + return round(sum(nums) / len(nums)) + + +def _issue_counts(categories: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0} + for cat in categories: + for issue in (cat.get("issues") or []): + p = str(issue.get("priority") or "Medium") + counts[p] = counts.get(p, 0) + 1 + return counts + + +def _lh_scores(payload: dict[str, Any]) -> tuple[Optional[int], Optional[int]]: + summary = payload.get("lighthouse_summary") + if not isinstance(summary, dict): + return None, None + mm = summary.get("median_metrics") or {} + cs = summary.get("category_scores") or {} + perf_raw = mm.get("performance_score") or cs.get("performance") + seo_raw = mm.get("seo_score") or cs.get("seo") + perf = round(float(perf_raw)) if isinstance(perf_raw, (int, float)) else None + seo = round(float(seo_raw)) if isinstance(seo_raw, (int, float)) else None + return perf, seo + + +def list_audit_history( + conn: Connection, + property_id: Optional[int] = None, + domain: Optional[str] = None, + limit: int = 20, +) -> list[dict[str, Any]]: + clauses: list[str] = [] + vals: list[Any] = [] + + if property_id is not None and property_id > 0: + clauses.append("property_id = %s") + vals.append(property_id) + elif domain: + normalized = domain.strip().lower() + clauses.append( + "(LOWER(canonical_domain) = %s OR regexp_replace(LOWER(COALESCE(canonical_domain, '')), '[^a-z0-9]+', '-', 'g') = %s)" + ) + vals.append(normalized) + vals.append(normalized) + + limit = max(1, min(100, limit)) + vals.append(limit) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + + cur = conn.execute( + f"""SELECT id, canonical_domain, site_name, generated_at, data + FROM report_payload {where} + ORDER BY generated_at DESC LIMIT %s""", + vals, + ) + rows = cur.fetchall() + result = [] + for row in rows: + data = _parse_row_json(row, "data") + if not isinstance(data, dict): + data = {} + categories = data.get("categories") or [] + cat_scores = { + (c.get("id") or c.get("name") or "unknown"): float(c["score"]) + for c in categories + if isinstance(c.get("score"), (int, float)) + } + perf, seo = _lh_scores(data) + tech_seo_cat = next((c for c in categories if c.get("id") == "technical_seo"), None) + tech_seo = round(float(tech_seo_cat["score"])) if tech_seo_cat and isinstance(tech_seo_cat.get("score"), (int, float)) else None + generated_at = _row_field(row, "generated_at") + result.append({ + "reportId": int(_row_field(row, "id")), + "canonicalDomain": _row_field(row, "canonical_domain"), + "siteName": _row_field(row, "site_name"), + "generatedAt": generated_at.isoformat() if hasattr(generated_at, "isoformat") else generated_at, + "healthScore": _avg_score(categories), + "categoryScores": cat_scores, + "issueCounts": _issue_counts(categories), + "perfScore": perf, + "seoScore": seo, + "technicalSeoScore": tech_seo, + }) + return result + + +# ── Mobile-desktop delta ───────────────────────────────────────────────────── + +def get_mobile_desktop_delta(conn: Connection, run_id: int) -> list[dict[str, Any]]: + cur = conn.execute( + "SELECT mobile_run_id FROM crawl_runs WHERE id = %s", (run_id,) + ) + row = cur.fetchone() + mobile_run_id = _row_field(row, "mobile_run_id") + if not row or mobile_run_id is None: + return [] + mobile_run_id = int(mobile_run_id) + + def fetch_run(rid: int) -> dict[str, dict[str, Any]]: + c = conn.execute( + "SELECT url, data FROM crawl_results WHERE crawl_run_id = %s", (rid,) + ) + m: dict[str, dict[str, Any]] = {} + for r in c.fetchall(): + d = _parse_row_json(r, "data", index=1) + if not isinstance(d, dict): + d = {} + key = str(_row_field(r, "url") or "").rstrip("/").lower() + m[key] = { + "title": str(d.get("title") or ""), + "h1": str(d.get("h1") or ""), + "word_count": int(d.get("word_count") or 0), + "status": int(d.get("status") or 0), + } + return m + + desktop_map = fetch_run(run_id) + mobile_map = fetch_run(mobile_run_id) + + deltas = [] + for key, desktop in desktop_map.items(): + mobile = mobile_map.get(key) + if not mobile: + continue + title_differs = desktop["title"] != mobile["title"] + h1_differs = desktop["h1"] != mobile["h1"] + word_count_delta = abs(desktop["word_count"] - mobile["word_count"]) + status_differs = desktop["status"] != mobile["status"] + if not title_differs and not h1_differs and word_count_delta <= 50 and not status_differs: + continue + deltas.append({ + "url": key, + "desktop": desktop, + "mobile": mobile, + "title_differs": title_differs, + "h1_differs": h1_differs, + "word_count_delta": word_count_delta, + "status_differs": status_differs, + }) + + deltas.sort( + key=lambda d: (d["status_differs"] * 4 + d["title_differs"] * 2 + d["h1_differs"]), + reverse=True, + ) + return deltas diff --git a/src/website_profiling/db/config_store.py b/src/website_profiling/db/config_store.py index 7bcc10cd..fa5c5853 100644 --- a/src/website_profiling/db/config_store.py +++ b/src/website_profiling/db/config_store.py @@ -17,6 +17,7 @@ _json_val, _now_iso, _parse_json_field, + _row_field, _sanitize_for_json, ) from .pool import db_session, get_data_dir, get_database_url @@ -82,3 +83,41 @@ def write_llm_config(conn: Connection, entries: dict[str, str], secret_keys: set ) +def read_llm_config_full(conn: Connection) -> list[dict[str, Any]]: + """Return llm_config rows including the is_secret flag.""" + try: + cur = conn.execute("SELECT key, value, is_secret FROM llm_config ORDER BY key") + return [ + { + "key": str(_row_field(row, "key", index=0)), + "value": str(_row_field(row, "value", index=1)), + "is_secret": bool(_row_field(row, "is_secret", index=2)), + } + for row in cur.fetchall() or [] + ] + except Exception: + return [] + + +def read_app_setting(conn: Connection, key: str) -> str | None: + try: + cur = conn.execute("SELECT value FROM app_settings WHERE key = %s", (key,)) + row = cur.fetchone() + if not row: + return None + val = _row_field(row, "value", index=0) + return str(val) if val is not None else None + except Exception: + return None + + +def write_app_setting(conn: Connection, key: str, value: str) -> None: + conn.execute( + """INSERT INTO app_settings (key, value, updated_at) + VALUES (%s, %s, now()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_at = now()""", + (key, value), + ) + conn.commit() diff --git a/src/website_profiling/db/content_draft_store.py b/src/website_profiling/db/content_draft_store.py new file mode 100644 index 00000000..41f41118 --- /dev/null +++ b/src/website_profiling/db/content_draft_store.py @@ -0,0 +1,177 @@ +"""Content drafts for Content Studio (content_drafts table).""" +from __future__ import annotations + +from typing import Any, Optional + +from psycopg import Connection +from psycopg.types.json import Json + +from ._common import _parse_row_json, _row_field + +_LIST_COLUMNS = """ + id, property_id, title, target_keyword, landing_url, status, + grade_score, created_at::text, updated_at::text +""" + +_DETAIL_COLUMNS = """ + id, property_id, title, target_keyword, landing_url, status, + body_html, title_tag, meta_description, grade_score, grade_snapshot, + created_at::text, updated_at::text +""" + + +def _grade_score_value(raw: Any) -> float | None: + if raw is None: + return None + return float(raw) + + +def _map_list_row(row: Any) -> dict[str, Any]: + return { + "id": int(_row_field(row, "id")), + "property_id": int(_row_field(row, "property_id")), + "title": _row_field(row, "title"), + "target_keyword": _row_field(row, "target_keyword"), + "landing_url": _row_field(row, "landing_url"), + "status": _row_field(row, "status"), + "grade_score": _grade_score_value(_row_field(row, "grade_score")), + "created_at": _row_field(row, "created_at"), + "updated_at": _row_field(row, "updated_at"), + } + + +def _map_detail_row(row: Any) -> dict[str, Any]: + return { + "id": int(_row_field(row, "id")), + "property_id": int(_row_field(row, "property_id")), + "title": _row_field(row, "title"), + "target_keyword": _row_field(row, "target_keyword"), + "landing_url": _row_field(row, "landing_url"), + "status": _row_field(row, "status"), + "body_html": _row_field(row, "body_html") or "", + "title_tag": _row_field(row, "title_tag") or "", + "meta_description": _row_field(row, "meta_description") or "", + "grade_score": _grade_score_value(_row_field(row, "grade_score")), + "grade_snapshot": _parse_row_json(row, "grade_snapshot"), + "created_at": _row_field(row, "created_at"), + "updated_at": _row_field(row, "updated_at"), + } + + +def list_content_drafts( + conn: Connection, + property_id: int, + *, + limit: int = 100, +) -> list[dict[str, Any]]: + limit = max(1, min(int(limit), 200)) + cur = conn.execute( + f"""SELECT {_LIST_COLUMNS} + FROM content_drafts + WHERE property_id = %s + ORDER BY updated_at DESC + LIMIT %s""", + (property_id, limit), + ) + return [_map_list_row(row) for row in cur.fetchall() or []] + + +def get_content_draft(conn: Connection, draft_id: int) -> dict[str, Any] | None: + cur = conn.execute( + f"SELECT {_DETAIL_COLUMNS} FROM content_drafts WHERE id = %s", + (draft_id,), + ) + row = cur.fetchone() + return _map_detail_row(row) if row else None + + +def create_content_draft( + conn: Connection, + property_id: int, + *, + title: str = "Untitled draft", + target_keyword: str = "", + landing_url: str | None = None, + status: str = "draft", + body_html: str = "", + title_tag: str = "", + meta_description: str = "", +) -> int: + cur = conn.execute( + """INSERT INTO content_drafts + (property_id, title, target_keyword, landing_url, status, + body_html, title_tag, meta_description) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id""", + ( + property_id, + (title or "Untitled draft").strip() or "Untitled draft", + (target_keyword or "").strip(), + (landing_url or "").strip() or None, + status or "draft", + body_html or "", + title_tag or "", + meta_description or "", + ), + ) + row = cur.fetchone() + conn.commit() + return int(_row_field(row, "id")) + + +def update_content_draft( + conn: Connection, + draft_id: int, + patch: dict[str, Any], +) -> dict[str, Any] | None: + fields: list[str] = [] + values: list[Any] = [] + + def set_field(col: str, val: Any) -> None: + fields.append(f"{col} = %s") + values.append(val) + + if "title" in patch: + set_field("title", (str(patch["title"]).strip() or "Untitled draft")) + if "target_keyword" in patch: + set_field("target_keyword", str(patch["target_keyword"]).strip()) + if "landing_url" in patch: + set_field("landing_url", str(patch["landing_url"]).strip() or None) + if "status" in patch: + set_field("status", patch["status"]) + if "body_html" in patch: + set_field("body_html", patch["body_html"]) + if "title_tag" in patch: + set_field("title_tag", patch["title_tag"]) + if "meta_description" in patch: + set_field("meta_description", patch["meta_description"]) + if "grade_score" in patch: + set_field("grade_score", patch["grade_score"]) + if "grade_snapshot" in patch: + gs = patch["grade_snapshot"] + set_field("grade_snapshot", Json(gs) if gs is not None else None) + + if not fields: + return get_content_draft(conn, draft_id) + + fields.append("updated_at = now()") + values.append(draft_id) + cur = conn.execute( + f"""UPDATE content_drafts SET {', '.join(fields)} + WHERE id = %s + RETURNING {_DETAIL_COLUMNS}""", + values, + ) + row = cur.fetchone() + conn.commit() + return _map_detail_row(row) if row else None + + +def delete_content_draft(conn: Connection, draft_id: int) -> bool: + cur = conn.execute( + "DELETE FROM content_drafts WHERE id = %s RETURNING id", + (draft_id,), + ) + deleted = cur.fetchone() is not None + conn.commit() + return deleted diff --git a/src/website_profiling/db/dashboard_store.py b/src/website_profiling/db/dashboard_store.py new file mode 100644 index 00000000..308d03c2 --- /dev/null +++ b/src/website_profiling/db/dashboard_store.py @@ -0,0 +1,116 @@ +"""Custom dashboards (dashboards table).""" +from __future__ import annotations + +from typing import Any + +from psycopg import Connection +from psycopg.types.json import Json + +from ._common import _row_field + +_SELECT = """ + SELECT id, property_id, name, layout_json, is_default, created_at, updated_at + FROM dashboards +""" + + +def _map_dashboard(row: Any) -> dict[str, Any]: + created = _row_field(row, "created_at", index=5) + updated = _row_field(row, "updated_at", index=6) + layout = _row_field(row, "layout_json", index=3) or {} + return { + "id": int(_row_field(row, "id", index=0)), + "propertyId": int(_row_field(row, "property_id", index=1)), + "name": _row_field(row, "name", index=2), + "layoutJson": layout, + "isDefault": bool(_row_field(row, "is_default", index=4)), + "createdAt": created.isoformat() if hasattr(created, "isoformat") else str(created or ""), + "updatedAt": updated.isoformat() if hasattr(updated, "isoformat") else str(updated or ""), + } + + +def list_dashboards(conn: Connection, property_id: int) -> list[dict[str, Any]]: + cur = conn.execute( + f"{_SELECT} WHERE property_id = %s ORDER BY updated_at DESC", + (property_id,), + ) + return [_map_dashboard(row) for row in cur.fetchall() or []] + + +def get_dashboard(conn: Connection, dashboard_id: int, property_id: int) -> dict[str, Any] | None: + cur = conn.execute( + f"{_SELECT} WHERE id = %s AND property_id = %s", + (dashboard_id, property_id), + ) + row = cur.fetchone() + return _map_dashboard(row) if row else None + + +def create_dashboard( + conn: Connection, + property_id: int, + name: str, + layout_json: Any, +) -> dict[str, Any]: + cur = conn.execute( + """ + INSERT INTO dashboards (property_id, name, layout_json) + VALUES (%s, %s, %s) + RETURNING id, property_id, name, layout_json, is_default, created_at, updated_at + """, + (property_id, name, Json(layout_json)), + ) + row = cur.fetchone() + conn.commit() + return _map_dashboard(row) + + +def update_dashboard( + conn: Connection, + dashboard_id: int, + property_id: int, + *, + name: str | None = None, + layout_json: Any | None = None, + is_default: bool | None = None, +) -> dict[str, Any] | None: + sets = ["updated_at = now()"] + vals: list[Any] = [] + + if name is not None: + sets.append("name = %s") + vals.append(name.strip() or "Untitled dashboard") + if layout_json is not None: + sets.append("layout_json = %s") + vals.append(Json(layout_json)) + if is_default is not None: + if is_default: + conn.execute( + "UPDATE dashboards SET is_default = false WHERE property_id = %s", + (property_id,), + ) + sets.append("is_default = %s") + vals.append(is_default) + + vals.extend([dashboard_id, property_id]) + cur = conn.execute( + f""" + UPDATE dashboards SET {', '.join(sets)} + WHERE id = %s AND property_id = %s + RETURNING id, property_id, name, layout_json, is_default, created_at, updated_at + """, + vals, + ) + row = cur.fetchone() + conn.commit() + return _map_dashboard(row) if row else None + + +def delete_dashboard(conn: Connection, dashboard_id: int, property_id: int) -> bool: + cur = conn.execute( + "DELETE FROM dashboards WHERE id = %s AND property_id = %s RETURNING id", + (dashboard_id, property_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + return deleted diff --git a/src/website_profiling/db/issue_status_store.py b/src/website_profiling/db/issue_status_store.py new file mode 100644 index 00000000..05870569 --- /dev/null +++ b/src/website_profiling/db/issue_status_store.py @@ -0,0 +1,100 @@ +"""Issue workflow status persistence (issue_status table).""" +from __future__ import annotations + +import hashlib +from typing import Any, Optional + +from psycopg import Connection + +from ._common import _row_field + +_VALID_STATUS = frozenset({"open", "in_progress", "fixed", "ignored"}) + +_SELECT_COLUMNS = """ + id, property_id, report_id, issue_fingerprint, category_id, + message, url, priority, status, assignee, note, updated_at +""" + + +def issue_fingerprint(message: str, url: str, category_id: Optional[str] = None) -> str: + raw = f"{category_id or ''}|{url or ''}|{message or ''}" + return hashlib.sha256(raw.encode()).hexdigest()[:32] + + +def _map_issue_row(row: Any) -> dict[str, Any]: + report_id = _row_field(row, "report_id") + updated = _row_field(row, "updated_at") + return { + "id": int(_row_field(row, "id")), + "propertyId": int(_row_field(row, "property_id")), + "reportId": int(report_id) if report_id is not None else None, + "issueFingerprint": _row_field(row, "issue_fingerprint"), + "categoryId": _row_field(row, "category_id"), + "message": _row_field(row, "message"), + "url": _row_field(row, "url"), + "priority": _row_field(row, "priority"), + "status": _row_field(row, "status"), + "assignee": _row_field(row, "assignee"), + "note": _row_field(row, "note"), + "updatedAt": updated.isoformat() if hasattr(updated, "isoformat") else str(updated or ""), + } + + +def list_issue_status(conn: Connection, property_id: int) -> list[dict[str, Any]]: + cur = conn.execute( + f"""SELECT {_SELECT_COLUMNS} + FROM issue_status + WHERE property_id = %s + ORDER BY updated_at DESC""", + (property_id,), + ) + return [_map_issue_row(row) for row in cur.fetchall() or []] + + +def upsert_issue_status( + conn: Connection, + *, + property_id: int, + message: str, + status: str, + report_id: int | None = None, + url: str = "", + priority: str = "Medium", + category_id: str | None = None, + assignee: str | None = None, + note: str | None = None, +) -> dict[str, Any]: + if status not in _VALID_STATUS: + raise ValueError(f"invalid status: {status}") + + fp = issue_fingerprint(message, url, category_id) + cur = conn.execute( + f"""INSERT INTO issue_status + (property_id, report_id, issue_fingerprint, category_id, message, url, + priority, status, assignee, note, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now()) + ON CONFLICT (property_id, issue_fingerprint) DO UPDATE SET + status = EXCLUDED.status, + assignee = COALESCE(EXCLUDED.assignee, issue_status.assignee), + note = COALESCE(EXCLUDED.note, issue_status.note), + report_id = COALESCE(EXCLUDED.report_id, issue_status.report_id), + updated_at = now() + RETURNING {_SELECT_COLUMNS}""", + ( + property_id, + report_id, + fp, + category_id, + message, + url, + priority, + status, + assignee, + note, + ), + ) + row = cur.fetchone() + conn.commit() + if not row: + raise RuntimeError("issue status upsert failed") + return _map_issue_row(row) diff --git a/src/website_profiling/db/markdown_store.py b/src/website_profiling/db/markdown_store.py index 9dbbcd28..0677e62c 100644 --- a/src/website_profiling/db/markdown_store.py +++ b/src/website_profiling/db/markdown_store.py @@ -5,7 +5,7 @@ from psycopg import Connection -from ._common import _executemany, _now_iso +from ._common import _executemany, _now_iso, _row_field _MD_BATCH_SIZE = 200 @@ -71,7 +71,15 @@ def read_page_markdown(conn: Connection, crawl_run_id: int, url: str) -> Optiona row = cur.fetchone() if row is None: return None - return dict(row) + return { + "url": _row_field(row, "url"), + "title": _row_field(row, "title"), + "markdown": _row_field(row, "markdown"), + "word_count": _row_field(row, "word_count"), + "strategy": _row_field(row, "strategy"), + "source_byte_length": _row_field(row, "source_byte_length"), + "extracted_at": _row_field(row, "extracted_at"), + } except Exception: return None @@ -97,7 +105,7 @@ def list_page_markdown( (crawl_run_id, pattern), ) total_row = count_cur.fetchone() - total = int(dict(total_row).get("count", 0)) if total_row else 0 + total = int(_row_field(total_row, "count", index=0) or 0) if total_row else 0 cur = conn.execute( """SELECT url, title, word_count, strategy, extracted_at @@ -113,7 +121,7 @@ def list_page_markdown( (crawl_run_id,), ) total_row = count_cur.fetchone() - total = int(dict(total_row).get("count", 0)) if total_row else 0 + total = int(_row_field(total_row, "count", index=0) or 0) if total_row else 0 cur = conn.execute( """SELECT url, title, word_count, strategy, extracted_at @@ -123,7 +131,16 @@ def list_page_markdown( LIMIT %s OFFSET %s""", (crawl_run_id, limit, offset), ) - items = [dict(row) for row in cur.fetchall()] + items = [ + { + "url": _row_field(row, "url"), + "title": _row_field(row, "title"), + "word_count": _row_field(row, "word_count"), + "strategy": _row_field(row, "strategy"), + "extracted_at": _row_field(row, "extracted_at"), + } + for row in cur.fetchall() or [] + ] return {"items": items, "total": total, "limit": limit, "offset": offset} except Exception: return {"items": [], "total": 0, "limit": limit, "offset": offset} @@ -141,7 +158,10 @@ def count_page_markdown_by_run(conn: Connection, crawl_run_ids: list[int]) -> di GROUP BY crawl_run_id""", (crawl_run_ids,), ) - return {int(row["crawl_run_id"]): int(row["cnt"]) for row in cur.fetchall()} + return { + int(_row_field(row, "crawl_run_id")): int(_row_field(row, "cnt") or 0) + for row in cur.fetchall() or [] + } except Exception: return {} @@ -159,3 +179,47 @@ def delete_page_markdown_for_run(conn: Connection, crawl_run_id: int, *, commit: return deleted except Exception: return 0 + + +def list_markdown_crawl_runs( + conn: Connection, + property_id: int | None = None, + *, + limit: int = 50, +) -> list[dict[str, Any]]: + """Crawl runs with HTML and markdown page counts for the page-markdown UI.""" + limit = max(1, min(int(limit), 100)) + where = "WHERE cr.property_id = %s" if property_id else "" + params: tuple[Any, ...] = (property_id, limit) if property_id else (limit,) + cur = conn.execute( + f""" + SELECT cr.id, cr.created_at, cr.start_url, + COALESCE(html_counts.cnt, 0) AS html_page_count, + COALESCE(md_counts.cnt, 0) AS markdown_page_count + FROM crawl_runs cr + LEFT JOIN ( + SELECT crawl_run_id, COUNT(*)::int AS cnt + FROM crawl_page_html GROUP BY crawl_run_id + ) html_counts ON html_counts.crawl_run_id = cr.id + LEFT JOIN ( + SELECT crawl_run_id, COUNT(*)::int AS cnt + FROM crawl_page_markdown GROUP BY crawl_run_id + ) md_counts ON md_counts.crawl_run_id = cr.id + {where} + ORDER BY cr.id DESC + LIMIT %s + """, + params, + ) + runs: list[dict[str, Any]] = [] + for row in cur.fetchall() or []: + created = _row_field(row, "created_at") + runs.append({ + "id": int(_row_field(row, "id")), + "created_at": created.isoformat() if hasattr(created, "isoformat") else str(created or "") or None, + "start_url": _row_field(row, "start_url"), + "html_page_count": int(_row_field(row, "html_page_count") or 0), + "markdown_page_count": int(_row_field(row, "markdown_page_count") or 0), + }) + return runs + diff --git a/src/website_profiling/db/pipeline_jobs.py b/src/website_profiling/db/pipeline_jobs.py new file mode 100644 index 00000000..89c4d7c3 --- /dev/null +++ b/src/website_profiling/db/pipeline_jobs.py @@ -0,0 +1,270 @@ +"""Pipeline job DB helpers — shared by FastAPI routers and the worker process.""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from psycopg import Connection + +from .pool import db_session + +# Stale job thresholds (minutes for pending, hours for running) +_STALE_PENDING_MINUTES = int(os.getenv("PIPELINE_JOB_STALE_PENDING_MINUTES", "10")) +_STALE_RUNNING_HOURS = int(os.getenv("PIPELINE_JOB_STALE_HOURS", "1")) + +PIPELINE_LOG_MAX = 256_000 +PIPELINE_LOG_TRIM = 200_000 + + +def _trim_log(existing: str, chunk: str) -> tuple[str, bool]: + combined = existing + chunk + if len(combined) <= PIPELINE_LOG_MAX: + return combined, False + return combined[-PIPELINE_LOG_TRIM:], True + + +# ── Enqueue ────────────────────────────────────────────────────────────────── + +def enqueue_job( + conn: Connection, + job_id: str, + job_type: str, + command: Optional[str], + property_id: Optional[int], + config_hash: Optional[str] = None, +) -> bool: + """INSERT a pending job. Returns True if inserted, False if a job is already pending/running.""" + reconcile_stale_jobs(conn) + cur = conn.execute( + """INSERT INTO pipeline_jobs (id, job_type, status, command, property_id, config_hash) + SELECT %s::uuid, %s, 'pending', %s, %s, %s + WHERE NOT EXISTS ( + SELECT 1 FROM pipeline_jobs WHERE status IN ('pending', 'running') + ) + RETURNING id""", + (job_id, job_type, command, property_id, config_hash), + ) + conn.commit() + return cur.fetchone() is not None + + +# ── Worker claim ───────────────────────────────────────────────────────────── + +def try_claim_pending_job(conn: Connection, worker_pid: int) -> Optional[dict[str, Any]]: + """Atomically claim one pending job for the worker. Returns the job row or None.""" + cur = conn.execute( + """UPDATE pipeline_jobs + SET status = 'running', worker_pid = %s + WHERE id = ( + SELECT id FROM pipeline_jobs + WHERE status = 'pending' + ORDER BY started_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, job_type, command, property_id""", + (worker_pid,), + ) + row = cur.fetchone() + conn.commit() + if row is None: + return None + return { + "id": str(row["id"]), + "job_type": str(row["job_type"]), + "command": row["command"], + "property_id": row["property_id"], + } + + +# ── Log appending ──────────────────────────────────────────────────────────── + +def append_job_log(conn: Connection, job_id: str, chunk: str) -> bool: + """Append to log_text with row-level lock. Returns True if log was truncated.""" + conn.execute("BEGIN") + try: + cur = conn.execute( + "SELECT log_text, log_truncated FROM pipeline_jobs WHERE id = %s::uuid FOR UPDATE", + (job_id,), + ) + row = cur.fetchone() + if not row: + conn.execute("ROLLBACK") + return False + log, truncated = _trim_log(str(row["log_text"] or ""), chunk) + log_truncated = bool(row["log_truncated"]) or truncated + conn.execute( + "UPDATE pipeline_jobs SET log_text = %s, log_truncated = %s WHERE id = %s::uuid", + (log, log_truncated, job_id), + ) + conn.execute("COMMIT") + return log_truncated + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise + + +# ── Finish ─────────────────────────────────────────────────────────────────── + +def finish_job( + conn: Connection, + job_id: str, + status: str, + exit_code: Optional[int], + error: Optional[str] = None, + log_truncated: Optional[bool] = None, +) -> None: + if log_truncated is None: + conn.execute( + """UPDATE pipeline_jobs + SET status = %s, exit_code = %s, error_text = %s, finished_at = now(), worker_pid = NULL + WHERE id = %s::uuid""", + (status, exit_code, error, job_id), + ) + else: + conn.execute( + """UPDATE pipeline_jobs + SET status = %s, exit_code = %s, error_text = %s, finished_at = now(), + log_truncated = %s, worker_pid = NULL + WHERE id = %s::uuid""", + (status, exit_code, error, log_truncated, job_id), + ) + conn.commit() + + +# ── Flags ──────────────────────────────────────────────────────────────────── + +def check_flags(conn: Connection, job_id: str) -> tuple[bool, bool]: + """Return (cancel_requested, pause_requested) for a running job.""" + cur = conn.execute( + "SELECT cancel_requested, pause_requested FROM pipeline_jobs WHERE id = %s::uuid", + (job_id,), + ) + row = cur.fetchone() + if not row: + return False, False + return bool(row["cancel_requested"]), bool(row["pause_requested"]) + + +def set_cancel_flag(conn: Connection, job_id: str) -> bool: + cur = conn.execute( + """UPDATE pipeline_jobs SET cancel_requested = true + WHERE id = %s::uuid AND status = 'running' + RETURNING id""", + (job_id,), + ) + conn.commit() + return cur.fetchone() is not None + + +def set_pause_flag(conn: Connection, job_id: str) -> bool: + cur = conn.execute( + """UPDATE pipeline_jobs SET pause_requested = true + WHERE id = %s::uuid AND status = 'running' + RETURNING id""", + (job_id,), + ) + conn.commit() + return cur.fetchone() is not None + + +# ── Reconcile stale jobs ───────────────────────────────────────────────────── + +def reconcile_stale_jobs(conn: Connection) -> int: + """Mark stale running/pending jobs as error. Returns count reconciled.""" + cur = conn.execute( + """UPDATE pipeline_jobs + SET status = 'error', + error_text = COALESCE(error_text, 'Job interrupted (server restart or timeout)'), + finished_at = now() + WHERE status = 'running' + AND started_at < now() - (%s::text || ' hours')::interval + RETURNING id""", + (str(_STALE_RUNNING_HOURS),), + ) + count = len(cur.fetchall()) + + cur2 = conn.execute( + """UPDATE pipeline_jobs + SET status = 'error', + error_text = 'Job never started (worker restart)', + finished_at = now() + WHERE status = 'pending' + AND started_at < now() - (%s::text || ' minutes')::interval + RETURNING id""", + (str(_STALE_PENDING_MINUTES),), + ) + count += len(cur2.fetchall()) + if count: + conn.commit() + return count + + +# ── Read helpers ───────────────────────────────────────────────────────────── + +def get_job(conn: Connection, job_id: str) -> Optional[dict[str, Any]]: + cur = conn.execute( + """SELECT id, job_type, status, exit_code, log_text, error_text, + log_truncated, property_id, started_at, finished_at, command + FROM pipeline_jobs WHERE id = %s::uuid""", + (job_id,), + ) + row = cur.fetchone() + if not row: + return None + return _job_row_to_dict(row) + + +def list_jobs(conn: Connection, limit: int = 50) -> list[dict[str, Any]]: + reconcile_stale_jobs(conn) + cur = conn.execute( + """SELECT id, job_type, status, exit_code, log_text, error_text, + log_truncated, property_id, started_at, finished_at, command + FROM pipeline_jobs ORDER BY started_at DESC LIMIT %s""", + (limit,), + ) + return [_job_row_to_dict(r) for r in cur.fetchall()] + + +def get_active_job(conn: Connection) -> Optional[dict[str, Any]]: + cur = conn.execute( + """SELECT id, job_type, status, exit_code, log_text, error_text, + log_truncated, property_id, started_at, finished_at, command + FROM pipeline_jobs WHERE status IN ('pending', 'running') + ORDER BY started_at DESC LIMIT 1""", + ) + row = cur.fetchone() + return _job_row_to_dict(row) if row else None + + +def cancel_job_in_db(conn: Connection, job_id: str, message: str = "Cancelled by user") -> bool: + cur = conn.execute( + """UPDATE pipeline_jobs + SET status = 'error', error_text = %s, exit_code = -1, finished_at = now() + WHERE id = %s::uuid AND status IN ('pending', 'running') + RETURNING id""", + (message, job_id), + ) + conn.commit() + return cur.fetchone() is not None + + +def _job_row_to_dict(row: Any) -> dict[str, Any]: + started_at = row["started_at"] + finished_at = row["finished_at"] + return { + "id": str(row["id"]), + "jobType": str(row["job_type"] or ""), + "status": str(row["status"] or ""), + "exitCode": row["exit_code"], + "log": str(row["log_text"] or ""), + "error": row["error_text"], + "logTruncated": bool(row["log_truncated"]), + "propertyId": row["property_id"], + "startedAt": started_at.isoformat() if started_at else None, + "finishedAt": finished_at.isoformat() if finished_at else None, + "command": row["command"], + } diff --git a/src/website_profiling/db/portfolio_store.py b/src/website_profiling/db/portfolio_store.py new file mode 100644 index 00000000..39ac7f0c --- /dev/null +++ b/src/website_profiling/db/portfolio_store.py @@ -0,0 +1,36 @@ +"""Portfolio item deletion (report_payload / crawl_runs).""" +from __future__ import annotations + +from psycopg import Connection + + +def delete_portfolio_report(conn: Connection, report_id: int) -> bool: + cur = conn.execute( + "DELETE FROM report_payload WHERE id = %s RETURNING id", + (report_id,), + ) + deleted = cur.fetchone() is not None + conn.commit() + return deleted + + +def delete_portfolio_crawl_run(conn: Connection, crawl_run_id: int) -> bool: + cur = conn.execute( + "DELETE FROM crawl_runs WHERE id = %s RETURNING id", + (crawl_run_id,), + ) + deleted = cur.fetchone() is not None + conn.commit() + return deleted + + +def delete_portfolio_item( + conn: Connection, + *, + report_id: int | None = None, + crawl_run_id: int | None = None, +) -> None: + if report_id is not None: + delete_portfolio_report(conn, report_id) + if crawl_run_id is not None: + delete_portfolio_crawl_run(conn, crawl_run_id) diff --git a/src/website_profiling/db/property_store.py b/src/website_profiling/db/property_store.py index 434a2a76..85da80a0 100644 --- a/src/website_profiling/db/property_store.py +++ b/src/website_profiling/db/property_store.py @@ -206,3 +206,219 @@ def list_properties_public(conn: Connection) -> list[dict[str, Any]]: "crawl_authorized_at": crawl_auth.isoformat() if crawl_auth else None, }) return out + + +def get_property_id_by_domain(conn: Connection, domain: str) -> int | None: + """Resolve property id from canonical domain (case-insensitive).""" + normalized = (domain or "").strip().lower() + if not normalized: + return None + prop = get_property_by_domain(conn, normalized) + return int(prop["id"]) if prop else None + + +def resolve_property_id_for_page( + conn: Connection, + page_url: str, + property_id_str: str | None = None, + domain_str: str | None = None, +) -> int | None: + """Resolve property ID from explicit param, domain, or URL hostname.""" + if property_id_str: + try: + return int(property_id_str) + except (ValueError, TypeError): + pass + + if domain_str: + prop_id = get_property_id_by_domain(conn, domain_str) + if prop_id is not None: + return prop_id + + host = _extract_hostname(page_url) + if host: + return get_property_id_by_domain(conn, host) + return None + + +def get_property_ops(conn: Connection, property_id: int) -> dict[str, Any] | None: + cur = conn.execute( + "SELECT schedule_cron, alert_webhook_url, alert_email FROM properties WHERE id = %s", + (property_id,), + ) + row = cur.fetchone() + if not row: + return None + return { + "schedule_cron": _row_field(row, "schedule_cron", index=0), + "alert_webhook_url": _row_field(row, "alert_webhook_url", index=1), + "alert_email": _row_field(row, "alert_email", index=2), + } + + +def update_property_ops( + conn: Connection, + property_id: int, + *, + schedule_cron: str | None, + alert_webhook_url: str | None, + alert_email: str | None, +) -> None: + conn.execute( + """ + UPDATE properties + SET schedule_cron = %s, + alert_webhook_url = %s, + alert_email = %s, + updated_at = now() + WHERE id = %s + """, + (schedule_cron, alert_webhook_url, alert_email, property_id), + ) + conn.commit() + + +def delete_property(conn: Connection, property_id: int) -> bool: + cur = conn.execute( + "DELETE FROM properties WHERE id = %s RETURNING id", + (property_id,), + ) + deleted = cur.fetchone() is not None + conn.commit() + return deleted + + +def update_property_crawl_preset( + conn: Connection, + property_id: int, + preset: str | None, +) -> None: + conn.execute( + "UPDATE properties SET default_crawl_preset = %s, updated_at = now() WHERE id = %s", + (preset, property_id), + ) + conn.commit() + + +def authorize_property_crawl(conn: Connection, property_id: int) -> None: + """Mark property as crawl-authorized (OAuth flow).""" + conn.execute( + "UPDATE properties SET crawl_authorized_at = now(), updated_at = now() WHERE id = %s", + (property_id,), + ) + conn.commit() + + +def get_property_google_public_status(conn: Connection, property_id: int) -> dict[str, Any]: + row = get_property_by_id(conn, property_id) + if not row: + return { + "connected": False, + "authMode": None, + "gscSiteUrl": None, + "ga4PropertyId": None, + "dateRangeDays": 28, + "connectedEmail": None, + "connectedAt": None, + } + connected_at = row.get("google_connected_at") + return { + "connected": connected_at is not None, + "authMode": row.get("google_auth_mode"), + "gscSiteUrl": row.get("gsc_site_url"), + "ga4PropertyId": row.get("ga4_property_id"), + "dateRangeDays": int(row.get("google_date_range_days") or 0) or 28, + "connectedEmail": row.get("google_connected_email"), + "connectedAt": connected_at, + } + + +def apply_property_google_credentials_patch( + conn: Connection, + property_id: int, + *, + refresh_token: str | None = None, + auth_mode: str | None = None, + gsc_site_url: str | None = None, + ga4_property_id: str | None = None, + date_range_days: int | None = None, + connected_email: str | None = None, + fields_set: frozenset[str] | None = None, +) -> None: + """Merge Google OAuth / site mapping fields on a property row.""" + allowed = fields_set or frozenset({ + "refresh_token", "auth_mode", "gsc_site_url", "ga4_property_id", + "date_range_days", "connected_email", + }) + sets: list[str] = ["updated_at = now()"] + vals: list[Any] = [] + + def _add(col: str, val: Any) -> None: + sets.append(f"{col} = %s") + vals.append(val) + + if "gsc_site_url" in allowed and gsc_site_url is not None: + _add("gsc_site_url", gsc_site_url.strip() or None) + if "ga4_property_id" in allowed and ga4_property_id is not None: + v = ga4_property_id.strip() if ga4_property_id else "" + if v and not v.isdigit(): + raise ValueError( + "Analytics property ID must be a numeric ID (e.g. 123456789). " + "The G-XXXXXXX code is a Measurement ID." + ) + _add("ga4_property_id", v or None) + if "date_range_days" in allowed and date_range_days is not None and date_range_days > 0: + _add("google_date_range_days", date_range_days) + if "auth_mode" in allowed and auth_mode is not None: + _add("google_auth_mode", auth_mode or None) + if "connected_email" in allowed and connected_email is not None: + _add("google_connected_email", connected_email.strip() or None) + if "refresh_token" in allowed and refresh_token is not None: + token = refresh_token.strip() + _add("google_refresh_token", token or None) + if token: + sets.append("google_connected_at = now()") + else: + sets.append("google_connected_at = NULL") + if "connected_email" not in allowed or connected_email is None: + sets.append("google_connected_email = NULL") + + if len(vals) == 0: + raise ValueError("No valid fields provided") + + vals.append(property_id) + conn.execute( + f"UPDATE properties SET {', '.join(sets)} WHERE id = %s", + vals, + ) + conn.commit() + + +def disconnect_property_google(conn: Connection, property_id: int) -> None: + apply_property_google_credentials_patch( + conn, + property_id, + refresh_token="", + auth_mode=None, + fields_set=frozenset({"refresh_token", "auth_mode"}), + ) + + +def get_property_google_status(conn: Connection, property_id: int) -> dict[str, Any] | None: + """Property-level Google integration status for the integrations UI.""" + from website_profiling.db.google_app_store import read_google_app_settings + from website_profiling.integrations.google.store import read_last_google_fetched_at_for_property + + if not get_property_by_id(conn, property_id): + return None + + prop_status = get_property_google_public_status(conn, property_id) + app_cfg = read_google_app_settings(conn) + has_client_id = bool(app_cfg.get("client_id")) + + return { + **prop_status, + "hasClientId": has_client_id, + "lastFetchedAt": read_last_google_fetched_at_for_property(conn, property_id), + "propertyId": property_id, + } diff --git a/src/website_profiling/db/saved_filter_store.py b/src/website_profiling/db/saved_filter_store.py new file mode 100644 index 00000000..e05bec1d --- /dev/null +++ b/src/website_profiling/db/saved_filter_store.py @@ -0,0 +1,58 @@ +"""Saved crawl filters (saved_crawl_filters table).""" +from __future__ import annotations + +from typing import Any + +from psycopg import Connection +from psycopg.types.json import Json + +from ._common import _row_field + + +def _map_filter_row(row: Any) -> dict[str, Any]: + created = _row_field(row, "created_at") + return { + "id": _row_field(row, "id"), + "propertyId": _row_field(row, "property_id"), + "name": _row_field(row, "name"), + "filterJson": _row_field(row, "filter_json") or {}, + "createdAt": created.isoformat() if hasattr(created, "isoformat") else str(created or ""), + } + + +def list_saved_filters(conn: Connection, property_id: int) -> list[dict[str, Any]]: + cur = conn.execute( + """ + SELECT id, property_id, name, filter_json, created_at + FROM saved_crawl_filters + WHERE property_id = %s + ORDER BY name + """, + (property_id,), + ) + return [_map_filter_row(row) for row in cur.fetchall() or []] + + +def upsert_saved_filter( + conn: Connection, + property_id: int, + name: str, + filter_json: dict[str, Any], +) -> None: + conn.execute( + """ + INSERT INTO saved_crawl_filters (property_id, name, filter_json) + VALUES (%s, %s, %s) + ON CONFLICT (property_id, name) DO UPDATE SET filter_json = EXCLUDED.filter_json + """, + (property_id, name, Json(filter_json)), + ) + conn.commit() + + +def delete_saved_filter(conn: Connection, property_id: int, name: str) -> None: + conn.execute( + "DELETE FROM saved_crawl_filters WHERE property_id = %s AND name = %s", + (property_id, name), + ) + conn.commit() diff --git a/src/website_profiling/integrations/google/gsc_links_store.py b/src/website_profiling/integrations/google/gsc_links_store.py index 85af6030..d9ca1bd3 100644 --- a/src/website_profiling/integrations/google/gsc_links_store.py +++ b/src/website_profiling/integrations/google/gsc_links_store.py @@ -164,3 +164,34 @@ def read_gsc_links_status( "sampleLinkCount": len(data.get("sample_links") or []), "latestLinkCount": len(data.get("latest_links") or []), } + + +def list_backlinks_velocity( + conn: Connection, + property_id: int, + *, + limit: int = 52, +) -> list[dict[str, Any]]: + """Referring-domain trend snapshots for Backlinks velocity chart.""" + from ...db._common import _parse_row_json, _row_field + + limit = max(1, min(int(limit), 52)) + cur = conn.execute( + """SELECT fetched_at, referring_domains, top_domains + FROM gsc_links_snapshots + WHERE property_id = %s + ORDER BY fetched_at ASC + LIMIT %s""", + (property_id, limit), + ) + snapshots: list[dict[str, Any]] = [] + for row in cur.fetchall() or []: + fetched = _row_field(row, "fetched_at", index=0) + top_domains = _parse_row_json(row, "top_domains", index=2) + snapshots.append({ + "capturedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or "") or None, + "referringDomains": int(_row_field(row, "referring_domains", index=1) or 0), + "topDomains": top_domains if isinstance(top_domains, list) else [], + }) + return snapshots + diff --git a/src/website_profiling/integrations/google/keyword_store.py b/src/website_profiling/integrations/google/keyword_store.py index 6a6b5f4a..8c6460ba 100644 --- a/src/website_profiling/integrations/google/keyword_store.py +++ b/src/website_profiling/integrations/google/keyword_store.py @@ -9,7 +9,7 @@ from psycopg import Connection from psycopg.types.json import Json -from ...db.storage import _parse_row_json, _sanitize_for_json +from ...db._common import _parse_row_json, _row_field, _sanitize_for_json def write_keyword_data( @@ -118,9 +118,13 @@ def read_keyword_snapshots_for_property( ) out: list[dict[str, Any]] = [] for row in cur.fetchall(): - data = _parse_row_json(row) + data = _parse_row_json(row, "data", index=1) if isinstance(data, dict): - out.append({"fetched_at": row["fetched_at"], **data}) + fetched = _row_field(row, "fetched_at", index=0) + out.append({ + "fetched_at": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), + **data, + }) return out except Exception: return [] @@ -144,15 +148,33 @@ def read_keyword_history( ORDER BY id DESC LIMIT %s""", (property_id, keyword, limit), ) - return [ - { - "fetched_at": row["fetched_at"], - "position": row["position"], - "clicks": row["clicks"], - "impressions": row["impressions"], - "ctr": row["ctr"], - } - for row in cur.fetchall() - ] + rows = list(cur.fetchall() or []) + return [_map_keyword_history_row(row) for row in reversed(rows)] except Exception: return [] + + +def read_keyword_history_batch( + conn: Connection, + keywords: list[str], + *, + property_id: int, + limit: int = 30, +) -> dict[str, list[dict[str, Any]]]: + """Batch keyword history keyed by keyword string.""" + limit = max(1, min(int(limit), 90)) + results: dict[str, list[dict[str, Any]]] = {} + for kw in keywords: + results[kw] = read_keyword_history(conn, kw, limit, property_id=property_id) + return results + + +def _map_keyword_history_row(row: Any) -> dict[str, Any]: + fetched = _row_field(row, "fetched_at", index=0) + return { + "fetched_at": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), + "position": _row_field(row, "position", index=1), + "clicks": _row_field(row, "clicks", index=2), + "impressions": _row_field(row, "impressions", index=3), + "ctr": _row_field(row, "ctr", index=4), + } diff --git a/src/website_profiling/integrations/google/page_snapshot_store.py b/src/website_profiling/integrations/google/page_snapshot_store.py index 06b4f1a0..3067ac58 100644 --- a/src/website_profiling/integrations/google/page_snapshot_store.py +++ b/src/website_profiling/integrations/google/page_snapshot_store.py @@ -7,7 +7,7 @@ from psycopg import Connection from psycopg.types.json import Json -from ...db.storage import _parse_row_json, _sanitize_for_json +from ...db._common import _parse_row_json, _row_field, _sanitize_for_json from .normalize import normalize_url from .page_lookup import _public_ga4_page, _public_gsc_page, summary_from_slice @@ -33,7 +33,7 @@ def write_page_snapshot(conn: Connection, page_url: str, data: dict[str, Any]) - (page_url.strip(), url_norm, Json(_sanitize_for_json(data))), ) row = cur.fetchone() - snapshot_id = int(row["id"]) if row else 0 + snapshot_id = int(_row_field(row, "id", index=0)) if row else 0 limit = max_snapshots_per_url() conn.execute( """ @@ -60,12 +60,13 @@ def read_page_snapshot(conn: Connection, snapshot_id: int) -> dict[str, Any] | N row = cur.fetchone() if not row: return None - data = _parse_row_json(row) or {} + data = _parse_row_json(row, "data", index=4) or {} + fetched = _row_field(row, "fetched_at", index=3) return { - "snapshotId": int(row["id"]), - "pageUrl": str(row["page_url"]), - "urlNorm": str(row["url_norm"]), - "fetchedAt": row["fetched_at"].isoformat() if row["fetched_at"] else None, + "snapshotId": int(_row_field(row, "id", index=0)), + "pageUrl": str(_row_field(row, "page_url", index=1)), + "urlNorm": str(_row_field(row, "url_norm", index=2)), + "fetchedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), "source": data.get("source") or "live", "gsc": data.get("gsc"), "ga4": data.get("ga4"), @@ -90,13 +91,14 @@ def list_live_history( ) out: list[dict[str, Any]] = [] for row in cur.fetchall(): - data = _parse_row_json(row) or {} + data = _parse_row_json(row, "data", index=2) or {} gsc = data.get("gsc") ga4 = data.get("ga4") + fetched = _row_field(row, "fetched_at", index=1) out.append( { - "id": int(row["id"]), - "fetchedAt": row["fetched_at"].isoformat() if row["fetched_at"] else None, + "id": int(_row_field(row, "id", index=0)), + "fetchedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), "type": "live", **summary_from_slice(gsc, ga4), } @@ -111,6 +113,59 @@ def latest_live_snapshot(conn: Connection, page_url: str) -> dict[str, Any] | No return read_page_snapshot(conn, int(rows[0]["id"])) +def read_page_snapshot_compare(conn: Connection, snapshot_id: int) -> dict[str, Any] | None: + """Load snapshot for page-compare API ({id, fetchedAt, data}).""" + cur = conn.execute( + "SELECT id, fetched_at, data FROM page_google_snapshots WHERE id = %s", + (snapshot_id,), + ) + row = cur.fetchone() + if not row: + return None + data = _parse_row_json(row, "data", index=2) + if not isinstance(data, dict): + data = {} + fetched = _row_field(row, "fetched_at", index=1) + return { + "id": int(_row_field(row, "id", index=0)), + "fetchedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), + "data": data, + } + + +def list_page_snapshot_api_history( + conn: Connection, + page_url: str, + *, + limit: int = 15, +) -> list[dict[str, Any]]: + """History rows with raw gsc/ga4 blobs for the integrations API.""" + url_norm = normalize_url(page_url) + cur = conn.execute( + """ + SELECT id, fetched_at, data + FROM page_google_snapshots + WHERE url_norm = %s + ORDER BY fetched_at DESC, id DESC + LIMIT %s + """, + (url_norm, limit), + ) + out: list[dict[str, Any]] = [] + for row in cur.fetchall() or []: + data = _parse_row_json(row, "data", index=2) or {} + if not isinstance(data, dict): + data = {} + fetched = _row_field(row, "fetched_at", index=1) + out.append({ + "id": int(_row_field(row, "id", index=0)), + "fetchedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), + "gsc": data.get("gsc"), + "ga4": data.get("ga4"), + }) + return out + + def package_live_payload( page_url: str, gsc: dict[str, Any] | None, diff --git a/src/website_profiling/integrations/google/store.py b/src/website_profiling/integrations/google/store.py index ba5fdece..1c0e9334 100644 --- a/src/website_profiling/integrations/google/store.py +++ b/src/website_profiling/integrations/google/store.py @@ -11,7 +11,7 @@ from psycopg import Connection from psycopg.types.json import Json -from ...db.storage import _parse_row_json, _sanitize_for_json +from ...db._common import _parse_row_json, _row_field, _sanitize_for_json def write_google_data( @@ -128,6 +128,125 @@ def read_prior_google_snapshot( return None +def read_last_google_fetched_at(conn: Connection) -> str | None: + """ISO timestamp of the most recent google_data row (any property).""" + try: + cur = conn.execute( + "SELECT fetched_at FROM google_data ORDER BY id DESC LIMIT 1" + ) + row = cur.fetchone() + if not row: + return None + fetched = _row_field(row, "fetched_at", index=0) + if fetched is None: + return None + return fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched) + except Exception: + return None + + +def read_google_snapshot_row( + conn: Connection, + property_id: int, + *, + snapshot_id: int | None = None, +) -> dict[str, Any] | None: + """Return one google_data row as {id, fetchedAt, data} with full parsed blob.""" + try: + if snapshot_id is not None: + cur = conn.execute( + """ + SELECT id, fetched_at, data + FROM google_data + WHERE id = %s AND property_id = %s + """, + (snapshot_id, property_id), + ) + else: + cur = conn.execute( + """ + SELECT id, fetched_at, data + FROM google_data + WHERE property_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (property_id,), + ) + row = cur.fetchone() + if not row: + return None + data = _parse_row_json(row, "data", index=2) + if not isinstance(data, dict): + return None + fetched = _row_field(row, "fetched_at", index=1) + return { + "id": int(_row_field(row, "id", index=0)), + "fetchedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), + "data": data, + } + except Exception: + return None + + +def list_google_snapshot_rows( + conn: Connection, + property_id: int, + *, + limit: int = 10, +) -> list[dict[str, Any]]: + """Recent google_data rows for a property as {id, fetchedAt, data}.""" + limit = max(1, min(int(limit), 50)) + try: + cur = conn.execute( + """ + SELECT id, fetched_at, data + FROM google_data + WHERE property_id = %s + ORDER BY id DESC + LIMIT %s + """, + (property_id, limit), + ) + out: list[dict[str, Any]] = [] + for row in cur.fetchall() or []: + data = _parse_row_json(row, "data", index=2) + if not isinstance(data, dict): + continue + fetched = _row_field(row, "fetched_at", index=1) + out.append({ + "id": int(_row_field(row, "id", index=0)), + "fetchedAt": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), + "data": data, + }) + return out + except Exception: + return [] + + +def read_last_google_fetched_at_for_property(conn: Connection, property_id: int) -> str | None: + """ISO timestamp of the most recent google_data row for a property.""" + try: + cur = conn.execute( + """ + SELECT fetched_at FROM google_data + WHERE property_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (property_id,), + ) + row = cur.fetchone() + if not row: + return None + fetched = _row_field(row, "fetched_at", index=0) + if fetched is None: + return None + return fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched) + except Exception: + return None + + def gsc_row_deltas( current_rows: list[dict[str, Any]], prior_rows: list[dict[str, Any]], diff --git a/src/website_profiling/llm/ollama_catalog.py b/src/website_profiling/llm/ollama_catalog.py new file mode 100644 index 00000000..865a6f8b --- /dev/null +++ b/src/website_profiling/llm/ollama_catalog.py @@ -0,0 +1,174 @@ +"""Ollama local + cloud model catalog (mirrors web/src/server/ollamaModels.ts).""" +from __future__ import annotations + +import json +import re +import urllib.error +import urllib.request +from typing import Any + +OLLAMA_CLOUD_CATALOG_URL = "https://ollama.com/api/tags" + +PRO_CLOUD_MODEL_PATTERNS = [ + re.compile(r"671b", re.I), + re.compile(r"480b", re.I), + re.compile(r":1t(?:-cloud|:cloud)?$", re.I), + re.compile(r"v4-pro", re.I), + re.compile(r"nemotron-3-ultra", re.I), + re.compile(r"nemotron-3-super", re.I), + re.compile(r"mistral-large", re.I), + re.compile(r"397b", re.I), + re.compile(r"cogito-2\.1:671b", re.I), + re.compile(r"deepseek-v4-pro", re.I), + re.compile(r"qwen3-coder:480b", re.I), + re.compile(r"gpt-oss:120b", re.I), +] + + +def is_cloud_model_ref(name: str) -> bool: + return name.endswith("-cloud") or name.endswith(":cloud") + + +def to_cloud_model_ref(name: str) -> str: + trimmed = name.strip() + if not trimmed: + return trimmed + if trimmed.endswith("-cloud") or trimmed.endswith(":cloud"): + return trimmed + return f"{trimmed}-cloud" if ":" in trimmed else f"{trimmed}:cloud" + + +def resolve_billing_tier(name: str, source: str) -> dict[str, Any]: + cloud = source == "cloud" or is_cloud_model_ref(name) + if not cloud: + return {"billing": "free_local", "requires_subscription": False} + if any(p.search(name) for p in PRO_CLOUD_MODEL_PATTERNS): + return {"billing": "cloud_pro", "requires_subscription": True} + return {"billing": "cloud_free", "requires_subscription": True} + + +def _with_billing(entry: dict[str, Any]) -> dict[str, Any]: + tier = resolve_billing_tier(str(entry.get("name") or ""), str(entry.get("source") or "local")) + return {**entry, **tier} + + +def _normalize_local_model(raw: dict[str, Any]) -> dict[str, Any] | None: + name = str(raw.get("name") or "").strip() + if not name: + return None + cloud = bool(raw.get("remote_host")) or is_cloud_model_ref(name) + details = raw.get("details") if isinstance(raw.get("details"), dict) else {} + return _with_billing({ + "name": name, + "source": "cloud" if cloud else "local", + "installed": True, + "capabilities": raw.get("capabilities") if isinstance(raw.get("capabilities"), list) else None, + "context_length": details.get("context_length"), + }) + + +def _normalize_catalog_model(raw: dict[str, Any]) -> dict[str, Any] | None: + base = str(raw.get("name") or "").strip() + if not base: + return None + return _with_billing({ + "name": to_cloud_model_ref(base), + "source": "cloud", + "installed": False, + }) + + +def _model_key(name: str) -> str: + return name.lower() + + +def merge_ollama_models( + local: list[dict[str, Any]], + cloud_catalog: list[dict[str, Any]], +) -> list[dict[str, Any]]: + by_key: dict[str, dict[str, Any]] = {} + for m in cloud_catalog: + by_key[_model_key(str(m.get("name") or ""))] = m + for m in local: + key = _model_key(str(m.get("name") or "")) + existing = by_key.get(key) + merged = { + **(existing or {}), + **m, + "installed": True, + "capabilities": m.get("capabilities") or (existing or {}).get("capabilities"), + "context_length": m.get("context_length") or (existing or {}).get("context_length"), + } + by_key[key] = _with_billing(merged) + + def sort_key(m: dict[str, Any]) -> tuple: + return ( + 0 if m.get("installed") else 1, + 0 if m.get("source") == "local" else 1, + str(m.get("name") or ""), + ) + + return sorted(by_key.values(), key=sort_key) + + +def _fetch_json(url: str, *, timeout: float = 8.0) -> dict[str, Any] | None: + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError): + return None + + +def fetch_ollama_models(base_url: str) -> dict[str, Any]: + normalized_base = (base_url or "http://127.0.0.1:11434").rstrip("/") or "http://127.0.0.1:11434" + + local_data = _fetch_json(f"{normalized_base}/api/tags", timeout=8.0) + cloud_data = _fetch_json(OLLAMA_CLOUD_CATALOG_URL, timeout=12.0) + + local_ok = local_data is not None + cloud_catalog_ok = cloud_data is not None + + local_models = [ + m for raw in (local_data or {}).get("models") or [] + if isinstance(raw, dict) + for m in [_normalize_local_model(raw)] + if m is not None + ] + cloud_models = [ + m for raw in (cloud_data or {}).get("models") or [] + if isinstance(raw, dict) + for m in [_normalize_catalog_model(raw)] + if m is not None + ] + models = merge_ollama_models(local_models, cloud_models) + + if not local_ok and not cloud_catalog_ok: + return { + "ok": False, + "baseUrl": normalized_base, + "models": [], + "cloudCatalogOk": False, + "localOk": False, + "error": "Cannot reach Ollama or the cloud model catalog.", + } + + return { + "ok": local_ok or cloud_catalog_ok, + "baseUrl": normalized_base, + "models": models, + "cloudCatalogOk": cloud_catalog_ok, + "localOk": local_ok, + } + + +def model_is_configured(models: list[dict[str, Any]], configured_model: str) -> bool: + target = configured_model.strip() + if not target: + return len(models) > 0 + key = _model_key(target) + return any(_model_key(str(m.get("name") or "")) == key for m in models) + + +def models_support_tools(models: list[dict[str, Any]]) -> bool: + return any("tools" in (m.get("capabilities") or []) for m in models) diff --git a/src/website_profiling/tools/audit_tools/backlinks/backlinks.py b/src/website_profiling/tools/audit_tools/backlinks/backlinks.py index b322c275..781c7099 100644 --- a/src/website_profiling/tools/audit_tools/backlinks/backlinks.py +++ b/src/website_profiling/tools/audit_tools/backlinks/backlinks.py @@ -118,20 +118,20 @@ def get_backlinks_velocity(conn: Connection, ctx: AuditToolContext, args: dict[s return {"error": "property_id is required"} limit = parse_limit(args.get("limit"), 52, 52) cur = conn.execute( - """SELECT captured_at, referring_domains, top_domains + """SELECT fetched_at, referring_domains, top_domains FROM gsc_links_snapshots WHERE property_id = %s - ORDER BY captured_at ASC + ORDER BY fetched_at ASC LIMIT %s""", (int(scoped.property_id), limit), ) snapshots = [] for row in cur.fetchall() or []: - captured = row["captured_at"] if hasattr(row, "keys") else row[0] + fetched = row["fetched_at"] if hasattr(row, "keys") else row[0] domains = row["referring_domains"] if hasattr(row, "keys") else row[1] top = row["top_domains"] if hasattr(row, "keys") else row[2] snapshots.append({ - "captured_at": captured.isoformat() if hasattr(captured, "isoformat") else str(captured or ""), + "captured_at": fetched.isoformat() if hasattr(fetched, "isoformat") else str(fetched or ""), "referring_domains": domains, "top_domains": top, }) diff --git a/src/website_profiling/worker/__init__.py b/src/website_profiling/worker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/website_profiling/worker/__main__.py b/src/website_profiling/worker/__main__.py new file mode 100644 index 00000000..f83cfde6 --- /dev/null +++ b/src/website_profiling/worker/__main__.py @@ -0,0 +1,7 @@ +"""Entry point: python -m website_profiling.worker""" +from __future__ import annotations + +from .loop import run_worker_loop + +if __name__ == "__main__": + run_worker_loop() diff --git a/src/website_profiling/worker/loop.py b/src/website_profiling/worker/loop.py new file mode 100644 index 00000000..39f9b0fb --- /dev/null +++ b/src/website_profiling/worker/loop.py @@ -0,0 +1,52 @@ +"""Worker main loop: poll pending jobs and run them one at a time.""" +from __future__ import annotations + +import logging +import os +import signal +import time + +from website_profiling.db.pipeline_jobs import try_claim_pending_job +from website_profiling.db.pool import db_session + +from .runner import run_job + +logger = logging.getLogger("website_profiling.worker") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +_POLL_INTERVAL = float(os.getenv("WP_WORKER_POLL_INTERVAL", "1.0")) + +_running = True + + +def _handle_sigterm(signum: int, frame: object) -> None: + global _running + logger.info("Worker received signal %s, shutting down after current job.", signum) + _running = False + + +def run_worker_loop() -> None: + signal.signal(signal.SIGTERM, _handle_sigterm) + signal.signal(signal.SIGINT, _handle_sigterm) + + logger.info("Pipeline worker started (PID %s, poll interval %.1fs).", os.getpid(), _POLL_INTERVAL) + + while _running: + try: + with db_session() as conn: + job = try_claim_pending_job(conn, os.getpid()) + except Exception as exc: + logger.warning("Worker DB poll error: %s", exc) + time.sleep(_POLL_INTERVAL) + continue + + if job: + logger.info("Running job %s (command=%r).", job["id"], job.get("command")) + try: + run_job(job) + except Exception as exc: + logger.error("Unhandled error in job %s: %s", job["id"], exc, exc_info=True) + else: + time.sleep(_POLL_INTERVAL) + + logger.info("Worker exiting cleanly.") diff --git a/src/website_profiling/worker/runner.py b/src/website_profiling/worker/runner.py new file mode 100644 index 00000000..f7f72d7d --- /dev/null +++ b/src/website_profiling/worker/runner.py @@ -0,0 +1,134 @@ +"""Subprocess runner: spawn the audit CLI and pump output to the DB.""" +from __future__ import annotations + +import os +import subprocess +import sys +import threading +import time +from typing import Any + +from website_profiling.db.pipeline_jobs import append_job_log, check_flags, finish_job +from website_profiling.db.pool import db_session + +from .signals import cancel_subprocess, pause_subprocess + + +def _get_spawn_env(property_id: Any = None) -> dict[str, str]: + """Build env dict for spawning `python -m src`, mirroring pipelineSpawnEnv.ts.""" + repo_root = os.environ.get("WEBSITE_PROFILING_ROOT", os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + )) + data_dir = os.environ.get("DATA_DIR", os.path.join(repo_root, "data")) + env = os.environ.copy() + env["WEBSITE_PROFILING_ROOT"] = repo_root + env["DATA_DIR"] = data_dir + existing_pythonpath = env.get("PYTHONPATH", "") + src_path = os.path.join(repo_root, "src") + env["PYTHONPATH"] = f"{src_path}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else src_path + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONUTF8"] = "1" + if property_id is not None: + env["WP_PROPERTY_ID"] = str(property_id) + return env + + +def _pump_output(proc: subprocess.Popen, job_id: str) -> None: # type: ignore[type-arg] + """Read stdout+stderr from the subprocess and append to DB log.""" + def _pump_stream(stream: Any) -> None: + while True: + line = stream.readline() + if not line: + break + text = line if isinstance(line, str) else line.decode("utf-8", errors="replace") + try: + with db_session() as conn: + append_job_log(conn, job_id, text) + except Exception: + pass + + t_out = threading.Thread(target=_pump_stream, args=(proc.stdout,), daemon=True) + t_err = threading.Thread(target=_pump_stream, args=(proc.stderr,), daemon=True) + t_out.start() + t_err.start() + t_out.join() + t_err.join() + + +def run_job(job: dict) -> None: + """Execute one pipeline job, handling cancel/pause/resume signals.""" + job_id: str = job["id"] + command: str | None = job.get("command") + property_id = job.get("property_id") + + repo_root = os.environ.get("WEBSITE_PROFILING_ROOT", "") + python_exe = os.environ.get("PYTHON", sys.executable) + + args = [python_exe, "-m", "src"] + if command: + args.extend(command.split()) + + env = _get_spawn_env(property_id) + + try: + proc = subprocess.Popen( + args, + cwd=repo_root or None, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + universal_newlines=True, + ) + except Exception as exc: + with db_session() as conn: + finish_job(conn, job_id, "error", -1, str(exc)) + return + + pump_thread = threading.Thread(target=_pump_output, args=(proc, job_id), daemon=True) + pump_thread.start() + + paused = False + + while proc.poll() is None: + time.sleep(1.0) + try: + with db_session() as conn: + cancel, pause = check_flags(conn, job_id) + except Exception: + cancel, pause = False, False + + if cancel: + cancel_subprocess(proc) + proc.wait() + pump_thread.join(timeout=5) + with db_session() as conn: + finish_job(conn, job_id, "error", -1, "Cancelled by user") + return + + if pause and not paused: + pause_subprocess(proc) + paused = True + + proc.wait() + pump_thread.join(timeout=10) + + exit_code = proc.returncode + + if paused and exit_code == 0: + with db_session() as conn: + job_row = conn.execute( + "SELECT log_text FROM pipeline_jobs WHERE id = %s::uuid", (job_id,) + ).fetchone() + log_text = str((job_row or {}).get("log_text") or "") + log_truncated_row = conn.execute( + "SELECT log_truncated FROM pipeline_jobs WHERE id = %s::uuid", (job_id,) + ).fetchone() + log_truncated = bool((log_truncated_row or {}).get("log_truncated")) + finish_job(conn, job_id, "paused", exit_code, log_truncated=log_truncated) + return + + status = "success" if exit_code == 0 else "error" + error = None if exit_code == 0 else f"Process exited with code {exit_code}" + with db_session() as conn: + finish_job(conn, job_id, status, exit_code, error) diff --git a/src/website_profiling/worker/signals.py b/src/website_profiling/worker/signals.py new file mode 100644 index 00000000..402151c6 --- /dev/null +++ b/src/website_profiling/worker/signals.py @@ -0,0 +1,34 @@ +"""Cancel and pause signal helpers for the pipeline worker.""" +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile + + +def cancel_subprocess(proc: subprocess.Popen) -> None: # type: ignore[type-arg] + """Kill a subprocess as hard as possible.""" + try: + proc.kill() + except ProcessLookupError: + pass + + +def pause_subprocess(proc: subprocess.Popen) -> None: # type: ignore[type-arg] + """Send SIGUSR1 on Unix or write a pause-flag file on Windows.""" + if sys.platform == "win32": + # Windows: write a flag file the Python worker checks. + flag = os.path.join(tempfile.gettempdir(), f"wp_pause_{proc.pid}.flag") + try: + with open(flag, "w") as f: + f.write("pause") + except OSError: + pass + else: + import signal + + try: + os.kill(proc.pid, signal.SIGUSR1) + except ProcessLookupError: + pass diff --git a/tests/README.md b/tests/README.md index 664a432a..7d5f45c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,6 +25,26 @@ tests/content_studio/ test_tools.py # deterministic analyze tools ``` +## API integration (`tests/api/`) + +FastAPI routes are omitted from the core coverage gate (see `.coveragerc`). Use HTTP integration tests against a real Postgres instead: + +``` +tests/api/ + conftest.py # TestClient + ephemeral property fixture + test_api_integration.py # @pytest.mark.integration — full route smoke + CRUD + test_content_drafts_list.py + test_report_loader_list.py +``` + +Requires `DATABASE_URL` (same as other `@pytest.mark.integration` tests). Run: + +```bash +pytest tests/api/test_api_integration.py -m integration --no-cov +``` + +These tests catch response-shape regressions (camelCase vs snake_case), dict_row SQL bugs, and wrong column names that unit mocks miss. + ## Core (everything else) Remaining `tests/test_*.py` files cover the core gate (100% on all packages except `reporting/`, `tools/`, and other omits in `.coveragerc`). diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 00000000..41f077a6 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,56 @@ +"""Shared fixtures for FastAPI integration tests (requires PostgreSQL).""" +from __future__ import annotations + +import os +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from website_profiling.api.deps import get_db +from website_profiling.api.main import app +from website_profiling.db.pool import db_session + + +def _database_url_configured() -> bool: + return bool((os.environ.get("DATABASE_URL") or "").strip()) + + +@pytest.fixture(scope="session") +def require_database_url() -> None: + if not _database_url_configured(): + pytest.skip("DATABASE_URL not set — start Postgres and run alembic upgrade head") + + +def _override_get_db() -> Iterator[Any]: + with db_session() as conn: + yield conn + + +@pytest.fixture +def api_client(require_database_url: None) -> Iterator[TestClient]: + app.dependency_overrides[get_db] = _override_get_db + with TestClient(app) as client: + yield client + app.dependency_overrides.clear() + + +@pytest.fixture +def test_property(require_database_url: None) -> Iterator[dict[str, Any]]: + """Ephemeral property row; deleted after the test module using it finishes.""" + domain = f"api-int-{uuid.uuid4().hex[:12]}.example" + with db_session() as conn: + from website_profiling.db.property_store import delete_property, upsert_property_by_domain + + property_id = upsert_property_by_domain( + conn, + "API Integration Test", + domain, + f"https://{domain}", + ) + payload = {"id": property_id, "domain": domain, "name": "API Integration Test"} + yield payload + with db_session() as conn: + delete_property(conn, property_id) diff --git a/tests/api/test_api_integration.py b/tests/api/test_api_integration.py new file mode 100644 index 00000000..83d238af --- /dev/null +++ b/tests/api/test_api_integration.py @@ -0,0 +1,357 @@ +"""FastAPI HTTP integration tests — exercises real routes against PostgreSQL. + +These catch response-shape regressions and dict_row bugs that unit tests miss. +Requires DATABASE_URL (same as other @pytest.mark.integration tests). +""" +from __future__ import annotations + +import uuid +from typing import Any +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from website_profiling.db.pool import db_session + + +pytestmark = pytest.mark.integration + + +def test_health(api_client: TestClient) -> None: + res = api_client.get("/api/health") + assert res.status_code == 200 + body = res.json() + assert body["ok"] is True + assert body["database"] == "up" + + +def test_report_meta_response_shape(api_client: TestClient) -> None: + res = api_client.get("/api/report/meta") + assert res.status_code == 200 + body = res.json() + assert "reports" in body + assert "crawlRuns" in body + assert isinstance(body["reports"], list) + for row in body["reports"]: + assert "canonical_domain" in row + assert "site_name" in row + assert "generated_at" in row + assert "canonicalDomain" not in row + + +def test_properties_crud_and_ops(api_client: TestClient) -> None: + domain = f"api-prop-{uuid.uuid4().hex[:10]}.example" + create = api_client.post( + "/api/properties", + json={"name": "Props API", "canonical_domain": domain, "site_url": f"https://{domain}"}, + ) + assert create.status_code == 201 + created = create.json() + property_id = int(created["id"]) + assert created["canonical_domain"] == domain + + try: + listing = api_client.get("/api/properties") + assert listing.status_code == 200 + ids = {p["id"] for p in listing.json()["properties"]} + assert property_id in ids + + detail = api_client.get(f"/api/properties/{property_id}") + assert detail.status_code == 200 + assert detail.json()["canonical_domain"] == domain + + ops_put = api_client.put( + f"/api/properties/{property_id}/ops", + json={ + "scheduleCron": "0 9 * * 1", + "alertWebhookUrl": "https://hooks.example/alert", + "alertEmail": "ops@example.com", + }, + ) + assert ops_put.status_code == 200 + assert ops_put.json()["ok"] is True + + ops_get = api_client.get(f"/api/properties/{property_id}/ops") + assert ops_get.status_code == 200 + ops = ops_get.json() + assert ops["schedule_cron"] == "0 9 * * 1" + assert ops["alert_webhook_url"] == "https://hooks.example/alert" + assert ops["alert_email"] == "ops@example.com" + + preset_put = api_client.put( + f"/api/properties/{property_id}/preset", + json={"preset": "quick"}, + ) + assert preset_put.status_code == 200 + assert preset_put.json()["default_crawl_preset"] == "quick" + finally: + deleted = api_client.delete(f"/api/properties/{property_id}") + assert deleted.status_code == 200 + assert deleted.json()["ok"] is True + + +def test_property_google_status_shape(api_client: TestClient, test_property: dict[str, Any]) -> None: + property_id = int(test_property["id"]) + res = api_client.get(f"/api/properties/{property_id}/google/status") + assert res.status_code == 200 + body = res.json() + for key in ( + "connected", + "authMode", + "gscSiteUrl", + "ga4PropertyId", + "dateRangeDays", + "hasClientId", + "lastFetchedAt", + "propertyId", + ): + assert key in body + assert body["propertyId"] == property_id + + +def test_integrations_google_status(api_client: TestClient) -> None: + res = api_client.get("/api/integrations/google/status") + assert res.status_code == 200 + body = res.json() + assert "hasClientId" in body + assert "lastFetchedAt" in body + + +def test_pipeline_and_llm_config_wrappers(api_client: TestClient) -> None: + pipe = api_client.get("/api/pipeline-config") + assert pipe.status_code == 200 + pipe_body = pipe.json() + assert "state" in pipe_body + assert isinstance(pipe_body["state"], dict) + + llm = api_client.get("/api/llm-config") + assert llm.status_code == 200 + llm_body = llm.json() + assert "state" in llm_body + assert isinstance(llm_body["state"], dict) + + +def test_content_drafts_full_crud(api_client: TestClient, test_property: dict[str, Any]) -> None: + property_id = int(test_property["id"]) + + empty = api_client.get("/api/content-drafts", params={"propertyId": property_id}) + assert empty.status_code == 200 + assert isinstance(empty.json()["drafts"], list) + + create = api_client.post( + "/api/content-drafts", + json={ + "propertyId": property_id, + "title": "Integration draft", + "target_keyword": "seo audit", + }, + ) + assert create.status_code == 200 + draft_id = int(create.json()["id"]) + + listed = api_client.get("/api/content-drafts", params={"propertyId": property_id}) + assert listed.status_code == 200 + drafts = listed.json()["drafts"] + match = next((d for d in drafts if d["id"] == draft_id), None) + assert match is not None + assert match["property_id"] == property_id + assert match["target_keyword"] == "seo audit" + + detail = api_client.get(f"/api/content-drafts/{draft_id}") + assert detail.status_code == 200 + assert detail.json()["draft"]["title"] == "Integration draft" + + patched = api_client.patch( + f"/api/content-drafts/{draft_id}", + json={"title": "Updated draft", "body_html": "

Hello

"}, + ) + assert patched.status_code == 200 + assert patched.json()["draft"]["title"] == "Updated draft" + + removed = api_client.delete(f"/api/content-drafts/{draft_id}") + assert removed.status_code == 200 + assert removed.json()["ok"] is True + + +def test_dashboards_crud(api_client: TestClient, test_property: dict[str, Any]) -> None: + property_id = int(test_property["id"]) + + create = api_client.post( + "/api/dashboards", + json={ + "propertyId": property_id, + "name": "Integration dashboard", + "layoutJson": {"version": 2, "widgets": [], "slicers": []}, + }, + ) + assert create.status_code == 201 + dashboard = create.json()["dashboard"] + dashboard_id = int(dashboard["id"]) + assert dashboard["propertyId"] == property_id + assert dashboard["name"] == "Integration dashboard" + + listed = api_client.get("/api/dashboards", params={"propertyId": property_id}) + assert listed.status_code == 200 + ids = {d["id"] for d in listed.json()["dashboards"]} + assert dashboard_id in ids + + updated = api_client.put( + f"/api/dashboards/{dashboard_id}", + json={"propertyId": property_id, "name": "Renamed dashboard"}, + ) + assert updated.status_code == 200 + assert updated.json()["dashboard"]["name"] == "Renamed dashboard" + + deleted = api_client.delete( + f"/api/dashboards/{dashboard_id}", + params={"propertyId": property_id}, + ) + assert deleted.status_code == 200 + assert deleted.json()["ok"] is True + + +def test_saved_filters_crud(api_client: TestClient, test_property: dict[str, Any]) -> None: + property_id = int(test_property["id"]) + filter_name = f"filter-{uuid.uuid4().hex[:8]}" + + upsert = api_client.post( + "/api/filters", + json={ + "propertyId": property_id, + "name": filter_name, + "filterJson": {"status": ["200"]}, + }, + ) + assert upsert.status_code == 200 + assert upsert.json()["ok"] is True + + listed = api_client.get("/api/filters", params={"propertyId": property_id}) + assert listed.status_code == 200 + names = {f["name"] for f in listed.json()["filters"]} + assert filter_name in names + + deleted = api_client.request( + "DELETE", + "/api/filters", + json={"propertyId": property_id, "name": filter_name}, + ) + assert deleted.status_code == 200 + assert deleted.json()["ok"] is True + + +def test_issue_status_upsert_and_list(api_client: TestClient, test_property: dict[str, Any]) -> None: + property_id = int(test_property["id"]) + + empty = api_client.get("/api/issues/status", params={"propertyId": property_id}) + assert empty.status_code == 200 + assert isinstance(empty.json()["issues"], list) + + upsert = api_client.put( + "/api/issues/status", + json={ + "propertyId": property_id, + "message": "Missing meta description", + "status": "open", + "url": "https://example.com/page", + "priority": "Medium", + }, + ) + assert upsert.status_code == 200 + issue = upsert.json()["issue"] + assert issue["propertyId"] == property_id + assert issue["status"] == "open" + assert issue["message"] == "Missing meta description" + + listed = api_client.get("/api/issues/status", params={"propertyId": property_id}) + assert listed.status_code == 200 + messages = {i["message"] for i in listed.json()["issues"]} + assert "Missing meta description" in messages + + +def test_portfolio_delete_crawl_run(api_client: TestClient, test_property: dict[str, Any]) -> None: + property_id = int(test_property["id"]) + with db_session() as conn: + from website_profiling.db.crawl_store import create_crawl_run + + crawl_run_id = create_crawl_run( + conn, + start_url=f"https://{test_property['domain']}", + property_id=property_id, + ) + + res = api_client.request( + "DELETE", + "/api/portfolio/delete", + json={"crawlRunId": crawl_run_id}, + ) + assert res.status_code == 200 + assert res.json()["ok"] is True + + with db_session() as conn: + cur = conn.execute("SELECT id FROM crawl_runs WHERE id = %s", (crawl_run_id,)) + assert cur.fetchone() is None + + +def test_properties_resolve(api_client: TestClient, test_property: dict[str, Any]) -> None: + res = api_client.get( + "/api/properties/resolve", + params={"startUrl": f"https://{test_property['domain']}/"}, + ) + assert res.status_code == 200 + body = res.json() + assert body["id"] == test_property["id"] + assert body["canonical_domain"] == test_property["domain"] + + +def test_ollama_status_response_shape(api_client: TestClient) -> None: + fake_models = [ + { + "name": "llama3.2", + "source": "local", + "installed": True, + "capabilities": ["tools"], + "billing": "free_local", + "requires_subscription": False, + } + ] + with ( + patch( + "website_profiling.llm.ollama_catalog.fetch_ollama_models", + return_value={ + "ok": True, + "baseUrl": "http://127.0.0.1:11434", + "models": fake_models, + "cloudCatalogOk": True, + "localOk": True, + }, + ), + patch( + "website_profiling.db.config_store.read_llm_config", + return_value={"llm_model": "llama3.2", "llm_base_url": "http://127.0.0.1:11434"}, + ), + ): + res = api_client.get("/api/ollama/status") + + assert res.status_code == 200 + body = res.json() + assert body["ok"] is True + assert body["configuredModel"] == "llama3.2" + assert body["modelInstalled"] is True + assert body["supportsTools"] is True + assert isinstance(body["models"], list) + assert len(body["models"]) == 1 + + +def test_backlinks_velocity_empty(api_client: TestClient, test_property: dict[str, Any]) -> None: + res = api_client.get( + "/api/backlinks/velocity", + params={"propertyId": test_property["id"]}, + ) + assert res.status_code == 200 + assert isinstance(res.json()["snapshots"], list) + + +def test_report_payload_not_found(api_client: TestClient) -> None: + res = api_client.get("/api/report/payload", params={"reportId": 999999999}) + assert res.status_code == 404 diff --git a/tests/api/test_content_drafts_list.py b/tests/api/test_content_drafts_list.py new file mode 100644 index 00000000..4b7d3f04 --- /dev/null +++ b/tests/api/test_content_drafts_list.py @@ -0,0 +1,30 @@ +"""Content drafts list must work with psycopg dict_row (pool default).""" +from __future__ import annotations + +from website_profiling.db.content_draft_store import list_content_drafts +from website_profiling.db.pool import db_session + + +def test_list_content_drafts_with_rows() -> None: + with db_session() as conn: + cur = conn.execute("SELECT id FROM properties LIMIT 1") + row = cur.fetchone() + assert row is not None + property_id = int(row["id"]) + + conn.execute( + "DELETE FROM content_drafts WHERE property_id = %s AND title = 'Dict row test'", + (property_id,), + ) + conn.execute( + """INSERT INTO content_drafts (property_id, title, target_keyword) + VALUES (%s, 'Dict row test', 'seo')""", + (property_id,), + ) + conn.commit() + + drafts = list_content_drafts(conn, property_id) + assert len(drafts) >= 1 + draft = next(d for d in drafts if d["title"] == "Dict row test") + assert draft["property_id"] == property_id + assert draft["target_keyword"] == "seo" diff --git a/tests/api/test_report_loader_list.py b/tests/api/test_report_loader_list.py new file mode 100644 index 00000000..a7699d13 --- /dev/null +++ b/tests/api/test_report_loader_list.py @@ -0,0 +1,29 @@ +"""Tests for report_loader list_reports field naming (snake_case for frontend).""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from website_profiling.api.services.report_loader import list_reports + + +def test_list_reports_uses_snake_case_keys() -> None: + row = { + "id": 7, + "canonical_domain": "example.com", + "site_name": "Example", + "generated_at": MagicMock(isoformat=lambda: "2026-01-01T00:00:00+00:00"), + } + conn = MagicMock() + conn.execute.return_value.fetchall.return_value = [row] + + reports = list_reports(conn) + assert len(reports) == 1 + assert reports[0] == { + "id": 7, + "canonical_domain": "example.com", + "site_name": "Example", + "generated_at": "2026-01-01T00:00:00+00:00", + } + assert "canonicalDomain" not in reports[0] + assert "siteName" not in reports[0] + assert "generatedAt" not in reports[0] diff --git a/tests/llm/test_ollama_catalog.py b/tests/llm/test_ollama_catalog.py new file mode 100644 index 00000000..94903ccf --- /dev/null +++ b/tests/llm/test_ollama_catalog.py @@ -0,0 +1,24 @@ +"""Ollama catalog merge and model lookup.""" +from __future__ import annotations + +from website_profiling.llm.ollama_catalog import ( + merge_ollama_models, + model_is_configured, + models_support_tools, +) + + +def test_merge_ollama_models_prefers_installed_local() -> None: + local = [{"name": "llama3.2", "source": "local", "installed": True, "capabilities": ["tools"]}] + cloud = [{"name": "llama3.2:cloud", "source": "cloud", "installed": False}] + merged = merge_ollama_models(local, cloud) + assert len(merged) >= 1 + entry = next(m for m in merged if m["name"] == "llama3.2") + assert entry["installed"] is True + assert entry["capabilities"] == ["tools"] + + +def test_model_is_configured_case_insensitive() -> None: + models = [{"name": "Llama3.2", "source": "local", "installed": True}] + assert model_is_configured(models, "llama3.2") is True + assert models_support_tools(models) is False diff --git a/tests/test_db_pipeline_jobs_unit.py b/tests/test_db_pipeline_jobs_unit.py new file mode 100644 index 00000000..2a583a2b --- /dev/null +++ b/tests/test_db_pipeline_jobs_unit.py @@ -0,0 +1,301 @@ +"""Unit tests for website_profiling.db.pipeline_jobs using FakeConn.""" +from __future__ import annotations + +import pytest +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) +from db_test_fakes import FakeConn, FakeCursor + +from website_profiling.db.pipeline_jobs import ( + PIPELINE_LOG_MAX, + PIPELINE_LOG_TRIM, + _trim_log, + append_job_log, + cancel_job_in_db, + check_flags, + enqueue_job, + finish_job, + get_active_job, + get_job, + list_jobs, + reconcile_stale_jobs, + set_cancel_flag, + set_pause_flag, + try_claim_pending_job, +) + + +# ── _trim_log ───────────────────────────────────────────────────────────────── + +def test_trim_log_no_truncation(): + result, truncated = _trim_log("hello", " world") + assert result == "hello world" + assert truncated is False + + +def test_trim_log_truncation(): + big = "x" * PIPELINE_LOG_MAX + result, truncated = _trim_log(big, "extra") + assert truncated is True + assert len(result) == PIPELINE_LOG_TRIM + + +# ── enqueue_job ─────────────────────────────────────────────────────────────── + +def test_enqueue_job_success(monkeypatch): + conn = FakeConn() + # reconcile_stale_jobs will be called; make it a no-op + monkeypatch.setattr( + "website_profiling.db.pipeline_jobs.reconcile_stale_jobs", lambda c: 0 + ) + # enqueue returns a row (success) + conn.set_next_cursor(FakeCursor(fetchone_value={"id": "abc-123"})) + result = enqueue_job(conn, "abc-123", "crawl", None, None) + assert result is True + assert conn.commits == 1 + + +def test_enqueue_job_already_running(monkeypatch): + conn = FakeConn() + monkeypatch.setattr( + "website_profiling.db.pipeline_jobs.reconcile_stale_jobs", lambda c: 0 + ) + # enqueue returns no row (already running) + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = enqueue_job(conn, "abc-123", "crawl", None, None) + assert result is False + + +# ── try_claim_pending_job ───────────────────────────────────────────────────── + +def test_try_claim_pending_job_returns_job(): + conn = FakeConn() + conn.set_next_cursor( + FakeCursor( + fetchone_value={ + "id": "job-1", + "job_type": "crawl", + "command": None, + "property_id": None, + } + ) + ) + result = try_claim_pending_job(conn, worker_pid=1234) + assert result is not None + assert result["id"] == "job-1" + assert result["job_type"] == "crawl" + assert conn.commits == 1 + + +def test_try_claim_pending_job_returns_none(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = try_claim_pending_job(conn, worker_pid=1234) + assert result is None + + +# ── append_job_log ──────────────────────────────────────────────────────────── + +def test_append_job_log_no_row(): + conn = FakeConn() + # BEGIN → FakeCursor, SELECT FOR UPDATE → returns None row + conn.set_next_cursor(FakeCursor()) + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = append_job_log(conn, "job-1", "some output") + assert result is False + + +def test_append_job_log_appends_successfully(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor()) # BEGIN + conn.set_next_cursor( + FakeCursor(fetchone_value={"log_text": "existing", "log_truncated": False}) + ) + conn.set_next_cursor(FakeCursor()) # UPDATE + conn.set_next_cursor(FakeCursor()) # COMMIT + result = append_job_log(conn, "job-1", " more") + assert result is False # not truncated + + +def test_append_job_log_error_calls_rollback(): + class BoomConn(FakeConn): + def execute(self, sql: str, params=None): # type: ignore[override] + self.executed.append((sql, params)) + if "FOR UPDATE" in sql: + raise RuntimeError("db error") + return FakeCursor() + + conn = BoomConn() + with pytest.raises(RuntimeError): + append_job_log(conn, "job-1", "chunk") + sqls = [s for s, _ in conn.executed] + assert any("ROLLBACK" in s for s in sqls) + + +def test_append_job_log_error_rollback_also_fails(): + """Covers the 'except Exception: pass' inside the rollback handler.""" + + class BoomAllConn(FakeConn): + def execute(self, sql: str, params=None): # type: ignore[override] + self.executed.append((sql, params)) + if "FOR UPDATE" in sql or "ROLLBACK" in sql: + raise RuntimeError("db error") + return FakeCursor() + + conn = BoomAllConn() + with pytest.raises(RuntimeError): + append_job_log(conn, "job-1", "chunk") + + +# ── finish_job ──────────────────────────────────────────────────────────────── + +def test_finish_job_without_log_truncated(): + conn = FakeConn() + finish_job(conn, "job-1", "completed", 0) + assert conn.commits == 1 + sql = conn.executed[0][0] + assert "log_truncated" not in sql + + +def test_finish_job_with_log_truncated(): + conn = FakeConn() + finish_job(conn, "job-1", "error", 1, error="oops", log_truncated=True) + assert conn.commits == 1 + sql = conn.executed[0][0] + assert "log_truncated" in sql + + +# ── check_flags ─────────────────────────────────────────────────────────────── + +def test_check_flags_returns_false_when_no_row(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + cancel, pause = check_flags(conn, "job-1") + assert cancel is False + assert pause is False + + +def test_check_flags_returns_values(): + conn = FakeConn() + conn.set_next_cursor( + FakeCursor(fetchone_value={"cancel_requested": True, "pause_requested": False}) + ) + cancel, pause = check_flags(conn, "job-1") + assert cancel is True + assert pause is False + + +# ── set_cancel_flag / set_pause_flag ───────────────────────────────────────── + +def test_set_cancel_flag(): + conn = FakeConn() + set_cancel_flag(conn, "job-1") + assert conn.commits == 1 + assert any("cancel_requested" in sql for sql, _ in conn.executed) + + +def test_set_pause_flag(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value={"id": "job-1"})) + set_pause_flag(conn, "job-1") + assert conn.commits == 1 + assert any("pause_requested" in sql for sql, _ in conn.executed) + + +# ── reconcile_stale_jobs ────────────────────────────────────────────────────── + +def test_reconcile_stale_jobs(): + conn = FakeConn() + count = reconcile_stale_jobs(conn) + assert isinstance(count, int) + + +def test_reconcile_stale_jobs_commits_when_updated(): + conn = FakeConn() + # First SELECT returns stale pending jobs + conn.set_next_cursor(FakeCursor(fetchall_value=[{"id": "j1"}])) + # Second SELECT returns stale running jobs + conn.set_next_cursor(FakeCursor(fetchall_value=[{"id": "j2"}])) + count = reconcile_stale_jobs(conn) + assert count == 2 + assert conn.commits >= 1 + + +# ── get_job ─────────────────────────────────────────────────────────────────── + +def test_get_job_returns_none_when_not_found(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = get_job(conn, "no-such-job") + assert result is None + + +def test_get_job_returns_dict(): + conn = FakeConn() + conn.set_next_cursor( + FakeCursor( + fetchone_value={ + "id": "job-1", + "job_type": "crawl", + "status": "completed", + "command": None, + "property_id": None, + "config_hash": None, + "started_at": None, + "finished_at": None, + "exit_code": 0, + "error_text": None, + "log_text": "", + "log_truncated": False, + "cancel_requested": False, + "pause_requested": False, + "worker_pid": None, + } + ) + ) + result = get_job(conn, "job-1") + assert result is not None + assert result["id"] == "job-1" + + +# ── list_jobs ───────────────────────────────────────────────────────────────── + +def test_list_jobs_returns_empty(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchall_value=[])) + result = list_jobs(conn, limit=5) + assert result == [] + + +# ── get_active_job ──────────────────────────────────────────────────────────── + +def test_get_active_job_returns_none_when_no_active(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = get_active_job(conn) + assert result is None + + +# ── cancel_job_in_db ────────────────────────────────────────────────────────── + +def test_cancel_job_in_db_not_found(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = cancel_job_in_db(conn, "no-such-job") + assert result is False + + +def test_cancel_job_in_db_already_finished(): + conn = FakeConn() + # The UPDATE returns no row because the job is already finished (status not in pending/running) + conn.set_next_cursor(FakeCursor(fetchone_value=None)) + result = cancel_job_in_db(conn, "job-1") + assert result is False + + +def test_cancel_job_in_db_running(): + conn = FakeConn() + conn.set_next_cursor(FakeCursor(fetchone_value={"status": "running", "worker_pid": 99})) + result = cancel_job_in_db(conn, "job-1") + assert result is True diff --git a/web/app/api/ai/fix-suggestion/route.ts b/web/app/api/ai/fix-suggestion/route.ts index 78ed2a8f..4d5714f3 100644 --- a/web/app/api/ai/fix-suggestion/route.ts +++ b/web/app/api/ai/fix-suggestion/route.ts @@ -1,84 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const PYTHON_SCRIPT = ` -import json, sys -from website_profiling.llm.fix_suggestions import generate_fix_suggestion -payload = json.load(sys.stdin) -print(json.dumps(generate_fix_suggestion(payload, refresh=bool(payload.get("refresh"))))) -`; - -/** - * POST /api/ai/fix-suggestion — on-demand LLM fix for any audit surface. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - let body: { - source?: string; - message?: string; - url?: string; - refresh?: boolean; - context?: Record; - priority?: string; - category?: string; - recommendation?: string; - type?: string; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - const message = String(body.message || '').trim(); - if (!message) { - return NextResponse.json({ error: 'message required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const payload = { - source: body.source || 'issue', - message, - url: body.url, - refresh: body.refresh, - context: body.context, - priority: body.priority, - category: body.category, - recommendation: body.recommendation, - type: body.type, - }; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', PYTHON_SCRIPT], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write(JSON.stringify(payload)); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Fix suggestion failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json(parsed)); - return; - } - resolve(NextResponse.json({ error: 'Fix suggestion failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Fix suggestion timed out after 90s' }, { status: 504 })); - }, 90_000); - }); + const denied = forbiddenIfNotLocal(request); if (denied) return denied; return proxyToFastAPI(request, '/api/ai/fix-suggestion'); }; diff --git a/web/app/api/alerts/check/route.ts b/web/app/api/alerts/check/route.ts index 8a7428e3..1d765baa 100644 --- a/web/app/api/alerts/check/route.ts +++ b/web/app/api/alerts/check/route.ts @@ -1,71 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { spawn } from 'child_process'; -import path from 'path'; -import { resolvePythonExecutable, formatPythonSpawnError } from '@/server/resolvePython'; -import { getRepoRoot } from '@/server/pipelineSpawnEnv'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/alerts/check?propertyId= — run health alert rules and optional webhook dispatch. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.tools.alert_checker import check_all_alerts, dispatch_webhook, dispatch_email -from website_profiling.db.storage import db_session -from website_profiling.db._common import _row_field - -property_id = int(sys.argv[1]) -alerts = check_all_alerts(property_id) -webhook_sent = False -email_sent = False -with db_session() as conn: - cur = conn.execute( - "SELECT alert_webhook_url, alert_email FROM properties WHERE id = %s", - (property_id,), - ) - row = cur.fetchone() - url = (_row_field(row, "alert_webhook_url", index=0) or "") if row else "" - email = (_row_field(row, "alert_email", index=1) or "") if row else "" - payload = {"property_id": property_id, "alerts": alerts} - if url and alerts: - webhook_sent = dispatch_webhook(url, payload) - if email and alerts: - email_sent = dispatch_email(email, payload) -print(json.dumps({"alerts": alerts, "webhook_sent": webhook_sent, "email_sent": email_sent})) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script, String(propertyId)], { - cwd: repoRoot, - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.on('error', (err: Error) => { - resolve(NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 })); - }); - proc.on('close', (code) => { - try { - const parsed = JSON.parse(stdout.trim() || '{}'); - resolve(NextResponse.json(parsed, { status: code === 0 ? 200 : 500 })); - } catch { - resolve(NextResponse.json({ error: stdout.trim() || 'Alert check failed' }, { status: 500 })); - } - }); - }); + return proxyToFastAPI(request, '/api/alerts/check'); }; diff --git a/web/app/api/app-settings/route.ts b/web/app/api/app-settings/route.ts index 1b28bd8d..ec9a44a1 100644 --- a/web/app/api/app-settings/route.ts +++ b/web/app/api/app-settings/route.ts @@ -1,61 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { loadAppSetting, saveAppSetting } from '@/server/appSettings'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** GET /api/app-settings?key= — Returns { key, value } or { key, value: null }. */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const key = request.nextUrl.searchParams.get('key'); - if (!key || typeof key !== 'string' || key.trim() === '') { - return NextResponse.json({ error: 'Missing key query parameter' }, { status: 400 }); - } - - try { - const value = await loadAppSetting(key.trim()); - return NextResponse.json({ key: key.trim(), value }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/app-settings'); }; -/** PUT /api/app-settings — Body: { key: string; value: string } */ export const PUT: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - if ( - typeof body !== 'object' || - body === null || - typeof (body as Record).key !== 'string' || - typeof (body as Record).value !== 'string' - ) { - return NextResponse.json({ error: 'Body must be { key: string; value: string }' }, { status: 400 }); - } - - const { key, value } = body as { key: string; value: string }; - - if (key.trim() === '') { - return NextResponse.json({ error: 'key must not be empty' }, { status: 400 }); - } - - try { - await saveAppSetting(key.trim(), value); - return NextResponse.json({ ok: true }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/app-settings'); }; diff --git a/web/app/api/backlinks/competitor-import/route.ts b/web/app/api/backlinks/competitor-import/route.ts index 151289ab..7e223d22 100644 --- a/web/app/api/backlinks/competitor-import/route.ts +++ b/web/app/api/backlinks/competitor-import/route.ts @@ -1,73 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/backlinks/competitor-import - * Body: { competitor, csvText, ourDomains?: string[] } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: { competitor?: string; csvText?: string; ourDomains?: string[] }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - const competitor = String(body.competitor || '').trim(); - const csvText = String(body.csvText || ''); - if (!competitor || !csvText.trim()) { - return NextResponse.json({ error: 'competitor and csvText required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.integrations.google.competitor_links import ( - parse_referring_domains_from_csv, - build_competitor_domain_gap, -) -payload = json.load(sys.stdin) -refs = parse_referring_domains_from_csv(payload.get("csvText") or "") -our = set(payload.get("ourDomains") or []) -print(json.dumps(build_competitor_domain_gap(our, payload.get("competitor") or "", refs))) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write( - JSON.stringify({ - competitor, - csvText, - ourDomains: body.ourDomains || [], - }), - ); - proc.stdin?.end(); - proc.on('error', () => { - resolve(NextResponse.json({ error: 'Import failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json({ gap: parsed })); - return; - } - resolve(NextResponse.json({ error: 'Competitor backlink import failed' }, { status: 500 })); - }); - }); + return proxyToFastAPI(request, '/api/backlinks/competitor-import'); }; diff --git a/web/app/api/backlinks/third-party-import/route.ts b/web/app/api/backlinks/third-party-import/route.ts index fe1ef914..e93189a8 100644 --- a/web/app/api/backlinks/third-party-import/route.ts +++ b/web/app/api/backlinks/third-party-import/route.ts @@ -1,92 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/backlinks/third-party-import - * Body: { propertyId, provider: 'moz'|'majestic', csvText, ourDomains?: string[] } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: { - propertyId?: number; - provider?: string; - csvText?: string; - ourDomains?: string[]; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - const provider = String(body.provider || 'moz').trim().toLowerCase(); - const csvText = String(body.csvText || ''); - if (!propertyId || !csvText.trim()) { - return NextResponse.json({ error: 'propertyId and csvText required' }, { status: 400 }); - } - if (provider !== 'moz' && provider !== 'majestic') { - return NextResponse.json({ error: 'provider must be moz or majestic' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.integrations.links.third_party_csv import build_third_party_overlay -from website_profiling.integrations.google.gsc_links_store import import_third_party_links_overlay -from website_profiling.db.storage import db_session - -payload = json.load(sys.stdin) -property_id = int(payload["propertyId"]) -overlay = build_third_party_overlay( - payload.get("provider") or "moz", - payload.get("csvText") or "", - payload.get("ourDomains") or [], -) -with db_session() as conn: - result = import_third_party_links_overlay(conn, property_id, overlay) -print(json.dumps(result)) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stderr?.on('data', (c: Buffer | string) => { stderr += c.toString(); }); - proc.stdin?.write( - JSON.stringify({ - propertyId, - provider, - csvText, - ourDomains: body.ourDomains || [], - }), - ); - proc.stdin?.end(); - proc.on('error', () => { - resolve(NextResponse.json({ error: 'Import failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json(parsed)); - return; - } - resolve(NextResponse.json({ error: 'Third-party backlink import failed' }, { status: 500 })); - }); - }); + return proxyToFastAPI(request, '/api/backlinks/third-party-import'); }; diff --git a/web/app/api/backlinks/velocity/route.ts b/web/app/api/backlinks/velocity/route.ts index ad116ffd..b2a13048 100644 --- a/web/app/api/backlinks/velocity/route.ts +++ b/web/app/api/backlinks/velocity/route.ts @@ -1,41 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { withDb } from '@/server/db'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; -/** - * GET /api/backlinks/velocity?propertyId= - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - const snapshots = await withDb(async (client) => { - const cur = await client.query<{ - captured_at: Date; - referring_domains: number; - top_domains: unknown; - }>( - `SELECT captured_at, referring_domains, top_domains - FROM gsc_links_snapshots - WHERE property_id = $1 - ORDER BY captured_at ASC - LIMIT 52`, - [propertyId], - ); - return cur.rows.map((row) => ({ - capturedAt: row.captured_at.toISOString(), - referringDomains: row.referring_domains, - topDomains: row.top_domains, - })); - }); - return NextResponse.json({ snapshots }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg, snapshots: [] }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/backlinks/velocity'); }; diff --git a/web/app/api/chat/artifacts/[id]/route.ts b/web/app/api/chat/artifacts/[id]/route.ts index 336162ec..4933b267 100644 --- a/web/app/api/chat/artifacts/[id]/route.ts +++ b/web/app/api/chat/artifacts/[id]/route.ts @@ -1,34 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; +/** + * GET /api/chat/artifacts/[id] — retrieve an AI-generated artifact file via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuthForChat } from '@/server/auth'; -import { resolvePythonExecutable, formatPythonSpawnError } from '@/server/resolvePython'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const REPO_ROOT = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); - -const ARTIFACT_SCRIPT = ` -import json -import sys -from website_profiling.tools.export_artifacts import read_artifact_bytes -aid = sys.argv[1] -result = read_artifact_bytes(aid) -if not result: - print(json.dumps({"error": "not found"})) -else: - meta, data = result - import base64 - print(json.dumps({ - "filename": meta.get("filename"), - "mime_type": meta.get("mime_type"), - "data_base64": base64.b64encode(data).decode("ascii"), - })) -`; - export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, context: { params: Promise<{ id: string }> }, @@ -37,69 +18,6 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( if (denied) return denied; const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; - const { id } = await context.params; - if (!id || !/^[a-f0-9-]{36}$/.test(id)) { - return NextResponse.json({ error: 'Invalid artifact id' }, { status: 400 }); - } - - const python = resolvePythonExecutable(process.env.PYTHON, REPO_ROOT); - - return new Promise((resolve) => { - const proc = spawn(python, ['-c', ARTIFACT_SCRIPT, id], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHONPATH: path.join(REPO_ROOT, 'src'), - PYTHONIOENCODING: 'utf-8', - }, - }); - let out = ''; - let err = ''; - proc.stdout.on('data', (c: Buffer | string) => { - out += c.toString(); - }); - proc.stderr.on('data', (c) => { - err += c.toString(); - }); - proc.on('error', (spawnErr: Error) => { - resolve(NextResponse.json({ error: formatPythonSpawnError(spawnErr, python, REPO_ROOT) }, { status: 500 })); - }); - proc.on('close', (code) => { - if (code !== 0) { - resolve(NextResponse.json({ error: err.trim() || 'Artifact read failed' }, { status: 500 })); - return; - } - try { - const parsed = JSON.parse(out.trim()) as { - error?: string; - filename?: string; - mime_type?: string; - data_base64?: string; - }; - if (parsed.error || !parsed.data_base64) { - resolve(NextResponse.json({ error: 'Artifact not found' }, { status: 404 })); - return; - } - const body = Buffer.from(parsed.data_base64, 'base64'); - const rawName = parsed.filename || 'export.bin'; - // Sanitize the ASCII fallback (strip non-printable/quote/slash chars so - // a CR/LF or quote can't break or inject the header) and provide an - // RFC 5987 filename* for the full UTF-8 name. - const asciiName = - rawName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\/]/g, '_') || 'export.bin'; - const mime = parsed.mime_type || 'application/octet-stream'; - resolve( - new NextResponse(body, { - headers: { - 'Content-Type': mime, - 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(rawName)}`, - }, - }), - ); - } catch { - resolve(NextResponse.json({ error: 'Invalid artifact response' }, { status: 500 })); - } - }); - }); + return proxyToFastAPI(request, `/api/chat/artifacts/${id}`); }; diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts index f48b8d48..ded08f56 100644 --- a/web/app/api/chat/route.ts +++ b/web/app/api/chat/route.ts @@ -1,384 +1,20 @@ +/** + * POST /api/chat — stream agent response via FastAPI SSE. + * FastAPI runs the Python agent directly and streams text/event-stream. + */ import { type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuthForChat } from '@/server/auth'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; -import { - appendChatMessage, - getChatMessages, - getChatSession, - messagesForAgentContext, - updateChatSessionTitle, -} from '@/server/chatDb'; -import { loadLlmConfig } from '@/server/llmConfig'; import type { ApiRouteHandler } from '@/types/api'; -import type { ChatNarrative } from '@/types/chatNarrative'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const DEFAULT_CHAT_TIMEOUT_MS = 120_000; -const OLLAMA_MIN_TIMEOUT_MS = 300_000; - -async function resolveChatTimeoutMs(): Promise { - try { - const cfg = await loadLlmConfig(); - const provider = String(cfg.state.llm_provider || 'none'); - const timeoutS = Number(cfg.state.llm_timeout_s) || 120; - const baseMs = Math.max(timeoutS, 30) * 1000; - if (provider === 'ollama') { - return Math.max(baseMs, OLLAMA_MIN_TIMEOUT_MS); - } - return baseMs; - } catch { - return DEFAULT_CHAT_TIMEOUT_MS; - } -} - -interface ChatBody { - sessionId?: number; - message?: string; - propertyId?: number; - reportId?: number; -} - -function sseLine(event: string, data: Record): string { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; -} - -function buildPersistedAssistantContent( - assistantText: string, - toolEvents: Array<{ name: string; args?: Record; result?: Record }>, - narrative: ChatNarrative | null, - sawError: boolean, - lastErrorMessage: string, -): string | null { - if (narrative) { - if (toolEvents.length > 0) { - return 'Tool results from this turn are shown below.'; - } - return ''; - } - const text = assistantText.trim(); - if (text) return text; - if (toolEvents.length > 0) { - return sawError - ? 'Tool results were saved from this turn. The assistant did not produce a final summary.' - : 'Tool results from this turn are shown below.'; - } - if (sawError && lastErrorMessage.trim()) { - return lastErrorMessage.trim(); - } - return null; -} - -/** POST /api/chat — stream agent response via SSE. */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; - - let body: ChatBody; - try { - body = await request.json(); - } catch { - return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400 }); - } - - const sessionId = Number(body.sessionId || 0); - const propertyId = Number(body.propertyId || 0); - const message = String(body.message || '').trim(); - const reportId = body.reportId != null ? Number(body.reportId) : undefined; - - if (!sessionId || !propertyId || !message) { - return new Response( - JSON.stringify({ error: 'sessionId, propertyId, and message are required' }), - { status: 400 }, - ); - } - - const session = await getChatSession(sessionId); - if (!session || session.property_id !== propertyId) { - return new Response(JSON.stringify({ error: 'session not found' }), { status: 404 }); - } - - await appendChatMessage(sessionId, 'user', message); - - const history = await getChatMessages(sessionId); - const agentMessages = messagesForAgentContext(history, 20); - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const stdinPayload = JSON.stringify({ - messages: agentMessages, - property_id: propertyId, - report_id: Number.isFinite(reportId) ? reportId : undefined, - }); - - const chatTimeoutMs = await resolveChatTimeoutMs(); - const timeoutSec = Math.round(chatTimeoutMs / 1000); - - // Track the spawned child so we can kill it if the client disconnects - // (ReadableStream.cancel) instead of leaking it until the timeout fires. - let activeProc: ReturnType | null = null; - let activeKillTimer: ReturnType | null = null; - let cancelled = false; - - const cancelChild = () => { - cancelled = true; - const p = activeProc; - if (!p) return; - try { - p.kill('SIGTERM'); - activeKillTimer = setTimeout(() => { - try { - p.kill('SIGKILL'); - } catch { - /* already exited */ - } - }, 2000); - (activeKillTimer as { unref?: () => void }).unref?.(); - } catch { - /* already exited */ - } - }; - - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - let assistantText = ''; - let buffer = ''; - let stderrAcc = ''; - let lastErrorMessage = ''; - let narrative: ChatNarrative | null = null; - const toolEvents: Array<{ - name: string; - args?: Record; - result?: Record; - }> = []; - let sawError = false; - let timedOut = false; - let closed = false; - let exitCode: number | null = null; - - const closeStream = () => { - if (closed) return; - closed = true; - try { - controller.close(); - } catch { - /* stream may already be closed (client disconnect, timeout race) */ - } - }; - - const push = (event: string, data: Record) => { - if (closed) return; - if (event === 'error') { - sawError = true; - lastErrorMessage = String(data.message || 'Agent error'); - } - try { - controller.enqueue(encoder.encode(sseLine(event, data))); - } catch { - closed = true; - } - }; - - const proc = spawn( - pythonExe, - ['-m', 'src', 'chat', '--stdin-json'], - { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot, propertyId), - shell: false, - }, - ); - activeProc = proc; - - const timer = setTimeout(() => { - timedOut = true; - try { - proc.kill(); - } catch { - /* ignore */ - } - push('error', { message: `Chat timed out after ${timeoutSec}s` }); - closeStream(); - }, chatTimeoutMs); - - // Without an error listener, an EPIPE/ERR_STREAM_DESTROYED on the stdin - // pipe (child exits before reading) would surface as an unhandled stream - // error and crash the Node process instead of a clean chat error. - proc.stdin?.on('error', (err: Error) => { - clearTimeout(timer); - push('error', { message: `Failed to send request to assistant: ${err.message}` }); - closeStream(); - }); - proc.stdin?.write(stdinPayload); - proc.stdin?.end(); - - proc.stdout?.on('data', (chunk: Buffer) => { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const evt = JSON.parse(trimmed) as { - type?: string; - text?: string; - message?: string; - phase?: string; - detail?: string; - name?: string; - args?: Record; - result?: Record; - narrative?: ChatNarrative; - }; - if (evt.type === 'token' && evt.text) { - assistantText += evt.text; - push('token', { text: evt.text }); - } else if (evt.type === 'status') { - push('status', { - phase: evt.phase || 'working', - detail: evt.detail || evt.message || '', - }); - } else if (evt.type === 'tool_start') { - toolEvents.push({ - name: String(evt.name || ''), - args: evt.args || {}, - }); - push('tool_start', evt as Record); - } else if (evt.type === 'tool_end') { - const name = String(evt.name || ''); - const existing = toolEvents.findIndex((t) => t.name === name && t.result == null); - if (existing >= 0) { - toolEvents[existing] = { - ...toolEvents[existing], - result: evt.result || {}, - }; - } else { - toolEvents.push({ name, result: evt.result || {} }); - } - push('tool_end', evt as Record); - } else if (evt.type === 'narrative' && evt.narrative) { - narrative = evt.narrative; - push('narrative', { narrative: evt.narrative }); - } else if (evt.type === 'done') { - if (evt.message) { - assistantText = evt.message; - } - push('done', { message: evt.message || '' }); - } else if (evt.type === 'partial_done' && evt.message) { - assistantText = evt.message; - push('partial_done', { message: evt.message }); - } else if (evt.type === 'error') { - push('error', { message: evt.message || 'Agent error' }); - } - } catch { - /* ignore non-JSON log lines */ - } - } - }); - - proc.stderr?.on('data', (chunk: Buffer) => { - stderrAcc += chunk.toString(); - if (stderrAcc.length > 8000) { - stderrAcc = stderrAcc.slice(-8000); - } - }); - - proc.on('error', (err: Error) => { - clearTimeout(timer); - push('error', { message: formatPythonSpawnError(err, pythonExe, repoRoot) }); - closeStream(); - }); - - proc.on('close', async (code: number | null) => { - clearTimeout(timer); - if (activeKillTimer) { - clearTimeout(activeKillTimer); - activeKillTimer = null; - } - // On client cancel we drop the partial turn (the user navigated away); - // on timeout the error was already streamed. - if (timedOut || cancelled) return; - exitCode = code; - - if (!sawError && !assistantText.trim() && !narrative) { - const stderrLine = stderrAcc - .split('\n') - .map((l) => l.trim()) - .find((l) => l && !l.startsWith('[')); - const fallback = - stderrLine || - (exitCode != null && exitCode !== 0 - ? `Assistant process exited with code ${exitCode}.` - : 'No response from the assistant.'); - push('error', { message: fallback }); - } else if (!sawError && exitCode != null && exitCode !== 0) { - const stderrLine = stderrAcc - .split('\n') - .map((l) => l.trim()) - .find((l) => l && !l.startsWith('[')); - if (stderrLine) { - push('error', { message: stderrLine }); - } - } - - const contentToSave = buildPersistedAssistantContent( - assistantText, - toolEvents, - narrative, - sawError, - lastErrorMessage, - ); - - if (contentToSave !== null || narrative || toolEvents.length > 0) { - try { - const toolResultPayload = - toolEvents.length || narrative || (sawError && lastErrorMessage) - ? { - ...(toolEvents.length ? { tool_events: toolEvents } : {}), - ...(narrative ? { narrative } : {}), - ...(sawError && lastErrorMessage ? { agent_error: lastErrorMessage } : {}), - } - : null; - await appendChatMessage( - sessionId, - 'assistant', - contentToSave ?? '', - { - toolResult: toolResultPayload, - }, - ); - if (session.title === 'New chat') { - const title = message.slice(0, 60) + (message.length > 60 ? '…' : ''); - await updateChatSessionTitle(sessionId, title); - } - } catch { - /* persistence failure should not break stream */ - } - } - - closeStream(); - }); - }, - cancel() { - // Client disconnected mid-stream (reload/navigate/abort): terminate the - // agent process so it does not keep holding the LLM connection/CPU. - cancelChild(); - }, - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }); + return proxyToFastAPI(request, '/api/chat/'); }; diff --git a/web/app/api/chat/sessions/[id]/messages/route.ts b/web/app/api/chat/sessions/[id]/messages/route.ts index 59a5725c..c2b2ebb8 100644 --- a/web/app/api/chat/sessions/[id]/messages/route.ts +++ b/web/app/api/chat/sessions/[id]/messages/route.ts @@ -1,13 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * GET /api/chat/sessions/[id]/messages — get chat session messages via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuthForChat } from '@/server/auth'; -import { getChatMessages, getChatSession } from '@/server/chatDb'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** GET /api/chat/sessions/[id]/messages?propertyId= */ export const GET: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, @@ -16,28 +18,6 @@ export const GET: ApiRouteHandler = async ( if (denied) return denied; const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; - const params = context?.params ? await context.params : { id: '' }; - const sessionId = Number(params.id || '0'); - if (!sessionId) { - return NextResponse.json({ error: 'invalid session id' }, { status: 400 }); - } - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - // Scope conversation history to the caller's property to avoid leaking - // another property's messages by enumerating session ids. - const session = await getChatSession(sessionId); - if (!session || session.property_id !== propertyId) { - return NextResponse.json({ error: 'session not found' }, { status: 404 }); - } - const messages = await getChatMessages(sessionId); - return NextResponse.json({ messages }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/chat/sessions/${params.id}/messages`); }; diff --git a/web/app/api/chat/sessions/[id]/route.ts b/web/app/api/chat/sessions/[id]/route.ts index 0ed68a00..330116f3 100644 --- a/web/app/api/chat/sessions/[id]/route.ts +++ b/web/app/api/chat/sessions/[id]/route.ts @@ -1,13 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * GET/DELETE /api/chat/sessions/[id] — get or delete a chat session via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth, requireApiAuthForChat } from '@/server/auth'; -import { deleteChatSession, getChatSession } from '@/server/chatDb'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** GET /api/chat/sessions/[id] */ export const GET: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, @@ -16,59 +18,18 @@ export const GET: ApiRouteHandler = async ( if (denied) return denied; const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; - const params = context?.params ? await context.params : { id: '' }; - const sessionId = Number(params.id || '0'); - if (!sessionId) { - return NextResponse.json({ error: 'invalid session id' }, { status: 400 }); - } - - try { - const session = await getChatSession(sessionId); - if (!session) { - return NextResponse.json({ error: 'session not found' }, { status: 404 }); - } - return NextResponse.json({ session }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/chat/sessions/${params.id}`); }; -/** DELETE /api/chat/sessions/[id]?propertyId= */ export const DELETE: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - // Deleting a session is a destructive mutation: require a non-read-only role. const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - const params = context?.params ? await context.params : { id: '' }; - const sessionId = Number(params.id || '0'); - if (!sessionId) { - return NextResponse.json({ error: 'invalid session id' }, { status: 400 }); - } - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - // Scope the delete to the caller's property (consistent with POST /api/chat). - const session = await getChatSession(sessionId); - if (!session || session.property_id !== propertyId) { - return NextResponse.json({ error: 'session not found' }, { status: 404 }); - } - const deleted = await deleteChatSession(sessionId); - if (!deleted) { - return NextResponse.json({ error: 'session not found' }, { status: 404 }); - } - return NextResponse.json({ ok: true }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/chat/sessions/${params.id}`); }; diff --git a/web/app/api/chat/sessions/route.ts b/web/app/api/chat/sessions/route.ts index 0a3cace5..badf4e24 100644 --- a/web/app/api/chat/sessions/route.ts +++ b/web/app/api/chat/sessions/route.ts @@ -1,59 +1,27 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * GET/POST /api/chat/sessions — list or create chat sessions via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuthForChat } from '@/server/auth'; -import { createChatSession, listChatSessions } from '@/server/chatDb'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** GET /api/chat/sessions?propertyId= — list chat sessions for a property. */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; - - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - const sessions = await listChatSessions(propertyId); - return NextResponse.json({ sessions }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/chat/sessions'); }; -/** POST /api/chat/sessions — create session { propertyId, title? }. */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - // Chat (incl. starting a session) is intentionally available to the - // read-only client role; only destructive deletes are restricted (see DELETE). const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; - - let body: { propertyId?: number; title?: string }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - const id = await createChatSession(propertyId, body.title); - return NextResponse.json({ id, propertyId, title: body.title?.trim() || 'New chat' }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/chat/sessions'); }; diff --git a/web/app/api/compare/export/route.ts b/web/app/api/compare/export/route.ts index 5d609b5c..35da28ee 100644 --- a/web/app/api/compare/export/route.ts +++ b/web/app/api/compare/export/route.ts @@ -1,70 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { withDb } from '@/server/db'; -import { buildIssueDeltas } from '@/lib/reportCompareExtras'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; -import type { ReportCategory, ReportPayload } from '@/types/report'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -function csvEscape(value: string): string { - if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`; - return value; -} - -/** - * POST /api/compare/export — CSV diff between two report ids. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - let body: { reportIdA?: number; reportIdB?: number }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const reportIdA = Number(body.reportIdA || 0); - const reportIdB = Number(body.reportIdB || 0); - if (!reportIdA || !reportIdB) { - return NextResponse.json({ error: 'reportIdA and reportIdB required' }, { status: 400 }); - } - - try { - const [payloadA, payloadB] = await withDb(async (client) => { - const rows = await Promise.all( - [reportIdA, reportIdB].map(async (id) => { - const cur = await client.query<{ data: ReportPayload }>( - 'SELECT data FROM report_payload WHERE id = $1', - [id], - ); - return cur.rows[0]?.data ?? { categories: [] as ReportCategory[] }; - }), - ); - return rows; - }); - - const deltas = buildIssueDeltas(payloadA, payloadB); - const lines = ['change,category,priority,url,message,recommendation']; - - for (const row of deltas) { - const change = row.kind === 'new' ? 'added' : 'removed'; - lines.push( - [change, row.category, row.priority, row.url, row.message, ''] - .map((v) => csvEscape(String(v))) - .join(','), - ); - } - - const csv = `${lines.join('\n')}\n`; - return new NextResponse(csv, { - status: 200, - headers: { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="audit-compare-${reportIdA}-vs-${reportIdB}.csv"`, - }, - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + const denied = forbiddenIfNotLocal(request); if (denied) return denied; return proxyToFastAPI(request, '/api/compare/export'); }; diff --git a/web/app/api/content-drafts/[id]/route.ts b/web/app/api/content-drafts/[id]/route.ts index 8e20da21..d5ae755f 100644 --- a/web/app/api/content-drafts/[id]/route.ts +++ b/web/app/api/content-drafts/[id]/route.ts @@ -1,41 +1,19 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { - deleteContentDraft, - getContentDraft, - updateContentDraft, - type UpdateContentDraftInput, -} from '@/server/contentDraftDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** GET /api/content-drafts/[id] */ export const GET: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, ): Promise => { const params = context?.params ? await context.params : { id: '' }; - const draftId = Number(params.id || '0'); - if (!draftId) { - return NextResponse.json({ error: 'invalid draft id' }, { status: 400 }); - } - - try { - const draft = await getContentDraft(draftId); - if (!draft) { - return NextResponse.json({ error: 'draft not found' }, { status: 404 }); - } - return NextResponse.json({ draft }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/content-drafts/${params.id}`); }; -/** PATCH /api/content-drafts/[id] */ export const PATCH: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, @@ -44,34 +22,10 @@ export const PATCH: ApiRouteHandler = async ( if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - const params = context?.params ? await context.params : { id: '' }; - const draftId = Number(params.id || '0'); - if (!draftId) { - return NextResponse.json({ error: 'invalid draft id' }, { status: 400 }); - } - - let body: UpdateContentDraftInput; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - try { - const existing = await getContentDraft(draftId); - if (!existing) { - return NextResponse.json({ error: 'draft not found' }, { status: 404 }); - } - const draft = await updateContentDraft(draftId, body); - return NextResponse.json({ draft }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/content-drafts/${params.id}`); }; -/** DELETE /api/content-drafts/[id] */ export const DELETE: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, @@ -80,21 +34,6 @@ export const DELETE: ApiRouteHandler = async ( if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - const params = context?.params ? await context.params : { id: '' }; - const draftId = Number(params.id || '0'); - if (!draftId) { - return NextResponse.json({ error: 'invalid draft id' }, { status: 400 }); - } - - try { - const ok = await deleteContentDraft(draftId); - if (!ok) { - return NextResponse.json({ error: 'draft not found' }, { status: 404 }); - } - return NextResponse.json({ ok: true }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/content-drafts/${params.id}`); }; diff --git a/web/app/api/content-drafts/route.ts b/web/app/api/content-drafts/route.ts index f16f10a5..e860a4a6 100644 --- a/web/app/api/content-drafts/route.ts +++ b/web/app/api/content-drafts/route.ts @@ -1,55 +1,19 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { - createContentDraft, - listContentDrafts, - type CreateContentDraftInput, -} from '@/server/contentDraftDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** GET /api/content-drafts?propertyId= — list drafts for a property. */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - try { - const drafts = await listContentDrafts(propertyId); - return NextResponse.json({ drafts }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/content-drafts'); }; -/** POST /api/content-drafts — create a new draft. */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: CreateContentDraftInput & { propertyId?: number }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - const id = await createContentDraft(propertyId, body); - return NextResponse.json({ id, propertyId }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/content-drafts'); }; diff --git a/web/app/api/content/analyze/route.ts b/web/app/api/content/analyze/route.ts index 4c6861eb..02ef08ed 100644 --- a/web/app/api/content/analyze/route.ts +++ b/web/app/api/content/analyze/route.ts @@ -1,104 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/content/analyze — SEO score + rule/AI suggestions (one-click analyzer). - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: { - propertyId?: number; - keyword?: string; - bodyHtml?: string; - titleTag?: string; - metaDescription?: string; - landingUrl?: string; - title?: string; - useAi?: boolean; - refresh?: boolean; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const keyword = String(body.keyword || '').trim(); - if (!keyword) { - return NextResponse.json({ error: 'keyword required' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0) || null; - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.content_studio.ai_suggest import analyze_content_draft -payload = json.load(sys.stdin) -pid = payload.get("propertyId") -print(json.dumps(analyze_content_draft( - int(pid) if pid else None, - payload.get("keyword", ""), - payload.get("bodyHtml", ""), - payload.get("titleTag", ""), - payload.get("metaDescription", ""), - payload.get("landingUrl"), - use_ai=bool(payload.get("useAi")), - refresh=bool(payload.get("refresh")), - title=payload.get("title", ""), -))) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write( - JSON.stringify({ - propertyId, - keyword, - bodyHtml: body.bodyHtml || '', - titleTag: body.titleTag || '', - metaDescription: body.metaDescription || '', - landingUrl: body.landingUrl || null, - title: body.title || '', - useAi: body.useAi === true, - refresh: body.refresh === true, - }), - ); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Analyze failed: could not start Python' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json({ analysis: parsed })); - return; - } - resolve(NextResponse.json({ error: 'Content analyze failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Analyze timed out after 90s' }, { status: 504 })); - }, 90_000); - }); + return proxyToFastAPI(request, '/api/content/analyze'); }; diff --git a/web/app/api/content/score/route.ts b/web/app/api/content/score/route.ts index 22ad4073..28a34e13 100644 --- a/web/app/api/content/score/route.ts +++ b/web/app/api/content/score/route.ts @@ -1,89 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/content/score - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - let body: { - propertyId?: number; - keyword?: string; - bodyHtml?: string; - titleTag?: string; - metaDescription?: string; - landingUrl?: string; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const keyword = String(body.keyword || '').trim(); - if (!keyword) { - return NextResponse.json({ error: 'keyword required' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0) || null; - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.content_studio.score import score_content_draft -payload = json.load(sys.stdin) -pid = payload.get("propertyId") -print(json.dumps(score_content_draft( - int(pid) if pid else None, - payload.get("keyword", ""), - payload.get("bodyHtml", ""), - payload.get("titleTag", ""), - payload.get("metaDescription", ""), - payload.get("landingUrl"), -))) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write( - JSON.stringify({ - propertyId, - keyword, - bodyHtml: body.bodyHtml || '', - titleTag: body.titleTag || '', - metaDescription: body.metaDescription || '', - landingUrl: body.landingUrl || null, - }), - ); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Content score failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json({ score: parsed })); - return; - } - resolve(NextResponse.json({ error: 'Content score failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Content score timed out after 30s' }, { status: 504 })); - }, 30_000); - }); + return proxyToFastAPI(request, '/api/content/score'); }; diff --git a/web/app/api/content/wizard/route.ts b/web/app/api/content/wizard/route.ts index 43b3030b..5fad8841 100644 --- a/web/app/api/content/wizard/route.ts +++ b/web/app/api/content/wizard/route.ts @@ -1,90 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const VALID_STEPS = new Set(['intents', 'content_types', 'tones', 'titles', 'outline', 'draft', 'research']); - -/** - * POST /api/content/wizard — one step of the guided-draft wizard. - * Body: { step, keyword, locale?, intent?, contentType?, tone?, title?, outline? } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: Record; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const step = String(body.step || '').trim(); - if (!VALID_STEPS.has(step)) { - return NextResponse.json({ error: 'Invalid wizard step' }, { status: 400 }); - } - - const payload = { - keyword: String(body.keyword || '').trim(), - locale: String(body.locale || 'en-US'), - intent: String(body.intent || ''), - contentType: String(body.contentType || ''), - tone: String(body.tone || ''), - title: String(body.title || ''), - outline: Array.isArray(body.outline) ? body.outline : [], - }; - - // The draft step writes a full article and can be slow on local models. - const timeoutMs = step === 'draft' ? 180_000 : 60_000; - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.content_studio.wizard import run_wizard_step -payload = json.load(sys.stdin) -print(json.dumps(run_wizard_step(payload.get("step", ""), payload.get("payload") or {}))) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write(JSON.stringify({ step, payload })); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Wizard failed: could not start Python' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - if (parsed.ok === false) { - resolve(NextResponse.json({ error: parsed.error || 'Wizard step failed' }, { status: 400 })); - return; - } - resolve(NextResponse.json({ result: parsed })); - return; - } - resolve(NextResponse.json({ error: 'Wizard step failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: `Wizard step timed out after ${Math.round(timeoutMs / 1000)}s` }, { status: 504 })); - }, timeoutMs); - }); + return proxyToFastAPI(request, '/api/content/wizard'); }; diff --git a/web/app/api/crawl/browser-status/route.ts b/web/app/api/crawl/browser-status/route.ts index 87725239..75864cf3 100644 --- a/web/app/api/crawl/browser-status/route.ts +++ b/web/app/api/crawl/browser-status/route.ts @@ -1,98 +1,12 @@ -import { spawn } from 'child_process'; -import { NextResponse } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const CHECK_SCRIPT = - 'from website_profiling.crawl.fetchers import ensure_browser_deps; import json; print(json.dumps(ensure_browser_deps()))'; - -/** First-time Playwright/Chromium install can take a few minutes. */ -const CHECK_TIMEOUT_MS = 180_000; - -/** - * GET /api/crawl/browser-status - * Returns whether Playwright and Chromium are available for JS/auto crawls. - */ -export const GET: ApiRouteHandler = async (request): Promise => { +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - const proc = spawn(pythonExe, ['-c', CHECK_SCRIPT], { - cwd: repoRoot, - env: getPipelineSpawnEnv(), - shell: false, - }); - - const appendStdout = (chunk: Buffer | string): void => { - stdout += chunk.toString(); - }; - const appendStderr = (chunk: Buffer | string): void => { - stderr += chunk.toString(); - }; - proc.stdout?.on('data', appendStdout); - proc.stderr?.on('data', appendStderr); - - const finish = (payload: { ok: boolean; message?: string; error?: string }, status = 200) => { - resolve(NextResponse.json(payload, { status })); - }; - - proc.on('error', (err: Error) => { - finish({ - ok: false, - message: formatPythonSpawnError(err, pythonExe, repoRoot), - error: err.message, - }); - }); - - proc.on('close', (code: number | null) => { - if (code !== 0) { - finish({ - ok: false, - message: - stderr.trim() || - 'JavaScript crawl requires Playwright and Chromium. Install: pip install -r requirements.txt.', - error: stderr.trim() || `exit ${code}`, - }); - return; - } - try { - const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'; - const parsed = JSON.parse(line) as { ok?: boolean; message?: string }; - finish({ - ok: Boolean(parsed.ok), - message: parsed.message, - }); - } catch { - finish({ - ok: false, - message: 'Could not parse browser status from Python.', - error: stdout.slice(-500) || stderr.slice(-500), - }); - } - }); - - setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - finish({ - ok: false, - message: 'Browser status check timed out.', - error: 'timeout', - }); - }, CHECK_TIMEOUT_MS); - }); + return proxyToFastAPI(request, '/api/crawl/browser-status'); }; diff --git a/web/app/api/crawl/page-html/route.ts b/web/app/api/crawl/page-html/route.ts index 71f34003..095defde 100644 --- a/web/app/api/crawl/page-html/route.ts +++ b/web/app/api/crawl/page-html/route.ts @@ -1,75 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withReportDb } from '@/server/reportDb'; -import { deletePageHtmlForRun, listCrawlPageHtmlRuns } from '@/lib/loadReportDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -type DeleteBody = { - crawlRunId?: number | null; -}; - -/** - * GET /api/crawl/page-html?limit=30 - * Lists recent crawl runs with stored HTML stats. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const limitRaw = Number(request.nextUrl.searchParams.get('limit') || '30'); - const limit = Number.isFinite(limitRaw) ? Math.min(100, Math.max(1, limitRaw)) : 30; - - try { - const runs = await withReportDb((client) => listCrawlPageHtmlRuns(client, { limit })); - return NextResponse.json({ runs }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg, runs: [] }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/crawl/page-html'); }; -/** - * DELETE /api/crawl/page-html - * Body: { crawlRunId: number } - * Removes raw HTML for one crawl run; crawl results and reports are kept. - */ export const DELETE: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: DeleteBody = {}; - try { - body = (await request.json()) as DeleteBody; - } catch { - const crawlRunIdRaw = request.nextUrl.searchParams.get('crawlRunId'); - if (crawlRunIdRaw) body.crawlRunId = Number(crawlRunIdRaw); - } - - const crawlRunId = - body.crawlRunId != null && Number.isFinite(Number(body.crawlRunId)) - ? Number(body.crawlRunId) - : null; - - if (crawlRunId == null) { - return NextResponse.json({ error: 'crawlRunId is required' }, { status: 400 }); - } - - try { - const deletedPages = await withReportDb((client) => deletePageHtmlForRun(client, crawlRunId)); - if (deletedPages === 0) { - return NextResponse.json({ - ok: true, - crawlRunId, - deletedPages: 0, - message: 'No stored HTML found for this crawl run.', - }); - } - return NextResponse.json({ ok: true, crawlRunId, deletedPages }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/crawl/page-html'); }; diff --git a/web/app/api/dashboards/[id]/route.ts b/web/app/api/dashboards/[id]/route.ts index 1a390c7c..6e980bc0 100644 --- a/web/app/api/dashboards/[id]/route.ts +++ b/web/app/api/dashboards/[id]/route.ts @@ -1,108 +1,36 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getDashboard, updateDashboard, deleteDashboard } from '@/server/dashboardsDb'; import type { ApiRouteHandlerWithParams } from '@/types/api'; -import type { DashboardDoc } from '@/types/dashboard'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -type Params = { id: string }; - -/** - * GET /api/dashboards/[id]?propertyId= - * Returns a single dashboard. - */ -export const GET: ApiRouteHandlerWithParams = async ( +export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, - { params }, + { params }: { params: Promise<{ id: string }> }, ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const { id } = await params; - const dashboardId = Number(id); - const propertyId = Number(new URL(request.url).searchParams.get('propertyId') || 0); - - if (!dashboardId || !propertyId) { - return NextResponse.json({ error: 'id and propertyId required' }, { status: 400 }); - } - - try { - const dashboard = await getDashboard(dashboardId, propertyId); - if (!dashboard) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - return NextResponse.json({ dashboard }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/dashboards/${id}`); }; -/** - * PUT /api/dashboards/[id] - * Body: { propertyId, name?, layoutJson?, isDefault? } - * Partial update — only provided fields are changed. - */ -export const PUT: ApiRouteHandlerWithParams = async ( +export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, - { params }, + { params }: { params: Promise<{ id: string }> }, ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const { id } = await params; - const dashboardId = Number(id); - - let body: { propertyId?: number; name?: string; layoutJson?: DashboardDoc; isDefault?: boolean }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - if (!dashboardId || !propertyId) { - return NextResponse.json({ error: 'id and propertyId required' }, { status: 400 }); - } - - try { - const dashboard = await updateDashboard(dashboardId, propertyId, { - name: body.name, - layoutJson: body.layoutJson, - isDefault: body.isDefault, - }); - if (!dashboard) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - return NextResponse.json({ dashboard }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/dashboards/${id}`); }; -/** - * DELETE /api/dashboards/[id]?propertyId= - */ -export const DELETE: ApiRouteHandlerWithParams = async ( +export const DELETE: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, - { params }, + { params }: { params: Promise<{ id: string }> }, ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const { id } = await params; - const dashboardId = Number(id); - const propertyId = Number(new URL(request.url).searchParams.get('propertyId') || 0); - - if (!dashboardId || !propertyId) { - return NextResponse.json({ error: 'id and propertyId required' }, { status: 400 }); - } - - try { - const deleted = await deleteDashboard(dashboardId, propertyId); - if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - return NextResponse.json({ ok: true }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/dashboards/${id}`); }; diff --git a/web/app/api/dashboards/ai-generate/route.ts b/web/app/api/dashboards/ai-generate/route.ts new file mode 100644 index 00000000..7f0268ee --- /dev/null +++ b/web/app/api/dashboards/ai-generate/route.ts @@ -0,0 +1,118 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { DASHBOARD_CATALOG, dimensions, measures } from '@/lib/dashboard/catalog/catalog'; +import { VIZ_LABELS } from '@/lib/dashboard/viz/labels'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const DASHSCRIPT_HELP = ` +DashScript is a lightweight formula language for dashboard widgets. + +MEASURE (scalar formula, produces a single number or string): + field("key") — value from root result by dot-path key + sum("col") — sum of numeric column across all rows + avg("col") — average + count() — number of rows + min("col") / max("col") — min / max of column + if(cond, thenVal, elseVal) — conditional + coalesce(a, b, c) — first non-null value + Arithmetic: + - * / (division by zero returns null) + Comparison: == != < <= > >= + Logical: && || ! + +TRANSFORM (row pipeline, applied to rows array before rendering): + filter(expr) — keep rows where expr is truthy (use row column names directly) + sort(col, asc|desc) — sort rows by column (default asc) + take(N) — keep first N rows + skip(N) — drop first N rows + project(col1, col2) — keep only listed columns + Stages are joined with | e.g. filter(count > 0) | sort(count, desc) | take(10) + +Examples: + measure: field("health_score") + measure: sum("issues") / count() + transform: filter(severity == "critical") | sort(count, desc) | take(5) +`.trim(); + +function fastApiBase(): string { + return (process.env.FASTAPI_URL || 'http://127.0.0.1:8001').replace(/\/$/, ''); +} + +/** + * POST /api/dashboards/ai-generate + * Body: { mode, prompt, toolName?, propertyId?, reportId?, current? } + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + let body: { + mode?: string; + prompt?: string; + toolName?: string; + propertyId?: number; + reportId?: number | null; + current?: unknown; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const mode = String(body.mode || 'widget').trim().toLowerCase(); + if (!['script', 'widget', 'dashboard'].includes(mode)) { + return NextResponse.json({ error: 'mode must be script, widget, or dashboard' }, { status: 400 }); + } + const prompt = String(body.prompt || '').trim(); + if (!prompt) { + return NextResponse.json({ error: 'prompt required' }, { status: 400 }); + } + + const payload = { + mode, + prompt, + toolName: String(body.toolName || '').trim() || undefined, + propertyId: Number(body.propertyId || 0) || undefined, + reportId: body.reportId != null ? Number(body.reportId) : undefined, + catalog: DASHBOARD_CATALOG.map((e) => ({ + toolName: e.toolName, + label: e.label, + section: e.section, + fields: e.fields, + dimensions: dimensions(e).map((f) => ({ + key: f.key, + label: f.label, + defaultAgg: f.defaultAgg, + format: f.format, + })), + measures: measures(e).map((f) => ({ + key: f.key, + label: f.label, + defaultAgg: f.defaultAgg, + format: f.format, + })), + rowsPath: e.rowsPath, + compatibleViz: e.compatibleViz, + })), + viz_types: VIZ_LABELS, + dashscript_help: DASHSCRIPT_HELP, + current: body.current ?? null, + }; + + try { + const res = await fetch(`${fastApiBase()}/api/dashboards/ai-generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store', + }); + const data = (await res.json().catch(() => ({}))) as Record; + return NextResponse.json(data, { status: res.status }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg || 'AI generation failed' }, { status: 500 }); + } +}; diff --git a/web/app/api/dashboards/route.ts b/web/app/api/dashboards/route.ts index 2271a275..6cdc3f55 100644 --- a/web/app/api/dashboards/route.ts +++ b/web/app/api/dashboards/route.ts @@ -1,69 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { - listDashboards, - createDashboard, -} from '@/server/dashboardsDb'; -import { emptyDashboard } from '@/types/dashboard'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * GET /api/dashboards?propertyId= - * Returns all dashboards for a property ordered by updated_at DESC. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const propertyId = Number(new URL(request.url).searchParams.get('propertyId') || 0); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - try { - const dashboards = await listDashboards(propertyId); - return NextResponse.json({ dashboards }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/dashboards'); }; -/** - * POST /api/dashboards - * Body: { propertyId, name?, layoutJson? } - * Creates a new dashboard and returns it. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: { propertyId?: number; name?: string; layoutJson?: unknown }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - - const name = String(body.name || 'Untitled dashboard').trim() || 'Untitled dashboard'; - - try { - const dashboard = await createDashboard( - propertyId, - name, - (body.layoutJson as ReturnType) ?? emptyDashboard(), - ); - return NextResponse.json({ dashboard }, { status: 201 }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/dashboards'); }; diff --git a/web/app/api/filters/route.ts b/web/app/api/filters/route.ts index 60dc53c5..6ca3b5de 100644 --- a/web/app/api/filters/route.ts +++ b/web/app/api/filters/route.ts @@ -1,37 +1,24 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { deleteSavedFilter, listSavedFilters, upsertSavedFilter } from '@/server/savedFiltersDb'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET(request: NextRequest) { - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || 0); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - const filters = await listSavedFilters(propertyId); - return NextResponse.json({ filters }); -} +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/filters'); +}; -export async function POST(request: NextRequest) { - const body = await request.json().catch(() => ({})); - const propertyId = Number(body.propertyId || 0); - const name = String(body.name || '').trim(); - const filterJson = (body.filterJson && typeof body.filterJson === 'object') ? body.filterJson : {}; - if (!propertyId || !name) { - return NextResponse.json({ error: 'propertyId and name required' }, { status: 400 }); - } - await upsertSavedFilter(propertyId, name, filterJson); - return NextResponse.json({ ok: true }); -} +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/filters'); +}; -export async function DELETE(request: NextRequest) { - const body = await request.json().catch(() => ({})); - const propertyId = Number(body.propertyId || 0); - const name = String(body.name || '').trim(); - if (!propertyId || !name) { - return NextResponse.json({ error: 'propertyId and name required' }, { status: 400 }); - } - await deleteSavedFilter(propertyId, name); - return NextResponse.json({ ok: true }); -} +export const DELETE: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/filters'); +}; diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts index 28c0a304..cb9f5a41 100644 --- a/web/app/api/health/route.ts +++ b/web/app/api/health/route.ts @@ -1,18 +1,10 @@ -import { NextResponse } from 'next/server'; -import { withDb } from '@/server/db'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export const GET: ApiRouteHandler = async (): Promise => { - try { - await withDb(async (client) => { - await client.query('SELECT 1'); - }); - return NextResponse.json({ ok: true, database: 'up' }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ ok: false, database: 'down', error: msg }, { status: 503 }); - } +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + return proxyToFastAPI(request, '/api/health'); }; diff --git a/web/app/api/integrations/bing/sync/route.ts b/web/app/api/integrations/bing/sync/route.ts index 5c934209..710b9149 100644 --- a/web/app/api/integrations/bing/sync/route.ts +++ b/web/app/api/integrations/bing/sync/route.ts @@ -1,63 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout, formatPythonSpawnError } from '@/server/resolvePython'; -import { loadPipelineConfigUnmasked } from '@/server/pipelineConfig'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/integrations/bing/sync — fetch Bing Webmaster backlinks summary. - */ -export const POST: ApiRouteHandler = async (_request: NextRequest): Promise => { - let state: Record; - try { - // Must use the UNMASKED loader: the API key is passed to Python to authenticate - // with Bing; loadPipelineConfig() would return a masked '••••' placeholder. - const cfg = await loadPipelineConfigUnmasked(); - state = cfg.state; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } - const apiKey = String(state.bing_webmaster_api_key || '').trim(); - const siteUrl = String(state.start_url || '').trim(); - if (!apiKey || !siteUrl) { - return NextResponse.json( - { error: 'Set bing_webmaster_api_key and start_url in pipeline settings.' }, - { status: 400 }, - ); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.integrations.bing.webmaster import fetch_bing_backlinks_summary -api_key, site_url = sys.argv[1], sys.argv[2] -print(json.dumps(fetch_bing_backlinks_summary(api_key, site_url))) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script, apiKey, siteUrl], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.on('error', (err: Error) => { - resolve(NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 })); - }); - proc.on('close', (code) => { - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json(parsed)); - return; - } - resolve(NextResponse.json({ error: stdout.trim() || 'Bing sync failed' }, { status: 500 })); - }); - }); +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); if (denied) return denied; return proxyToFastAPI(request, '/api/integrations/bing/sync'); }; diff --git a/web/app/api/integrations/google/credentials/route.ts b/web/app/api/integrations/google/credentials/route.ts index 37e14c90..44454b50 100644 --- a/web/app/api/integrations/google/credentials/route.ts +++ b/web/app/api/integrations/google/credentials/route.ts @@ -1,57 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { - getGoogleAppPublicStatus, - saveGoogleAppSettings, -} from '@/server/googleAppSettings'; -import type { ApiRouteHandler, GoogleCredentialsPostBody } from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -const PROPERTY_ONLY_MSG = - 'Per-site settings (GSC, GA4, refresh token) must be saved via property Integrations when a Site URL is set.'; - -/** POST /api/integrations/google/credentials — save OAuth app Client ID/Secret to database. */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - try { - const body = (await request.json().catch(() => ({}))) as GoogleCredentialsPostBody; - - if ( - 'refreshToken' in body || - 'gscSiteUrl' in body || - 'ga4PropertyId' in body - ) { - return NextResponse.json({ error: PROPERTY_ONLY_MSG }, { status: 400 }); - } - - const patch: Parameters[0] = {}; - if (typeof body.clientId === 'string' && body.clientId.trim()) { - patch.clientId = body.clientId.trim(); - } - if (typeof body.clientSecret === 'string' && body.clientSecret.trim()) { - patch.clientSecret = body.clientSecret.trim(); - } - if (typeof body.dateRangeDays === 'number' && body.dateRangeDays > 0) { - patch.dateRangeDays = body.dateRangeDays; - } - if (typeof body.developerToken === 'string' && body.developerToken.trim()) { - patch.developerToken = body.developerToken.trim(); - } - if (typeof body.loginCustomerId === 'string' && body.loginCustomerId.trim()) { - patch.loginCustomerId = body.loginCustomerId.trim().replace(/-/g, ''); - } - - if (Object.keys(patch).length === 0) { - return NextResponse.json({ error: 'No valid fields provided' }, { status: 400 }); - } - - await saveGoogleAppSettings(patch); - const status = await getGoogleAppPublicStatus(); - return NextResponse.json({ ok: true, status }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/credentials'); }; diff --git a/web/app/api/integrations/google/credentials/upload/route.ts b/web/app/api/integrations/google/credentials/upload/route.ts index 30639b49..f88000ae 100644 --- a/web/app/api/integrations/google/credentials/upload/route.ts +++ b/web/app/api/integrations/google/credentials/upload/route.ts @@ -1,58 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { - getGoogleAppPublicStatus, - saveGoogleAppSettings, -} from '@/server/googleAppSettings'; -import type { ApiRouteHandler, GoogleCredentialsUploadBody, GoogleServiceAccount } from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; - -function isServiceAccount(value: unknown): value is GoogleServiceAccount { - return ( - value != null && - typeof value === 'object' && - (value as GoogleServiceAccount).type === 'service_account' && - typeof (value as GoogleServiceAccount).client_email === 'string' && - typeof (value as GoogleServiceAccount).private_key === 'string' - ); -} +export const dynamic = 'force-dynamic'; export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - try { - const body = (await request.json().catch(() => ({}))) as GoogleCredentialsUploadBody; - const raw = body.fileContent; - if (!raw || typeof raw !== 'string') { - return NextResponse.json({ error: 'fileContent is required' }, { status: 400 }); - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return NextResponse.json( - { error: "This doesn't look like a valid JSON file." }, - { status: 400 }, - ); - } - - if (!isServiceAccount(parsed)) { - return NextResponse.json( - { - error: - "This doesn't look like a Google service account key file. Make sure you downloaded the JSON key from Google Cloud Console > IAM & Admin > Service Accounts.", - }, - { status: 400 }, - ); - } - - await saveGoogleAppSettings({ serviceAccount: parsed }); - const status = await getGoogleAppPublicStatus(); - return NextResponse.json({ ok: true, status }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/credentials/upload'); }; diff --git a/web/app/api/integrations/google/disconnect/route.ts b/web/app/api/integrations/google/disconnect/route.ts index b661d8a3..6ba0472c 100644 --- a/web/app/api/integrations/google/disconnect/route.ts +++ b/web/app/api/integrations/google/disconnect/route.ts @@ -1,20 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getGoogleAppPublicStatus } from '@/server/googleAppSettings'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** Global disconnect is deprecated — use per-property disconnect. */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const status = await getGoogleAppPublicStatus(); - return NextResponse.json({ - ok: false, - error: - 'Disconnect Google per site: set Site URL, open Integrations, and use Disconnect on that property.', - status, - }); + return proxyToFastAPI(request, '/api/integrations/google/disconnect'); }; diff --git a/web/app/api/integrations/google/keywords/by-page/route.ts b/web/app/api/integrations/google/keywords/by-page/route.ts index dabc00c3..e03c6531 100644 --- a/web/app/api/integrations/google/keywords/by-page/route.ts +++ b/web/app/api/integrations/google/keywords/by-page/route.ts @@ -1,84 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { parseJsonField } from '@/server/pageGoogleData'; -import { resolvePropertyIdFromRequest } from '@/server/resolvePropertyId'; import type { ApiRouteHandler } from '@/types/api'; -import type { PoolClient } from 'pg'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -interface KeywordRow { - gsc_url?: string; - [key: string]: unknown; -} - -interface CannibalisationEntry { - pages?: Array<{ url?: string }>; - [key: string]: unknown; -} - -/** - * GET /api/integrations/google/keywords/by-page?url=...&propertyId=|domain= - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const guard = forbiddenIfNotLocal(request); if (guard) return guard; - - const { searchParams } = new URL(request.url); - const pageUrl = (searchParams.get('url') || '').trim(); - const { propertyId, error } = await resolvePropertyIdFromRequest( - searchParams.get('propertyId'), - searchParams.get('domain'), - ); - - if (!pageUrl) { - return NextResponse.json({ error: 'url parameter is required' }, { status: 400 }); - } - if (error || propertyId == null) { - return NextResponse.json({ error: error || 'propertyId or domain required' }, { status: 400 }); - } - - try { - return await withDb(async (client: PoolClient) => { - const { rows } = await client.query( - `SELECT data FROM keyword_data - WHERE property_id = $1 - ORDER BY id DESC LIMIT 1`, - [propertyId], - ); - if (!rows.length) { - return NextResponse.json({ keywords: [], cannibalisation: [] }); - } - - const data = parseJsonField(rows[0].data) || {}; - const allRows = Array.isArray(data.rows) ? (data.rows as KeywordRow[]) : []; - - const normalizedTarget = pageUrl.toLowerCase().replace(/\/$/, ''); - const pageKeywords = allRows.filter((r) => { - const u = (r.gsc_url || '').toLowerCase().replace(/\/$/, ''); - return u === normalizedTarget || u.includes(normalizedTarget) || normalizedTarget.includes(u); - }); - - const cannibRaw = Array.isArray(data.cannibalisation) ? data.cannibalisation : []; - const cannib = (cannibRaw as CannibalisationEntry[]).filter((c) => - (c.pages || []).some((p) => { - const u = (p.url || '').toLowerCase().replace(/\/$/, ''); - return u === normalizedTarget; - }), - ); - - return NextResponse.json({ - url: pageUrl, - propertyId, - keyword_count: pageKeywords.length, - keywords: pageKeywords, - cannibalisation: cannib, - fetched_at: data.fetched_at, - }); - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/keywords/by-page'); }; diff --git a/web/app/api/integrations/google/keywords/expand/route.ts b/web/app/api/integrations/google/keywords/expand/route.ts index c186bd98..b6aaf92a 100644 --- a/web/app/api/integrations/google/keywords/expand/route.ts +++ b/web/app/api/integrations/google/keywords/expand/route.ts @@ -1,112 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; -import { resolvePropertyIdFromRequest } from '@/server/resolvePropertyId'; -import type { ApiRouteHandler, KeywordExpandPostBody } from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; -const WEB_CWD = process.cwd(); -const DEFAULT_REPO_ROOT = - process.env.WEBSITE_PROFILING_ROOT || path.resolve(WEB_CWD, '..'); - -/** - * POST /api/integrations/google/keywords/expand - * Body: { seeds: string[], sources?: string[] } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - const guard = forbiddenIfNotLocal(request); - if (guard) return guard; - - let body: KeywordExpandPostBody & { propertyId?: number; domain?: string }; - try { - body = (await request.json()) as KeywordExpandPostBody & { - propertyId?: number; - domain?: string; - }; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { propertyId, error: propError } = await resolvePropertyIdFromRequest( - body.propertyId != null ? String(body.propertyId) : null, - body.domain ?? null, - ); - if (propError || propertyId == null) { - return NextResponse.json({ error: propError || 'propertyId or domain required' }, { status: 400 }); - } - - const seeds = Array.isArray(body?.seeds) - ? body.seeds.filter((s): s is string => typeof s === 'string' && Boolean(s.trim())).slice(0, 30) - : []; - - if (seeds.length === 0) { - return NextResponse.json({ error: 'No seeds provided' }, { status: 400 }); - } - - const sources = Array.isArray(body?.sources) - ? body.sources.filter((s): s is string => typeof s === 'string') - : ['web', 'youtube', 'questions']; - const repoRoot = DEFAULT_REPO_ROOT; - const pythonExe = resolvePythonExecutable(null, repoRoot); - - const pyScript = [ - 'import json, sys', - "sys.path.insert(0, '.')", - 'from src.website_profiling.integrations.google.suggest import batch_expand', - `seeds = ${JSON.stringify(seeds)}`, - `sources = tuple(${JSON.stringify(sources)})`, - 'result = batch_expand(seeds, sources=sources, max_workers=4)', - 'print(json.dumps(result, ensure_ascii=False))', - ].join('\n'); - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', pyScript], { - cwd: repoRoot, - env: { ...process.env, WP_PROPERTY_ID: String(propertyId) }, - shell: false, - }); - - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (d: Buffer | string) => { stdout += d.toString(); }); - proc.stderr?.on('data', (d: Buffer | string) => { stderr += d.toString(); }); - - proc.on('error', (err: Error) => { - resolve( - NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 }), - ); - }); - - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Suggest expansion timed out (45s)' }, { status: 504 })); - }, 45_000); - - proc.on('close', (code: number | null) => { - clearTimeout(timer); - if (code !== 0) { - resolve( - NextResponse.json( - { error: 'Python error', detail: stderr.slice(0, 500) }, - { status: 500 }, - ), - ); - return; - } - try { - const result: unknown = JSON.parse(stdout.trim()); - resolve(NextResponse.json({ results: result })); - } catch { - resolve( - NextResponse.json( - { error: 'Failed to parse Python output', detail: stdout.slice(0, 500) }, - { status: 500 }, - ), - ); - } - }); - }); + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/integrations/google/keywords/expand'); }; diff --git a/web/app/api/integrations/google/keywords/history/batch/route.ts b/web/app/api/integrations/google/keywords/history/batch/route.ts index 931126fa..2cd300bf 100644 --- a/web/app/api/integrations/google/keywords/history/batch/route.ts +++ b/web/app/api/integrations/google/keywords/history/batch/route.ts @@ -1,100 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { resolvePropertyIdFromRequest } from '@/server/resolvePropertyId'; -import type { ApiRouteHandler, KeywordHistoryBatchBody, KeywordHistoryRow } from '@/types/api'; -import type { PoolClient } from 'pg'; +import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; -const MAX_KEYWORDS = 100; -const MAX_LIMIT_PER_KEYWORD = 90; - -/** - * POST /api/integrations/google/keywords/history/batch - * Body: { keywords: string[], limit?: number, propertyId?: number, domain?: string } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - const guard = forbiddenIfNotLocal(request); - if (guard) return guard; - - let body: KeywordHistoryBatchBody & { propertyId?: number; domain?: string }; - try { - body = (await request.json()) as KeywordHistoryBatchBody & { - propertyId?: number; - domain?: string; - }; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { propertyId, error } = await resolvePropertyIdFromRequest( - body.propertyId != null ? String(body.propertyId) : null, - body.domain ?? null, - ); - if (error || propertyId == null) { - return NextResponse.json({ error: error || 'propertyId or domain required' }, { status: 400 }); - } - - const rawKeywords: unknown[] = Array.isArray(body?.keywords) ? body.keywords : []; - const keywords = Array.from( - new Set( - rawKeywords - .map((k) => String(k || '').trim()) - .filter((k): k is string => Boolean(k)), - ), - ).slice(0, MAX_KEYWORDS); - const limit = Math.min( - Math.max(parseInt(String(body.limit ?? '30'), 10) || 30, 1), - MAX_LIMIT_PER_KEYWORD, - ); - - if (!keywords.length) { - return NextResponse.json({ histories: {}, propertyId }); - } - - try { - return await withDb(async (client: PoolClient) => { - const histories: Record = Object.fromEntries( - keywords.map((k) => [k, []]), - ); - - try { - const res = await client.query( - `SELECT keyword, fetched_at, position, clicks, impressions, ctr - FROM keyword_history - WHERE property_id = $1 AND keyword = ANY($2::text[]) - ORDER BY keyword, id DESC`, - [propertyId, keywords], - ); - - const buckets: Record = Object.fromEntries( - keywords.map((k) => [k, []]), - ); - for (const row of res.rows) { - const kw = String(row.keyword ?? ''); - if (!buckets[kw]) continue; - if (buckets[kw].length >= limit) continue; - buckets[kw].push({ - fetched_at: row.fetched_at != null ? String(row.fetched_at) : null, - position: row.position != null ? Number(row.position) : null, - clicks: row.clicks != null ? Number(row.clicks) : null, - impressions: row.impressions != null ? Number(row.impressions) : null, - ctr: row.ctr != null ? Number(row.ctr) : null, - }); - } - - for (const kw of keywords) { - histories[kw] = (buckets[kw] || []).reverse(); - } - } catch { - /* keyword_history table may not exist yet */ - } - - return NextResponse.json({ histories, propertyId }); - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error: msg }, { status: 500 }); - } + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/integrations/google/keywords/history/batch'); }; diff --git a/web/app/api/integrations/google/keywords/history/route.ts b/web/app/api/integrations/google/keywords/history/route.ts index 2b0b770f..ad624047 100644 --- a/web/app/api/integrations/google/keywords/history/route.ts +++ b/web/app/api/integrations/google/keywords/history/route.ts @@ -1,56 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { resolvePropertyIdFromRequest } from '@/server/resolvePropertyId'; import type { ApiRouteHandler } from '@/types/api'; -import type { KeywordHistoryRow } from '@/types/api'; -import type { PoolClient } from 'pg'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** - * GET /api/integrations/google/keywords/history?keyword=...&propertyId=|domain= - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const guard = forbiddenIfNotLocal(request); if (guard) return guard; - - const { searchParams } = new URL(request.url); - const keyword = (searchParams.get('keyword') || '').trim(); - const limit = Math.min(parseInt(searchParams.get('limit') || '30', 10), 90); - const { propertyId, error } = await resolvePropertyIdFromRequest( - searchParams.get('propertyId'), - searchParams.get('domain'), - ); - - if (!keyword) { - return NextResponse.json({ error: 'keyword parameter is required' }, { status: 400 }); - } - if (error || propertyId == null) { - return NextResponse.json({ error: error || 'propertyId or domain required' }, { status: 400 }); - } - - try { - return await withDb(async (client: PoolClient) => { - let rows: KeywordHistoryRow[] = []; - try { - const res = await client.query( - `SELECT fetched_at, position, clicks, impressions, ctr - FROM keyword_history - WHERE property_id = $1 AND keyword = $2 - ORDER BY id DESC - LIMIT $3`, - [propertyId, keyword, limit], - ); - rows = res.rows.reverse() as KeywordHistoryRow[]; - } catch { - /* table may not exist yet */ - } - - return NextResponse.json({ keyword, propertyId, history: rows }); - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/keywords/history'); }; diff --git a/web/app/api/integrations/google/keywords/planner/route.ts b/web/app/api/integrations/google/keywords/planner/route.ts index b84ee454..3ce19392 100644 --- a/web/app/api/integrations/google/keywords/planner/route.ts +++ b/web/app/api/integrations/google/keywords/planner/route.ts @@ -1,151 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; -import { resolvePropertyIdFromRequest } from '@/server/resolvePropertyId'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; -const WEB_CWD = process.cwd(); -const DEFAULT_REPO_ROOT = - process.env.WEBSITE_PROFILING_ROOT || path.resolve(WEB_CWD, '..'); - -interface PlannerPostBody { - seeds: string[]; - propertyId?: number; - domain?: string; - langId?: number; - geoIds?: number[]; -} - -/** - * POST /api/integrations/google/keywords/planner - * Body: { seeds: string[], propertyId?, domain?, langId?, geoIds? } - * - * Calls Google Ads KeywordPlanIdeaService.GenerateKeywordIdeas and returns - * keyword ideas with official search volume and competition data. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - const guard = forbiddenIfNotLocal(request); - if (guard) return guard; - - let body: PlannerPostBody; - try { - body = (await request.json()) as PlannerPostBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { propertyId, error: propError } = await resolvePropertyIdFromRequest( - body.propertyId != null ? String(body.propertyId) : null, - body.domain ?? null, - ); - if (propError || propertyId == null) { - return NextResponse.json( - { error: propError || 'propertyId or domain required' }, - { status: 400 }, - ); - } - - const seeds = Array.isArray(body?.seeds) - ? body.seeds - .filter((s): s is string => typeof s === 'string' && Boolean(s.trim())) - .slice(0, 30) - : []; - - if (seeds.length === 0) { - return NextResponse.json({ error: 'No seeds provided' }, { status: 400 }); - } - - const langId = typeof body.langId === 'number' ? body.langId : 1000; - const geoIds = - Array.isArray(body.geoIds) && body.geoIds.every((g) => typeof g === 'number') - ? body.geoIds - : [2840]; - - const repoRoot = DEFAULT_REPO_ROOT; - const pythonExe = resolvePythonExecutable(null, repoRoot); - - const pyScript = [ - 'import json, sys', - "sys.path.insert(0, '.')", - 'from src.website_profiling.integrations.google.auth import build_ads_client', - 'from src.website_profiling.integrations.google.keyword_planner import generate_keyword_ideas', - 'from src.website_profiling.db.google_app_store import read_google_app_settings', - `property_id = ${propertyId}`, - `seeds = ${JSON.stringify(seeds)}`, - `lang_id = ${langId}`, - `geo_ids = ${JSON.stringify(geoIds)}`, - 'settings = read_google_app_settings()', - 'customer_id = (settings.get("login_customer_id") or "").replace("-", "")', - 'client = build_ads_client(property_id)', - 'ideas = generate_keyword_ideas(client, customer_id, seeds, lang_id=lang_id, geo_ids=geo_ids)', - 'print(json.dumps(ideas, ensure_ascii=False))', - ].join('\n'); - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', pyScript], { - cwd: repoRoot, - env: { ...process.env, WP_PROPERTY_ID: String(propertyId) }, - shell: false, - }); - - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (d: Buffer | string) => { - stdout += d.toString(); - }); - proc.stderr?.on('data', (d: Buffer | string) => { - stderr += d.toString(); - }); - - proc.on('error', (err: Error) => { - resolve( - NextResponse.json( - { error: formatPythonSpawnError(err, pythonExe, repoRoot) }, - { status: 500 }, - ), - ); - }); - - const timer = setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve( - NextResponse.json( - { error: 'Keyword Planner expansion timed out (60s)' }, - { status: 504 }, - ), - ); - }, 60_000); - - proc.on('close', (code: number | null) => { - clearTimeout(timer); - if (code !== 0) { - resolve( - NextResponse.json( - { error: 'Python error', detail: stderr.slice(0, 500) }, - { status: 500 }, - ), - ); - return; - } - try { - const ideas: unknown = JSON.parse(stdout.trim()); - resolve(NextResponse.json({ ideas, provenance: 'Google Keyword Planner' })); - } catch { - resolve( - NextResponse.json( - { error: 'Failed to parse Python output', detail: stdout.slice(0, 500) }, - { status: 500 }, - ), - ); - } - }); - }); + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/integrations/google/keywords/planner'); }; diff --git a/web/app/api/integrations/google/page-compare/route.ts b/web/app/api/integrations/google/page-compare/route.ts index 1efc4472..e7fff375 100644 --- a/web/app/api/integrations/google/page-compare/route.ts +++ b/web/app/api/integrations/google/page-compare/route.ts @@ -1,228 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { normalizeUrl } from '@/lib/urlNorm'; -import { buildPageMetricsCompare } from '@/lib/pageMetricsCompare'; -import { - loadGoogleDataRow, - parseJsonField, - publicGa4Page, - publicGscPage, - sliceFromGoogleRow, -} from '@/server/pageGoogleData'; import type { ApiRouteHandler } from '@/types/api'; -import type { PoolClient } from 'pg'; export const runtime = 'nodejs'; -type SnapType = 'snapshot' | 'live'; - -async function loadLiveSlice( - client: PoolClient, - id: number, -): Promise<{ id: number; fetchedAt: string | null; gsc: ReturnType; ga4: ReturnType } | null> { - const { rows } = await client.query( - 'SELECT id, fetched_at, data FROM page_google_snapshots WHERE id = $1', - [id], - ); - if (!rows.length) return null; - const data = parseJsonField(rows[0].data); - if (!data) return null; - const gsc = - data.gsc && typeof data.gsc === 'object' - ? publicGscPage(data.gsc as Record) - : null; - const ga4 = - data.ga4 && typeof data.ga4 === 'object' - ? publicGa4Page(data.ga4 as Record) - : null; - return { - id: Number(rows[0].id), - fetchedAt: rows[0].fetched_at ? String(rows[0].fetched_at) : null, - gsc, - ga4, - }; -} - -async function loadSnapshotSlice(client: PoolClient, id: number, pageUrl: string) { - const row = await loadGoogleDataRow(client, id); - if (!row) return null; - const slice = sliceFromGoogleRow(row.raw, pageUrl); - return { - id: row.id, - fetchedAt: row.fetchedAt, - gsc: slice.gsc, - ga4: slice.ga4, - }; -} - -async function defaultCurrent( - client: PoolClient, - pageUrl: string, - urlNorm: string, -): Promise<{ type: SnapType; id: number; fetchedAt: string | null; gsc: ReturnType; ga4: ReturnType } | null> { - const live = await client.query( - `SELECT id FROM page_google_snapshots WHERE url_norm = $1 ORDER BY fetched_at DESC, id DESC LIMIT 1`, - [urlNorm], - ); - if (live.rows.length) { - return { type: 'live', ...(await loadLiveSlice(client, Number(live.rows[0].id)))! }; - } - const snap = await loadGoogleDataRow(client, null); - if (!snap) return null; - const slice = sliceFromGoogleRow(snap.raw, pageUrl); - return { - type: 'snapshot', - id: snap.id, - fetchedAt: snap.fetchedAt, - gsc: slice.gsc, - ga4: slice.ga4, - }; -} - -async function defaultBaseline( - client: PoolClient, - pageUrl: string, - urlNorm: string, - currentType: SnapType, - currentId: number, -): Promise<{ type: SnapType; id: number; fetchedAt: string | null; gsc: ReturnType; ga4: ReturnType } | null> { - if (currentType === 'live') { - const { rows } = await client.query( - ` - SELECT id FROM page_google_snapshots - WHERE url_norm = $1 AND id < $2 - ORDER BY fetched_at DESC, id DESC - LIMIT 1 - `, - [urlNorm, currentId], - ); - if (rows.length) { - const s = await loadLiveSlice(client, Number(rows[0].id)); - if (s) return { type: 'live', ...s }; - } - } - - let maxGoogleId = currentId; - if (currentType === 'live') { - const latest = await loadGoogleDataRow(client, null); - maxGoogleId = latest?.id ?? 0; - } - const { rows } = await client.query( - ` - SELECT id FROM google_data - WHERE id < $1 - ORDER BY id DESC - LIMIT 5 - `, - [maxGoogleId], - ); - for (const row of rows) { - const s = await loadSnapshotSlice(client, Number(row.id), pageUrl); - if (s && (s.gsc || s.ga4)) return { type: 'snapshot', ...s }; - } - return null; -} - -/** - * GET /api/integrations/google/page-compare?url=...¤tType=¤tId=&baselineType=&baselineId= - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const url = request.nextUrl.searchParams.get('url'); - if (!url) { - return NextResponse.json({ error: 'url parameter required' }, { status: 400 }); - } - - const urlNorm = normalizeUrl(url); - const currentType = (request.nextUrl.searchParams.get('currentType') || '') as SnapType; - const baselineType = (request.nextUrl.searchParams.get('baselineType') || '') as SnapType; - const currentIdParam = request.nextUrl.searchParams.get('currentId'); - const baselineIdParam = request.nextUrl.searchParams.get('baselineId'); - - try { - return await withDb(async (client: PoolClient) => { - let current = await defaultCurrent(client, url, urlNorm); - if (currentIdParam && currentType) { - const id = parseInt(currentIdParam, 10); - if (!Number.isFinite(id) || id <= 0) { - return NextResponse.json({ error: 'Invalid currentId' }, { status: 400 }); - } - if (currentType === 'live') { - const s = await loadLiveSlice(client, id); - if (s) current = { type: 'live', ...s }; - } else { - const s = await loadSnapshotSlice(client, id, url); - if (s) current = { type: 'snapshot', ...s }; - } - } - - if (!current) { - return NextResponse.json({ error: 'No current metrics found for this URL' }, { status: 404 }); - } - - let baseline = - baselineIdParam && baselineType - ? null - : await defaultBaseline(client, url, urlNorm, current.type, current.id); - - if (baselineIdParam && baselineType) { - const id = parseInt(baselineIdParam, 10); - if (!Number.isFinite(id) || id <= 0) { - return NextResponse.json({ error: 'Invalid baselineId' }, { status: 400 }); - } - if (baselineType === 'live') { - const s = await loadLiveSlice(client, id); - if (s) baseline = { type: 'live', ...s }; - } else { - const s = await loadSnapshotSlice(client, id, url); - if (s) baseline = { type: 'snapshot', ...s }; - } - } - - const metrics = baseline - ? buildPageMetricsCompare( - { gsc: current.gsc, ga4: current.ga4 }, - { gsc: baseline.gsc, ga4: baseline.ga4 }, - { - gscClicks: 'Clicks', - gscImpressions: 'Impressions', - gscCtr: 'CTR %', - gscPosition: 'Avg position', - ga4Sessions: 'Sessions', - ga4Users: 'Users', - ga4Views: 'Page views', - ga4Engagement: 'Engagement rate', - ga4Duration: 'Avg session (s)', - }, - ) - : []; - - return NextResponse.json({ - url, - current: { - type: current.type, - id: current.id, - fetchedAt: current.fetchedAt, - gsc: current.gsc, - ga4: current.ga4, - }, - baseline: baseline - ? { - type: baseline.type, - id: baseline.id, - fetchedAt: baseline.fetchedAt, - gsc: baseline.gsc, - ga4: baseline.ga4, - } - : null, - metrics, - }); - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/page-compare'); }; diff --git a/web/app/api/integrations/google/page-data/history/route.ts b/web/app/api/integrations/google/page-data/history/route.ts index 9a1c0220..3c3eb648 100644 --- a/web/app/api/integrations/google/page-data/history/route.ts +++ b/web/app/api/integrations/google/page-data/history/route.ts @@ -1,72 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { - historySummary, - parseJsonField, - resolvePropertyIdForPageGoogle, - sliceFromGoogleRow, -} from '@/server/pageGoogleData'; import type { ApiRouteHandler } from '@/types/api'; -import type { PoolClient } from 'pg'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** - * GET /api/integrations/google/page-data/history?url=...&propertyId=... - * Lists property-scoped google_data rows that have metrics for this page. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const url = request.nextUrl.searchParams.get('url'); - if (!url) { - return NextResponse.json({ error: 'url parameter required' }, { status: 400 }); - } - - try { - return await withDb(async (client: PoolClient) => { - const propertyId = await resolvePropertyIdForPageGoogle( - client, - url, - request.nextUrl.searchParams.get('propertyId'), - request.nextUrl.searchParams.get('domain'), - ); - - if (propertyId == null) { - return NextResponse.json({ url, history: [] }); - } - - const { rows } = await client.query( - 'SELECT id, fetched_at, data FROM google_data WHERE property_id = $1 ORDER BY id DESC LIMIT 10', - [propertyId], - ); - const history: Array<{ - id: number; - fetchedAt: string | null; - type: 'snapshot'; - gsc: { clicks?: number; impressions?: number; position?: number } | null; - ga4: { sessions?: number; engagementRate?: number } | null; - }> = []; - - for (const row of rows) { - const raw = parseJsonField(row.data); - if (!raw) continue; - const slice = sliceFromGoogleRow(raw, url); - if (!slice.gsc && !slice.ga4) continue; - history.push({ - id: Number(row.id), - fetchedAt: row.fetched_at ? String(row.fetched_at) : null, - type: 'snapshot', - ...historySummary(slice.gsc, slice.ga4), - }); - } - - return NextResponse.json({ url, history }); - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/page-data/history'); }; diff --git a/web/app/api/integrations/google/page-data/route.ts b/web/app/api/integrations/google/page-data/route.ts index 3588941a..b14b4c0d 100644 --- a/web/app/api/integrations/google/page-data/route.ts +++ b/web/app/api/integrations/google/page-data/route.ts @@ -1,62 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { loadGoogleDataRow, resolvePropertyIdForPageGoogle, sliceFromGoogleRow } from '@/server/pageGoogleData'; import type { ApiRouteHandler } from '@/types/api'; -import type { PoolClient } from 'pg'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** - * GET /api/integrations/google/page-data?url=...&googleSnapshotId=... - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const url = request.nextUrl.searchParams.get('url'); - if (!url) { - return NextResponse.json({ error: 'url parameter required' }, { status: 400 }); - } - - const snapParam = request.nextUrl.searchParams.get('googleSnapshotId'); - const googleSnapshotId = snapParam ? parseInt(snapParam, 10) : null; - - try { - return await withDb(async (client: PoolClient) => { - const propertyId = await resolvePropertyIdForPageGoogle( - client, - url, - request.nextUrl.searchParams.get('propertyId'), - request.nextUrl.searchParams.get('domain'), - ); - const row = await loadGoogleDataRow( - client, - googleSnapshotId != null && Number.isFinite(googleSnapshotId) ? googleSnapshotId : null, - propertyId, - ); - if (!row) { - return NextResponse.json({ - source: 'snapshot', - snapshotId: null, - gsc: null, - ga4: null, - coverage: { inCrawl: false, inGsc: false, inGa4: false }, - siteBenchmarks: { gsc: null, ga4: null }, - dateRange: {}, - fetchedAt: null, - }); - } - - const slice = sliceFromGoogleRow(row.raw, url); - return NextResponse.json({ - ...slice, - snapshotId: row.id, - fetchedAt: row.fetchedAt ?? slice.fetchedAt, - }); - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/page-data'); }; diff --git a/web/app/api/integrations/google/page-live/history/route.ts b/web/app/api/integrations/google/page-live/history/route.ts index 48fd3476..33e7b765 100644 --- a/web/app/api/integrations/google/page-live/history/route.ts +++ b/web/app/api/integrations/google/page-live/history/route.ts @@ -1,56 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; -import { normalizeUrl } from '@/lib/urlNorm'; -import { historySummary, parseJsonField, publicGa4Page, publicGscPage } from '@/server/pageGoogleData'; import type { ApiRouteHandler } from '@/types/api'; -import type { PoolClient } from 'pg'; export const runtime = 'nodejs'; -/** - * GET /api/integrations/google/page-live/history?url=... - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const url = request.nextUrl.searchParams.get('url'); - if (!url) { - return NextResponse.json({ error: 'url parameter required' }, { status: 400 }); - } - - const urlNorm = normalizeUrl(url); - - try { - return await withDb(async (client: PoolClient) => { - const { rows } = await client.query( - ` - SELECT id, fetched_at, data - FROM page_google_snapshots - WHERE url_norm = $1 - ORDER BY fetched_at DESC, id DESC - LIMIT 15 - `, - [urlNorm], - ); - - const history = rows.map((row) => { - const data = parseJsonField(row.data); - const gsc = data?.gsc && typeof data.gsc === 'object' ? publicGscPage(data.gsc as Record) : null; - const ga4 = data?.ga4 && typeof data.ga4 === 'object' ? publicGa4Page(data.ga4 as Record) : null; - return { - id: Number(row.id), - fetchedAt: row.fetched_at ? String(row.fetched_at) : null, - type: 'live' as const, - ...historySummary(gsc, ga4), - }; - }); - - return NextResponse.json({ url, history }); - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/integrations/google/page-live/history'); }; diff --git a/web/app/api/integrations/google/page-live/route.ts b/web/app/api/integrations/google/page-live/route.ts index 94a24941..59d99a32 100644 --- a/web/app/api/integrations/google/page-live/route.ts +++ b/web/app/api/integrations/google/page-live/route.ts @@ -1,119 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -interface PageLiveResult { - ok?: boolean; - snapshotId?: number | null; - source?: string; - pageUrl?: string; - gsc?: unknown; - ga4?: unknown; - dateRange?: { start?: string; end?: string }; - fetchedAt?: string | null; - errors?: string[]; - error?: string; -} - -/** - * POST /api/integrations/google/page-live - * Body: { url: string } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: { url?: string }; - try { - body = (await request.json()) as { url?: string }; - } catch { - body = {}; - } - const url = (body.url || '').trim(); - if (!url) { - return NextResponse.json({ error: 'url is required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let log = ''; - let stdout = ''; - const proc = spawn(pythonExe, ['-m', 'src', 'page-live', '--url', url], { - cwd: repoRoot, - env: getPipelineSpawnEnv(), - shell: false, - }); - - const append = (chunk: Buffer | string): void => { - const s = chunk.toString(); - log += s; - stdout += s; - if (log.length > 32_000) log = log.slice(-28_000); - }; - proc.stdout?.on('data', append); - proc.stderr?.on('data', append); - - proc.on('error', (err: Error) => { - clearTimeout(timer); - resolve( - NextResponse.json( - { ok: false, error: formatPythonSpawnError(err, pythonExe, repoRoot), log }, - { status: 500 }, - ), - ); - }); - - proc.on('close', (code: number | null) => { - clearTimeout(timer); - try { - const lines = stdout.trim().split('\n').filter(Boolean); - const last = lines[lines.length - 1] || '{}'; - const data = JSON.parse(last) as PageLiveResult; - if (code !== 0 && !data.ok && !data.gsc && !data.ga4) { - resolve( - NextResponse.json( - { ok: false, error: data.error || 'Live fetch failed', log, ...data }, - { status: 500 }, - ), - ); - return; - } - resolve( - NextResponse.json({ - ok: true, - fetchedAt: new Date().toISOString(), - ...data, - }), - ); - } catch { - resolve( - NextResponse.json( - { ok: false, error: 'Invalid response from page-live', log }, - { status: 500 }, - ), - ); - } - }); - - const timer = setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve( - NextResponse.json({ ok: false, error: 'Live fetch timed out after 45s', log }, { status: 504 }), - ); - }, 45_000); - }); + return proxyToFastAPI(request, '/api/integrations/google/page-live'); }; diff --git a/web/app/api/integrations/google/properties/route.ts b/web/app/api/integrations/google/properties/route.ts index 637dc986..e413d73b 100644 --- a/web/app/api/integrations/google/properties/route.ts +++ b/web/app/api/integrations/google/properties/route.ts @@ -1,30 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** Deprecated: use GET /api/properties/{id}/google/properties */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const propertyId = request.nextUrl.searchParams.get('propertyId'); - if (!propertyId) { - return NextResponse.json( - { - error: - 'propertyId query parameter is required. Use /api/properties/{id}/google/properties instead.', - }, - { status: 400 }, - ); - } - - const url = new URL( - `/api/properties/${propertyId}/google/properties`, - request.nextUrl.origin, - ); - const res = await fetch(url.toString(), { headers: request.headers }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); + return proxyToFastAPI(request, '/api/integrations/google/properties'); }; diff --git a/web/app/api/integrations/google/status/route.ts b/web/app/api/integrations/google/status/route.ts index 9b6de85b..84e7085f 100644 --- a/web/app/api/integrations/google/status/route.ts +++ b/web/app/api/integrations/google/status/route.ts @@ -1,31 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getGoogleAppPublicStatus } from '@/server/googleAppSettings'; -import { withDb } from '@/server/db'; import type { ApiRouteHandler } from '@/types/api'; -import type { PoolClient } from 'pg'; -export const runtime = 'nodejs'; - -async function getLastFetchedAt(): Promise { - try { - return await withDb(async (client: PoolClient) => { - const { rows } = await client.query( - 'SELECT fetched_at FROM google_data ORDER BY id DESC LIMIT 1', - ); - return rows.length ? String(rows[0].fetched_at) : null; - }); - } catch { - return null; - } -} +export const dynamic = 'force-dynamic'; export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const status = await getGoogleAppPublicStatus(); - const lastFetchedAt = await getLastFetchedAt(); - - return NextResponse.json({ ...status, lastFetchedAt }); + return proxyToFastAPI(request, '/api/integrations/google/status'); }; diff --git a/web/app/api/integrations/google/test/route.ts b/web/app/api/integrations/google/test/route.ts index f66492e2..38c7c29c 100644 --- a/web/app/api/integrations/google/test/route.ts +++ b/web/app/api/integrations/google/test/route.ts @@ -1,57 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** - * POST /api/integrations/google/test - * Spawns `python -m src google --test` (config from PostgreSQL pipeline_config). - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let log = ''; - const proc = spawn(pythonExe, ['-m', 'src', 'google', '--test'], { - cwd: repoRoot, - env: getPipelineSpawnEnv(), - shell: false, - }); - - const append = (chunk: Buffer | string): void => { - log += chunk.toString(); - if (log.length > 32_000) log = log.slice(-28_000); - }; - proc.stdout?.on('data', append); - proc.stderr?.on('data', append); - - proc.on('error', (err: Error) => { - clearTimeout(timer); - const message = formatPythonSpawnError(err, pythonExe, repoRoot); - resolve( - NextResponse.json({ ok: false, log, error: message }, { status: 500 }), - ); - }); - - proc.on('close', (code: number | null) => { - clearTimeout(timer); - resolve(NextResponse.json({ ok: code === 0, log, exitCode: code })); - }); - - // Safety timeout: 30s - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve( - NextResponse.json({ ok: false, log, error: 'Test timed out after 30s' }, { status: 504 }), - ); - }, 30_000); - }); + return proxyToFastAPI(request, '/api/integrations/google/test'); }; diff --git a/web/app/api/issues/action-plan/route.ts b/web/app/api/issues/action-plan/route.ts index 07921641..b58f981a 100644 --- a/web/app/api/issues/action-plan/route.ts +++ b/web/app/api/issues/action-plan/route.ts @@ -1,76 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const PYTHON_SCRIPT = ` -import json, sys -from website_profiling.llm.issues_action_plan import generate_issues_action_plan -payload = json.load(sys.stdin) -print(json.dumps(generate_issues_action_plan(payload, refresh=bool(payload.get("refresh"))))) -`; - -/** - * POST /api/issues/action-plan — LLM remediation plan for deduplicated audit issues. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - let body: { - domain?: string; - issues?: unknown[]; - refresh?: boolean; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const domain = String(body.domain || '').trim(); - if (!domain) { - return NextResponse.json({ error: 'domain required' }, { status: 400 }); - } - if (!Array.isArray(body.issues) || body.issues.length === 0) { - return NextResponse.json({ error: 'issues required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const payload = { - domain, - issues: body.issues, - refresh: body.refresh, - }; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', PYTHON_SCRIPT], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write(JSON.stringify(payload)); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Action plan failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json(parsed)); - return; - } - resolve(NextResponse.json({ error: 'Action plan failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Action plan timed out after 90s' }, { status: 504 })); - }, 90_000); - }); + const denied = forbiddenIfNotLocal(request); if (denied) return denied; return proxyToFastAPI(request, '/api/issues/action-plan'); }; diff --git a/web/app/api/issues/fix-suggestion/route.ts b/web/app/api/issues/fix-suggestion/route.ts index b3b27b6a..ea3005f0 100644 --- a/web/app/api/issues/fix-suggestion/route.ts +++ b/web/app/api/issues/fix-suggestion/route.ts @@ -1,81 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const PYTHON_SCRIPT = ` -import json, sys -from website_profiling.llm.fix_suggestions import generate_fix_suggestion -payload = json.load(sys.stdin) -print(json.dumps(generate_fix_suggestion(payload, refresh=bool(payload.get("refresh"))))) -`; - -/** - * POST /api/issues/fix-suggestion — legacy alias for issue-source fix suggestions. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - let body: { - message?: string; - url?: string; - priority?: string; - category?: string; - recommendation?: string; - type?: string; - refresh?: boolean; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - const message = String(body.message || '').trim(); - if (!message) { - return NextResponse.json({ error: 'message required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const payload = { - source: 'issue', - message, - url: body.url, - priority: body.priority, - category: body.category, - recommendation: body.recommendation, - type: body.type, - refresh: body.refresh, - }; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', PYTHON_SCRIPT], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write(JSON.stringify(payload)); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Fix suggestion failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json(parsed)); - return; - } - resolve(NextResponse.json({ error: 'Fix suggestion failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Fix suggestion timed out after 90s' }, { status: 504 })); - }, 90_000); - }); + const denied = forbiddenIfNotLocal(request); if (denied) return denied; return proxyToFastAPI(request, '/api/issues/fix-suggestion'); }; diff --git a/web/app/api/issues/status/route.ts b/web/app/api/issues/status/route.ts index 3b719aa9..7d101573 100644 --- a/web/app/api/issues/status/route.ts +++ b/web/app/api/issues/status/route.ts @@ -1,70 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { listIssueStatus, upsertIssueStatus, type IssueWorkflowStatus } from '@/server/issueStatusDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const VALID_STATUS = new Set(['open', 'in_progress', 'fixed', 'ignored']); - export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); - if (!propertyId) { - return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); - } - try { - const rows = await listIssueStatus(propertyId); - return NextResponse.json({ issues: rows }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/issues/status'); }; export const PUT: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: { - propertyId?: number; - reportId?: number; - message?: string; - url?: string; - priority?: string; - categoryId?: string; - status?: string; - assignee?: string; - note?: string; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - const message = String(body.message || '').trim(); - const status = body.status as IssueWorkflowStatus; - if (!propertyId || !message || !VALID_STATUS.has(status)) { - return NextResponse.json({ error: 'propertyId, message, and valid status required' }, { status: 400 }); - } - - try { - const row = await upsertIssueStatus({ - propertyId, - reportId: body.reportId, - message, - url: body.url, - priority: body.priority, - categoryId: body.categoryId, - status, - assignee: body.assignee, - note: body.note, - }); - return NextResponse.json({ issue: row }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/issues/status'); }; diff --git a/web/app/api/jobs/[id]/cancel/route.ts b/web/app/api/jobs/[id]/cancel/route.ts index 44f99111..899c4e17 100644 --- a/web/app/api/jobs/[id]/cancel/route.ts +++ b/web/app/api/jobs/[id]/cancel/route.ts @@ -1,14 +1,14 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * POST /api/jobs/[id]/cancel — cancel a pipeline job via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { cancelPipelineJob } from '@/server/pipelineJobs'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; -/** - * POST /api/jobs/:id/cancel — stop a running pipeline job. - */ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -17,16 +17,6 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - const { id } = await params; - const result = await cancelPipelineJob(id); - if (!result.ok) { - const status = result.error === 'Job not found' ? 404 : 409; - return NextResponse.json({ error: result.error || 'Unable to cancel job' }, { status }); - } - return NextResponse.json({ - ok: true, - status: result.status, - error: result.error ?? null, - }); + return proxyToFastAPI(request, `/api/jobs/${id}/cancel`); }; diff --git a/web/app/api/jobs/[id]/pause/route.ts b/web/app/api/jobs/[id]/pause/route.ts index 615688db..df855688 100644 --- a/web/app/api/jobs/[id]/pause/route.ts +++ b/web/app/api/jobs/[id]/pause/route.ts @@ -1,16 +1,14 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * POST /api/jobs/[id]/pause — pause a pipeline job via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { pausePipelineJob } from '@/server/pipelineJobs'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; -/** - * POST /api/jobs/:id/pause — send a pause signal to the running crawler. - * The Python process saves its frontier state and exits with code 2; the - * job status transitions to 'paused'. - */ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -19,12 +17,6 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - const { id } = await params; - const result = await pausePipelineJob(id); - if (!result.ok) { - const status = result.error === 'Job not found' ? 404 : 409; - return NextResponse.json({ error: result.error || 'Unable to pause job' }, { status }); - } - return NextResponse.json({ ok: true }); + return proxyToFastAPI(request, `/api/jobs/${id}/pause`); }; diff --git a/web/app/api/jobs/[id]/resume/route.ts b/web/app/api/jobs/[id]/resume/route.ts index df6712d7..1f0c6ff6 100644 --- a/web/app/api/jobs/[id]/resume/route.ts +++ b/web/app/api/jobs/[id]/resume/route.ts @@ -1,15 +1,14 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * POST /api/jobs/[id]/resume — resume a paused pipeline job via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { resumePipelineJob } from '@/server/pipelineJobs'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; -/** - * POST /api/jobs/:id/resume — spawn a new crawl job that restores the - * frontier saved when the job was paused. - */ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -18,12 +17,6 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - const { id } = await params; - const result = await resumePipelineJob(id); - if (!result.ok) { - const status = result.error === 'Job not found' ? 404 : 409; - return NextResponse.json({ error: result.error || 'Unable to resume job' }, { status }); - } - return NextResponse.json({ ok: true, newJobId: result.newJobId }); + return proxyToFastAPI(request, `/api/jobs/${id}/resume`); }; diff --git a/web/app/api/jobs/[id]/route.ts b/web/app/api/jobs/[id]/route.ts index 54b94077..999765e9 100644 --- a/web/app/api/jobs/[id]/route.ts +++ b/web/app/api/jobs/[id]/route.ts @@ -1,6 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * GET /api/jobs/[id] — get pipeline job status via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getJob } from '@/server/pipelineJobs'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -12,15 +15,5 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const job = await getJob(id); - if (!job) { - return NextResponse.json({ error: 'Job not found' }, { status: 404 }); - } - return NextResponse.json({ - status: job.status, - exitCode: job.exitCode, - log: job.log, - error: job.error ?? null, - logTruncated: job.logTruncated ?? false, - }); + return proxyToFastAPI(request, `/api/jobs/${id}`); }; diff --git a/web/app/api/jobs/route.ts b/web/app/api/jobs/route.ts index 8f9e4bd0..b63460c5 100644 --- a/web/app/api/jobs/route.ts +++ b/web/app/api/jobs/route.ts @@ -1,29 +1,16 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * GET /api/jobs — list recent pipeline jobs via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { listPipelineJobsForApi } from '@/server/pipelineJobs'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * GET /api/jobs — list recent pipeline jobs and return the active running job (if any). - * Reconciles stale running jobs before listing. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const limit = Math.min( - 100, - Math.max(1, Number(request.nextUrl.searchParams.get('limit') || '20') || 20), - ); - - try { - const { jobs, active, reconciled } = await listPipelineJobsForApi(limit); - return NextResponse.json({ jobs, active, reconciled }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/jobs'); }; diff --git a/web/app/api/keywords/competitor-import/route.ts b/web/app/api/keywords/competitor-import/route.ts index b7976872..ff78edfd 100644 --- a/web/app/api/keywords/competitor-import/route.ts +++ b/web/app/api/keywords/competitor-import/route.ts @@ -1,82 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const MERGE_SCRIPT = ` -import json, sys -from website_profiling.integrations.keywords.competitor_csv import parse_competitor_keyword_csv -from website_profiling.integrations.keywords.competitor_gap_store import merge_competitor_keyword_import -from website_profiling.db.storage import db_session - -payload = json.load(sys.stdin) -property_id = int(payload["propertyId"]) -competitor = payload.get("competitor") or "" -rows = parse_competitor_keyword_csv(payload.get("csvText") or "", competitor=competitor) -with db_session() as conn: - merged = merge_competitor_keyword_import(conn, property_id, competitor, rows) -print(json.dumps({"count": len(rows), "rows": rows[:500], "mergedCount": len(merged), "mergedRows": merged[:500]})) -`; - -/** - * POST /api/keywords/competitor-import - * Body: { propertyId, competitor, csvText } - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: { propertyId?: number; competitor?: string; csvText?: string }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const propertyId = Number(body.propertyId || 0); - const competitor = String(body.competitor || '').trim(); - const csvText = String(body.csvText || ''); - if (!propertyId || !competitor || !csvText.trim()) { - return NextResponse.json({ error: 'propertyId, competitor, and csvText required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', MERGE_SCRIPT], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stderr?.on('data', (c: Buffer | string) => { stderr += c.toString(); }); - proc.stdin?.write(JSON.stringify({ propertyId, competitor, csvText })); - proc.stdin?.end(); - proc.on('error', () => { - resolve(NextResponse.json({ error: 'Import failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve( - NextResponse.json({ - count: parsed.count ?? 0, - rows: parsed.rows ?? [], - mergedCount: parsed.mergedCount ?? parsed.count ?? 0, - mergedRows: parsed.mergedRows ?? parsed.rows ?? [], - }), - ); - return; - } - resolve(NextResponse.json({ error: 'Competitor keyword import failed' }, { status: 500 })); - }); - }); + return proxyToFastAPI(request, '/api/keywords/competitor-import'); }; diff --git a/web/app/api/keywords/content-brief/route.ts b/web/app/api/keywords/content-brief/route.ts index 1d58a324..4d44e142 100644 --- a/web/app/api/keywords/content-brief/route.ts +++ b/web/app/api/keywords/content-brief/route.ts @@ -1,66 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/keywords/content-brief - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - let body: { keyword?: string; rows?: unknown[]; gaps?: string[] }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - const keyword = String(body.keyword || '').trim(); - if (!keyword) { - return NextResponse.json({ error: 'keyword required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const script = ` -import json, sys -from website_profiling.llm.content_brief import generate_content_brief -payload = json.load(sys.stdin) -print(json.dumps(generate_content_brief( - payload.get("keyword", ""), - payload.get("rows") or [], - payload.get("gaps"), -))) -`; - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', script], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write(JSON.stringify({ keyword, rows: body.rows || [], gaps: body.gaps || [] })); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'Content brief failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - resolve(NextResponse.json({ brief: parsed })); - return; - } - resolve(NextResponse.json({ error: 'Content brief generation failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'Content brief timed out after 90s' }, { status: 504 })); - }, 90_000); - }); + return proxyToFastAPI(request, '/api/keywords/content-brief'); }; diff --git a/web/app/api/links/page-coach/route.ts b/web/app/api/links/page-coach/route.ts index 5dc96692..3d91306a 100644 --- a/web/app/api/links/page-coach/route.ts +++ b/web/app/api/links/page-coach/route.ts @@ -1,121 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -interface PageCoachBody { - url?: string; - refresh?: boolean; - currentType?: 'snapshot' | 'live'; - currentId?: number; - baselineType?: 'snapshot' | 'live'; - baselineId?: number; -} - -/** - * POST /api/links/page-coach - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: PageCoachBody = {}; - try { - body = (await request.json()) as PageCoachBody; - } catch { - body = {}; - } - - const url = (body.url || '').trim(); - if (!url) { - return NextResponse.json({ error: 'url is required' }, { status: 400 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - const args = ['-m', 'src', 'page-coach', '--url', url]; - if (body.refresh) args.push('--refresh'); - - return new Promise((resolve) => { - let log = ''; - let stdout = ''; - const env = { ...getPipelineSpawnEnv() }; - if (body.currentType && body.currentId != null) { - env.WP_PAGE_COACH_CURRENT = `${body.currentType}:${body.currentId}`; - } - if (body.baselineType && body.baselineId != null) { - env.WP_PAGE_COACH_BASELINE = `${body.baselineType}:${body.baselineId}`; - } - - const proc = spawn(pythonExe, args, { - cwd: repoRoot, - env, - shell: false, - }); - - const append = (chunk: Buffer | string): void => { - const s = chunk.toString(); - log += s; - stdout += s; - if (log.length > 48_000) log = log.slice(-40_000); - }; - proc.stdout?.on('data', append); - proc.stderr?.on('data', append); - - proc.on('error', (err: Error) => { - clearTimeout(timer); - resolve( - NextResponse.json( - { ok: false, error: formatPythonSpawnError(err, pythonExe, repoRoot), log }, - { status: 500 }, - ), - ); - }); - - proc.on('close', (code: number | null) => { - clearTimeout(timer); - try { - const lines = stdout.trim().split('\n').filter(Boolean); - const last = lines[lines.length - 1] || '{}'; - const data = JSON.parse(last) as { - ok?: boolean; - cached?: boolean; - coach?: Record; - error?: string; - }; - if (!data.ok) { - resolve( - NextResponse.json( - { ok: false, error: data.error || 'Page coach failed', log }, - { status: 500 }, - ), - ); - return; - } - resolve(NextResponse.json({ ok: true, cached: data.cached, coach: data.coach, log: code !== 0 ? log : undefined })); - } catch { - resolve( - NextResponse.json({ ok: false, error: 'Invalid page-coach response', log }, { status: 500 }), - ); - } - }); - - const timer = setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve( - NextResponse.json({ ok: false, error: 'Page coach timed out after 90s', log }, { status: 504 }), - ); - }, 90_000); - }); + return proxyToFastAPI(request, '/api/links/page-coach'); }; diff --git a/web/app/api/llm-config/route.ts b/web/app/api/llm-config/route.ts index 204d22f0..ee9adbb5 100644 --- a/web/app/api/llm-config/route.ts +++ b/web/app/api/llm-config/route.ts @@ -1,69 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { requireApiAuth, requireApiAuthForChat } from '@/server/auth'; -import { loadLlmConfig, saveLlmConfig } from '@/server/llmConfig'; -import { ALL_LLM_SCHEMA_KEYS, getLlmFieldByKey } from '@/lib/llmConfigSchema'; -import type { ApiRouteHandler, LlmConfigPutBody, LlmConfigState } from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** GET /api/llm-config — LLM settings from PostgreSQL only (secrets masked). */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const authDenied = requireApiAuthForChat(request); - if (authDenied) return authDenied; - - try { - const result = await loadLlmConfig(); - return NextResponse.json(result); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/llm-config'); }; -/** PUT /api/llm-config — Body: { state: Record } */ export const PUT: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const authDenied = requireApiAuth(request); - if (authDenied) return authDenied; - - let body: LlmConfigPutBody; - try { - body = (await request.json()) as LlmConfigPutBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { state: rawState } = body; - if (!rawState || typeof rawState !== 'object') { - return NextResponse.json({ error: 'Missing state object' }, { status: 400 }); - } - - const state: LlmConfigState = {}; - for (const [key, rawValue] of Object.entries(rawState)) { - if (!ALL_LLM_SCHEMA_KEYS.has(key)) continue; - if (key.endsWith('_masked')) continue; - const field = getLlmFieldByKey(key); - if (!field) continue; - - if (field.type === 'bool') { - state[key] = rawValue === true || rawValue === 'true'; - } else { - state[key] = rawValue == null ? '' : String(rawValue); - if (rawState[`${key}_masked`] === true) { - state[`${key}_masked`] = true; - } - } - } - - try { - const dbPath = await saveLlmConfig(state); - return NextResponse.json({ ok: true, dbPath }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/llm-config'); }; diff --git a/web/app/api/logs/upload/route.ts b/web/app/api/logs/upload/route.ts index 80236327..67f37f7f 100644 --- a/web/app/api/logs/upload/route.ts +++ b/web/app/api/logs/upload/route.ts @@ -1,81 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { requireApiAuth } from '@/server/auth'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withDb } from '@/server/db'; +import { requireApiAuth } from '@/server/auth'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/logs/upload — parse access log and store analysis (Phase 6). - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - const form = await request.formData(); - const file = form.get('file'); - const propertyId = Number(form.get('propertyId') || '0'); - if (!propertyId || !(file instanceof File)) { - return NextResponse.json({ error: 'propertyId and file required' }, { status: 400 }); - } - - const text = await file.text(); - const lines = text.split(/\r?\n/); - - try { - const { spawn } = await import('child_process'); - const path = await import('path'); - const repoRoot = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); - const analysis = await new Promise>((resolve, reject) => { - const startUrl = String(form.get('startUrl') || ''); - const crawlUrlsRaw = String(form.get('crawlUrls') || ''); - const crawlUrls = crawlUrlsRaw ? crawlUrlsRaw.split('\n').filter(Boolean) : []; - const script = ` -import json, sys -from website_profiling.analysis.log_parser import parse_access_log_lines, compare_log_to_crawl -lines = sys.stdin.read().splitlines() -analysis = parse_access_log_lines(lines) -meta = json.loads(sys.argv[1]) -start = meta.get("start_url") or "" -crawl_urls = meta.get("crawl_urls") or [] -if start and crawl_urls: - analysis["crawl_compare"] = compare_log_to_crawl(analysis, crawl_urls, start) -print(json.dumps(analysis)) -`; - const meta = JSON.stringify({ start_url: startUrl, crawl_urls: crawlUrls }); - const proc = spawn('python3', ['-c', script, meta], { cwd: repoRoot, shell: false }); - let out = ''; - let errOut = ''; - proc.stdout?.on('data', (c: Buffer) => { out += c.toString(); }); - proc.stderr?.on('data', (c: Buffer) => { errOut += c.toString(); }); - proc.stdin?.write(text); - proc.stdin?.end(); - proc.on('error', (e) => reject(e)); - proc.on('close', (code) => { - if (code !== 0) reject(new Error(errOut || out || 'parse failed')); - else { - try { - resolve(JSON.parse(out.trim() || '{}') as Record); - } catch { - reject(new Error('Invalid JSON response from log parser')); - } - } - }); - }); - await withDb(async (client) => { - await client.query( - `INSERT INTO log_file_uploads (property_id, filename, line_count, analysis) - VALUES ($1, $2, $3, $4)`, - [propertyId, file.name, lines.length, JSON.stringify(analysis)], - ); - }); - return NextResponse.json({ ok: true, analysis }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/logs/upload'); }; diff --git a/web/app/api/mcp-tools/route.ts b/web/app/api/mcp-tools/route.ts index 4b800122..50cfcac1 100644 --- a/web/app/api/mcp-tools/route.ts +++ b/web/app/api/mcp-tools/route.ts @@ -1,75 +1,11 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -const execFileAsync = promisify(execFile); +export const dynamic = 'force-dynamic'; -const PYTHON_SCRIPT = ` -import json, sys -try: - from website_profiling.tools.audit_tools.registry import ( - TOOL_DEFINITIONS, get_tool_meta, mcp_tool_names - ) - from website_profiling.tools.audit_tools.tool_domains import ( - MCP_DOMAIN_BUNDLES, CANONICAL_DOMAINS, classify_tool_domain - ) - bundle_sets = {b: mcp_tool_names(b) for b in MCP_DOMAIN_BUNDLES.keys()} - tools = [] - for spec in TOOL_DEFINITIONS: - name = spec.get("name", "") - if not name: - continue - meta = (get_tool_meta(name) or {}) - domain = meta.get("domain") or classify_tool_domain(name) - in_bundles = [b for b, names in bundle_sets.items() if name in names] - tools.append({ - "name": name, - "description": spec.get("description", ""), - "domain": domain, - "bundles": in_bundles, - }) - print(json.dumps({ - "tools": tools, - "bundles": {k: sorted(v) for k, v in bundle_sets.items()}, - "domains": list(CANONICAL_DOMAINS), - })) -except Exception as e: - print(json.dumps({"error": str(e), "tools": [], "bundles": {}, "domains": []})) -`; - -export async function GET(request: NextRequest): Promise { +export async function GET(request: NextRequest): Promise { const guard = forbiddenIfNotLocal(request); if (guard) return guard; - - try { - const pythonBin = process.env.PYTHON_BIN || 'python3'; - const { stdout } = await execFileAsync( - pythonBin, - ['-c', PYTHON_SCRIPT], - { - timeout: 15_000, - env: { - ...process.env, - PYTHONPATH: process.env.PYTHONPATH || 'src', - }, - }, - ); - const data = JSON.parse(stdout.trim()) as { - tools: { name: string; description: string; domain: string; bundles: string[] }[]; - bundles: Record; - domains: string[]; - error?: string; - }; - if (data.error) { - return NextResponse.json({ error: data.error, tools: [], bundles: {}, domains: [] }, { status: 500 }); - } - return NextResponse.json(data); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return NextResponse.json( - { error: `Failed to load tool catalog: ${message}`, tools: [], bundles: {}, domains: [] }, - { status: 500 }, - ); - } + return proxyToFastAPI(request, '/api/mcp-tools'); } diff --git a/web/app/api/ollama/status/route.ts b/web/app/api/ollama/status/route.ts index e742e9ab..9f3ede78 100644 --- a/web/app/api/ollama/status/route.ts +++ b/web/app/api/ollama/status/route.ts @@ -51,7 +51,8 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const params = request.nextUrl.searchParams; - const crawlRunId = Number(params.get('crawlRunId') || '0'); - const url = (params.get('url') || '').trim(); - - if (!crawlRunId) { - return NextResponse.json({ error: 'crawlRunId required' }, { status: 400 }); - } - if (!url) { - return NextResponse.json({ error: 'url required' }, { status: 400 }); - } - - try { - const content = await getPageMarkdownContent(crawlRunId, url); - if (!content) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - return NextResponse.json({ content }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/page-markdown/content'); }; diff --git a/web/app/api/page-markdown/extract/route.ts b/web/app/api/page-markdown/extract/route.ts index edf5c646..b0bf4bcb 100644 --- a/web/app/api/page-markdown/extract/route.ts +++ b/web/app/api/page-markdown/extract/route.ts @@ -1,54 +1,15 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { startPipelineJobAsync } from '@/server/pipelineJobs'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/page-markdown/extract - * Body: { crawlRunId: number, strategy?: 'main_only' | 'full_body', overwrite?: boolean } - * - * Spawns a `page-markdown` CLI job and returns a jobId to poll. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: { - crawlRunId?: number; - strategy?: string; - overwrite?: boolean; - workers?: number; - } = {}; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const crawlRunId = Number(body.crawlRunId ?? 0); - if (!crawlRunId) { - return NextResponse.json({ error: 'crawlRunId required' }, { status: 400 }); - } - - const strategy = body.strategy === 'full_body' ? 'full_body' : 'main_only'; - const overwrite = body.overwrite !== false; - const workers = Math.min(16, Math.max(1, Number(body.workers ?? 4))); - - // Build CLI command: page-markdown --crawl-run-id N --strategy S [--no-overwrite] --workers N - let command = `page-markdown --crawl-run-id ${crawlRunId} --strategy ${strategy} --workers ${workers}`; - if (!overwrite) command += ' --no-overwrite'; - - try { - const jobId = await startPipelineJobAsync(command, null); - return NextResponse.json({ jobId, crawlRunId, strategy, overwrite }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/page-markdown/extract'); }; diff --git a/web/app/api/page-markdown/route.ts b/web/app/api/page-markdown/route.ts index 059b4388..0909b613 100644 --- a/web/app/api/page-markdown/route.ts +++ b/web/app/api/page-markdown/route.ts @@ -1,63 +1,19 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuth } from '@/server/auth'; -import { listPageMarkdownItems, deletePageMarkdownForRun } from '@/server/pageMarkdownDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * GET /api/page-markdown?crawlRunId=&page=1&limit=25&q= - * Paginated list of extracted markdown entries for a crawl run. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const params = request.nextUrl.searchParams; - const crawlRunId = Number(params.get('crawlRunId') || '0'); - if (!crawlRunId) { - return NextResponse.json({ error: 'crawlRunId required' }, { status: 400 }); - } - const page = Math.max(1, Number(params.get('page') || '1')); - const pageSize = Math.min(100, Math.max(1, Number(params.get('limit') || '25'))); - const q = (params.get('q') || '').trim(); - - try { - const result = await listPageMarkdownItems(crawlRunId, page, pageSize, q); - return NextResponse.json(result); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/page-markdown'); }; -/** - * DELETE /api/page-markdown - * Body: { crawlRunId: number } - * Removes extracted markdown for one crawl run (localhost-only). - */ export const DELETE: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: { crawlRunId?: number } = {}; - try { - body = await request.json(); - } catch { - /* fall through — no body */ - } - - const crawlRunId = Number(body.crawlRunId ?? 0); - if (!crawlRunId) { - return NextResponse.json({ error: 'crawlRunId required' }, { status: 400 }); - } - - try { - const deletedRows = await deletePageMarkdownForRun(crawlRunId); - return NextResponse.json({ ok: true, crawlRunId, deletedRows }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/page-markdown'); }; diff --git a/web/app/api/page-markdown/runs/route.ts b/web/app/api/page-markdown/runs/route.ts index 679a9f9e..74876eee 100644 --- a/web/app/api/page-markdown/runs/route.ts +++ b/web/app/api/page-markdown/runs/route.ts @@ -1,21 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { listPageMarkdownRuns } from '@/server/pageMarkdownDb'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * GET /api/page-markdown/runs?propertyId= - * Returns crawl runs with html_page_count and markdown_page_count for a property. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0') || null; - try { - const runs = await listPageMarkdownRuns(propertyId); - return NextResponse.json({ runs }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg, runs: [] }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/page-markdown/runs'); }; diff --git a/web/app/api/pipeline-config/route.ts b/web/app/api/pipeline-config/route.ts index 1517d542..5fbb5f52 100644 --- a/web/app/api/pipeline-config/route.ts +++ b/web/app/api/pipeline-config/route.ts @@ -1,118 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { loadPipelineConfig, savePipelineConfig } from '@/server/pipelineConfig'; -import { - ALL_SCHEMA_KEYS, - INTERNAL_PIPELINE_KEYS, - getFieldByKey, - validateRequiredPipelineFields, -} from '@/lib/pipelineConfigSchema'; -import { resolvePropertyIdFromStartUrl } from '@/server/propertiesDb'; -import type { - ApiRouteHandler, - PipelineConfigPutBody, - PipelineConfigState, - PipelineUnknownKey, -} from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -function isUnknownKeyEntry(value: unknown): value is PipelineUnknownKey { - return ( - value != null && - typeof value === 'object' && - typeof (value as PipelineUnknownKey).key === 'string' && - typeof (value as PipelineUnknownKey).value === 'string' - ); -} - -/** - * GET /api/pipeline-config - * Returns { state, unknownKeys, source, dbPath }. - * Localhost-only. - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - try { - const result = await loadPipelineConfig(); - return NextResponse.json(result); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/pipeline-config'); }; -/** - * PUT /api/pipeline-config - * Body: { state: Record, unknownKeys?: Array<{key,value}> } - */ export const PUT: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: PipelineConfigPutBody; - try { - body = (await request.json()) as PipelineConfigPutBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { state: rawState, unknownKeys = [] } = body; - if (!rawState || typeof rawState !== 'object') { - return NextResponse.json({ error: 'Missing state object' }, { status: 400 }); - } - - const internalKeySet = new Set(INTERNAL_PIPELINE_KEYS); - const state: PipelineConfigState = {}; - for (const [key, rawValue] of Object.entries(rawState)) { - if (key.startsWith('llm_')) continue; - if (!ALL_SCHEMA_KEYS.has(key)) continue; - const field = getFieldByKey(key); - if (field) { - if (field.type === 'bool') { - state[key] = rawValue === true || rawValue === 'true'; - } else if (field.type === 'tristate') { - const s = String(rawValue ?? 'auto').toLowerCase(); - if (s === 'true') state[key] = 'true'; - else if (s === 'false') state[key] = 'false'; - else state[key] = 'auto'; - } else { - state[key] = rawValue == null ? '' : String(rawValue); - } - continue; - } - if (internalKeySet.has(key)) { - state[key] = rawValue == null ? '' : String(rawValue); - } - } - - const startUrl = String(state.start_url || '').trim(); - if (startUrl) { - const resolvedPropertyId = await resolvePropertyIdFromStartUrl(startUrl); - state.active_property_id = String(resolvedPropertyId); - } - - const safeUnknownKeys: PipelineUnknownKey[] = Array.isArray(unknownKeys) - ? unknownKeys.filter( - (u) => - isUnknownKeyEntry(u) && - !u.key.startsWith('llm_') && - !u.key.startsWith('ml_'), - ) - : []; - - const requiredErrors = validateRequiredPipelineFields(state); - if (requiredErrors.length > 0) { - return NextResponse.json({ error: requiredErrors.join(' ') }, { status: 400 }); - } - - try { - const configPath = await savePipelineConfig(state, { unknownKeys: safeUnknownKeys }); - return NextResponse.json({ ok: true, configPath, source: 'store' }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/pipeline-config'); }; diff --git a/web/app/api/portfolio/delete/route.ts b/web/app/api/portfolio/delete/route.ts index 6d76a017..9867ea76 100644 --- a/web/app/api/portfolio/delete/route.ts +++ b/web/app/api/portfolio/delete/route.ts @@ -1,52 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { withReportDb } from '@/server/reportDb'; -import { deletePortfolioItem } from '@/lib/loadReportDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -type DeleteBody = { - reportId?: number | null; - crawlRunId?: number | null; -}; - export const DELETE: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: DeleteBody = {}; - try { - body = (await request.json()) as DeleteBody; - } catch { - const reportIdRaw = request.nextUrl.searchParams.get('reportId'); - const crawlRunIdRaw = request.nextUrl.searchParams.get('crawlRunId'); - if (reportIdRaw) body.reportId = Number(reportIdRaw); - if (crawlRunIdRaw) body.crawlRunId = Number(crawlRunIdRaw); - } - - const reportId = - body.reportId != null && Number.isFinite(Number(body.reportId)) ? Number(body.reportId) : null; - const crawlRunId = - body.crawlRunId != null && Number.isFinite(Number(body.crawlRunId)) - ? Number(body.crawlRunId) - : null; - - if (reportId == null && crawlRunId == null) { - return NextResponse.json({ error: 'reportId or crawlRunId is required' }, { status: 400 }); - } - - try { - const result = await withReportDb((client) => - deletePortfolioItem(client, { reportId, crawlRunId }), - ); - if (!result.deletedReport && !result.deletedCrawl) { - return NextResponse.json({ error: 'Nothing was deleted (not found or crawl still in use)' }, { status: 404 }); - } - return NextResponse.json({ ok: true, ...result }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/portfolio/delete'); }; diff --git a/web/app/api/properties/[id]/authorize/route.ts b/web/app/api/properties/[id]/authorize/route.ts index 0ba4be11..5b50dcae 100644 --- a/web/app/api/properties/[id]/authorize/route.ts +++ b/web/app/api/properties/[id]/authorize/route.ts @@ -1,7 +1,6 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { setPropertyCrawlAuthorized } from '@/server/propertiesDb'; -import { writeAuditLog } from '@/server/pipelineJobsDb'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -13,16 +12,5 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - try { - await setPropertyCrawlAuthorized(propertyId); - void writeAuditLog('crawl_authorized', null, propertyId, {}).catch(() => {}); - return NextResponse.json({ ok: true }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/properties/${id}/authorize`); }; diff --git a/web/app/api/properties/[id]/google/credentials/route.ts b/web/app/api/properties/[id]/google/credentials/route.ts index d5a98f97..6936d02d 100644 --- a/web/app/api/properties/[id]/google/credentials/route.ts +++ b/web/app/api/properties/[id]/google/credentials/route.ts @@ -1,11 +1,7 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { - getPropertyById, - getPropertyGooglePublicStatus, - setPropertyGoogleCredentials, -} from '@/server/propertiesDb'; -import type { ApiRouteHandlerWithParams, GoogleCredentialsPostBody } from '@/types/api'; +import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -16,54 +12,5 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - try { - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - const body = (await request.json().catch(() => ({}))) as GoogleCredentialsPostBody; - const patch: Parameters[1] = {}; - - if ('gscSiteUrl' in body) { - patch.gscSiteUrl = - typeof body.gscSiteUrl === 'string' && body.gscSiteUrl.trim() - ? body.gscSiteUrl.trim() - : null; - } - if ('ga4PropertyId' in body) { - const v = typeof body.ga4PropertyId === 'string' ? body.ga4PropertyId.trim() : ''; - if (v && !/^\d+$/.test(v)) { - return NextResponse.json( - { - error: - 'Analytics property ID must be a numeric ID (e.g. 123456789). The G-XXXXXXX code is a Measurement ID.', - }, - { status: 400 }, - ); - } - patch.ga4PropertyId = v || null; - } - if (typeof body.dateRangeDays === 'number' && body.dateRangeDays > 0) { - patch.dateRangeDays = body.dateRangeDays; - } - if (typeof body.refreshToken === 'string' && body.refreshToken.trim()) { - patch.refreshToken = body.refreshToken.trim(); - patch.authMode = 'oauth'; - } - - if (Object.keys(patch).length === 0) { - return NextResponse.json({ error: 'No valid fields provided' }, { status: 400 }); - } - - await setPropertyGoogleCredentials(propertyId, patch); - const status = await getPropertyGooglePublicStatus(propertyId); - return NextResponse.json({ ok: true, status }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/properties/${id}/google/credentials`); }; diff --git a/web/app/api/properties/[id]/google/disconnect/route.ts b/web/app/api/properties/[id]/google/disconnect/route.ts index 354d852c..ce678de8 100644 --- a/web/app/api/properties/[id]/google/disconnect/route.ts +++ b/web/app/api/properties/[id]/google/disconnect/route.ts @@ -1,6 +1,6 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById, setPropertyGoogleCredentials } from '@/server/propertiesDb'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -12,22 +12,5 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - try { - await setPropertyGoogleCredentials(propertyId, { - refreshToken: null, - authMode: null, - }); - return NextResponse.json({ ok: true }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/properties/${id}/google/disconnect`); }; diff --git a/web/app/api/properties/[id]/google/links/import/route.ts b/web/app/api/properties/[id]/google/links/import/route.ts index e1ccf240..d0327a28 100644 --- a/web/app/api/properties/[id]/google/links/import/route.ts +++ b/web/app/api/properties/[id]/google/links/import/route.ts @@ -1,119 +1,16 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById } from '@/server/propertiesDb'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { - formatPythonSpawnError, - parsePythonJsonStdout, - resolvePythonExecutable, -} from '@/server/resolvePython'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; -interface ImportBody { - fileContent?: string; - fileName?: string; -} - export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - - const body = (await request.json().catch(() => ({}))) as ImportBody; - const fileContent = body.fileContent; - if (!fileContent || typeof fileContent !== 'string' || !fileContent.trim()) { - return NextResponse.json({ error: 'fileContent is required' }, { status: 400 }); - } - - const fileName = typeof body.fileName === 'string' ? body.fileName : ''; - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - const args = [ - '-m', - 'src', - 'gsc-links-import', - '--property-id', - String(propertyId), - '--csv-stdin', - ]; - if (fileName) { - args.push('--file-name', fileName); - } - - const proc = spawn(pythonExe, args, { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot, propertyId), - shell: false, - }); - - proc.stdin?.write(fileContent); - proc.stdin?.end(); - - proc.stdout?.on('data', (c: Buffer | string) => { - stdout += c.toString(); - }); - proc.stderr?.on('data', (c: Buffer | string) => { - stderr += c.toString(); - }); - - proc.on('error', (err: Error) => { - resolve( - NextResponse.json( - { error: formatPythonSpawnError(err, pythonExe, repoRoot) }, - { status: 500 }, - ), - ); - }); - - proc.on('close', (code: number | null) => { - const parsed = parsePythonJsonStdout(stdout); - if (parsed && code === 0 && parsed.ok) { - resolve(NextResponse.json(parsed)); - return; - } - if (parsed) { - const errMsg = - typeof parsed.error === 'string' - ? parsed.error - : stdout.trim() || stderr.trim() || 'Import failed'; - resolve(NextResponse.json({ error: errMsg, detail: parsed }, { status: 400 })); - return; - } - const raw = stdout.trim() || stderr.trim(); - resolve( - NextResponse.json( - { error: raw || 'Import failed', exitCode: code }, - { status: code === 0 ? 500 : 400 }, - ), - ); - }); - - setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve(NextResponse.json({ error: 'Timed out' }, { status: 504 })); - }, 120_000); - }); + return proxyToFastAPI(request, `/api/properties/${id}/google/links/import`); }; diff --git a/web/app/api/properties/[id]/google/links/status/route.ts b/web/app/api/properties/[id]/google/links/status/route.ts index bfd6222e..0980f6b5 100644 --- a/web/app/api/properties/[id]/google/links/status/route.ts +++ b/web/app/api/properties/[id]/google/links/status/route.ts @@ -1,13 +1,6 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById } from '@/server/propertiesDb'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { - formatPythonSpawnError, - parsePythonJsonStdout, - resolvePythonExecutable, -} from '@/server/resolvePython'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -18,67 +11,6 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - const proc = spawn( - pythonExe, - ['-m', 'src', 'gsc-links-import', '--status', '--property-id', String(propertyId)], - { cwd: repoRoot, env: getPipelineSpawnEnv(repoRoot, propertyId), shell: false }, - ); - - proc.stdout?.on('data', (c: Buffer | string) => { - stdout += c.toString(); - }); - proc.stderr?.on('data', (c: Buffer | string) => { - stderr += c.toString(); - }); - - proc.on('error', (err: Error) => { - resolve( - NextResponse.json( - { error: formatPythonSpawnError(err, pythonExe, repoRoot) }, - { status: 500 }, - ), - ); - }); - - proc.on('close', (code: number | null) => { - const parsed = parsePythonJsonStdout(stdout); - if (parsed && code === 0) { - resolve(NextResponse.json(parsed)); - return; - } - const raw = stdout.trim() || stderr.trim(); - resolve( - NextResponse.json( - { error: raw || 'Status check failed', exitCode: code }, - { status: code === 0 ? 500 : 400 }, - ), - ); - }); - - setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve(NextResponse.json({ error: 'Timed out' }, { status: 504 })); - }, 30_000); - }); + return proxyToFastAPI(request, `/api/properties/${id}/google/links/status`); }; diff --git a/web/app/api/properties/[id]/google/properties/route.ts b/web/app/api/properties/[id]/google/properties/route.ts index 140f566b..28f523b2 100644 --- a/web/app/api/properties/[id]/google/properties/route.ts +++ b/web/app/api/properties/[id]/google/properties/route.ts @@ -1,9 +1,6 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById } from '@/server/propertiesDb'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -15,70 +12,5 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - const proc = spawn( - pythonExe, - ['-m', 'src', 'google', '--list-properties', '--property-id', String(propertyId)], - { cwd: repoRoot, env: getPipelineSpawnEnv(repoRoot, propertyId), shell: false }, - ); - - proc.stdout?.on('data', (c: Buffer | string) => { - stdout += c.toString(); - }); - proc.stderr?.on('data', (c: Buffer | string) => { - stderr += c.toString(); - }); - - proc.on('error', (err: Error) => { - resolve( - NextResponse.json( - { error: formatPythonSpawnError(err, pythonExe, repoRoot) }, - { status: 500 }, - ), - ); - }); - - proc.on('close', (code: number | null) => { - if (code !== 0) { - resolve( - NextResponse.json( - { error: stderr.trim() || 'Failed to list properties', exitCode: code }, - { status: 500 }, - ), - ); - return; - } - try { - const data: unknown = JSON.parse(stdout.trim()); - resolve(NextResponse.json(data)); - } catch { - resolve( - NextResponse.json({ error: 'Could not parse properties response from Python' }, { status: 500 }), - ); - } - }); - - setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve(NextResponse.json({ error: 'Timed out listing properties' }, { status: 504 })); - }, 30_000); - }); + return proxyToFastAPI(request, `/api/properties/${id}/google/properties`); }; diff --git a/web/app/api/properties/[id]/google/status/route.ts b/web/app/api/properties/[id]/google/status/route.ts index e04fad1d..6cc915e7 100644 --- a/web/app/api/properties/[id]/google/status/route.ts +++ b/web/app/api/properties/[id]/google/status/route.ts @@ -1,8 +1,6 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyGooglePublicStatus, getPropertyById } from '@/server/propertiesDb'; -import { getGoogleAppPublicStatus } from '@/server/googleAppSettings'; -import { withDb } from '@/server/db'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -15,38 +13,5 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - try { - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - const propStatus = await getPropertyGooglePublicStatus(propertyId); - const globalStatus = await getGoogleAppPublicStatus(); - const status = { - ...propStatus, - hasClientId: globalStatus.hasClientId, - gscSiteUrl: propStatus.gscSiteUrl, - ga4PropertyId: propStatus.ga4PropertyId, - dateRangeDays: propStatus.dateRangeDays, - connected: propStatus.connected, - authMode: propStatus.authMode, - }; - let lastFetchedAt: string | null = null; - await withDb(async (client) => { - const cur = await client.query<{ fetched_at: string }>( - `SELECT fetched_at::text FROM google_data - WHERE property_id = $1 ORDER BY id DESC LIMIT 1`, - [propertyId], - ); - lastFetchedAt = cur.rows[0]?.fetched_at ?? null; - }); - return NextResponse.json({ ...status, lastFetchedAt, propertyId }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, `/api/properties/${id}/google/status`); }; diff --git a/web/app/api/properties/[id]/google/test/route.ts b/web/app/api/properties/[id]/google/test/route.ts index 26660e05..e43c5294 100644 --- a/web/app/api/properties/[id]/google/test/route.ts +++ b/web/app/api/properties/[id]/google/test/route.ts @@ -1,9 +1,6 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById } from '@/server/propertiesDb'; -import { getPipelineSpawnEnv, getRepoRoot } from '@/server/pipelineSpawnEnv'; -import { formatPythonSpawnError, resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -15,59 +12,5 @@ export const POST: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = parseInt(id, 10); - if (!Number.isFinite(propertyId)) { - return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - } - const row = await getPropertyById(propertyId); - if (!row) { - return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - } - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - const proc = spawn( - pythonExe, - ['-m', 'src', 'google', '--test', '--property-id', String(propertyId)], - { cwd: repoRoot, env: getPipelineSpawnEnv(repoRoot, propertyId), shell: false }, - ); - - proc.stdout?.on('data', (c: Buffer | string) => { - stdout += c.toString(); - }); - proc.stderr?.on('data', (c: Buffer | string) => { - stderr += c.toString(); - }); - - proc.on('error', (err: Error) => { - resolve( - NextResponse.json( - { error: formatPythonSpawnError(err, pythonExe, repoRoot) }, - { status: 500 }, - ), - ); - }); - - proc.on('close', (code: number | null) => { - const log = (stdout + stderr).trim(); - if (code === 0) { - resolve(NextResponse.json({ ok: true, log })); - } else { - resolve(NextResponse.json({ ok: false, log, exitCode: code }, { status: 400 })); - } - }); - - setTimeout(() => { - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve(NextResponse.json({ error: 'Timed out' }, { status: 504 })); - }, 60_000); - }); + return proxyToFastAPI(request, `/api/properties/${id}/google/test`); }; diff --git a/web/app/api/properties/[id]/ops/route.ts b/web/app/api/properties/[id]/ops/route.ts index 5d568eab..aec7ebc2 100644 --- a/web/app/api/properties/[id]/ops/route.ts +++ b/web/app/api/properties/[id]/ops/route.ts @@ -1,25 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById, setPropertyOpsSettings } from '@/server/propertiesDb'; import type { ApiRouteHandlerWithParams } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> }, ): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; const { id } = await params; - const propertyId = Number(id); - if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - const row = await getPropertyById(propertyId); - if (!row) return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - return NextResponse.json({ - schedule_cron: row.schedule_cron, - alert_webhook_url: row.alert_webhook_url, - alert_email: row.alert_email, - }); + return proxyToFastAPI(request, `/api/properties/${id}/ops`); }; export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( @@ -29,24 +22,5 @@ export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = Number(id); - if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - - let body: { - scheduleCron?: string | null; - alertWebhookUrl?: string | null; - alertEmail?: string | null; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - await setPropertyOpsSettings(propertyId, { - scheduleCron: body.scheduleCron, - alertWebhookUrl: body.alertWebhookUrl, - alertEmail: body.alertEmail, - }); - return NextResponse.json({ ok: true }); + return proxyToFastAPI(request, `/api/properties/${id}/ops`); }; diff --git a/web/app/api/properties/[id]/preset/route.ts b/web/app/api/properties/[id]/preset/route.ts index 278a94e5..1a01d521 100644 --- a/web/app/api/properties/[id]/preset/route.ts +++ b/web/app/api/properties/[id]/preset/route.ts @@ -1,22 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getPropertyById, setPropertyCrawlPreset } from '@/server/propertiesDb'; -import { isCrawlPresetId } from '@/lib/crawlPresets'; import type { ApiRouteHandlerWithParams } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> }, ): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; const { id } = await params; - const propertyId = Number(id); - if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - const row = await getPropertyById(propertyId); - if (!row) return NextResponse.json({ error: 'Property not found' }, { status: 404 }); - return NextResponse.json({ default_crawl_preset: row.default_crawl_preset }); + return proxyToFastAPI(request, `/api/properties/${id}/preset`); }; export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( @@ -26,19 +22,5 @@ export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( const denied = forbiddenIfNotLocal(request); if (denied) return denied; const { id } = await params; - const propertyId = Number(id); - if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); - - let body: { preset?: string }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - const preset = String(body.preset || '').trim(); - if (preset && !isCrawlPresetId(preset)) { - return NextResponse.json({ error: 'Invalid crawl preset' }, { status: 400 }); - } - await setPropertyCrawlPreset(propertyId, preset || null); - return NextResponse.json({ ok: true, default_crawl_preset: preset || null }); + return proxyToFastAPI(request, `/api/properties/${id}/preset`); }; diff --git a/web/app/api/properties/resolve/route.ts b/web/app/api/properties/resolve/route.ts index 4128f201..66432d19 100644 --- a/web/app/api/properties/resolve/route.ts +++ b/web/app/api/properties/resolve/route.ts @@ -1,34 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { - canonicalDomainFromStartUrl, - getPropertyByDomain, - resolvePropertyIdFromStartUrl, -} from '@/server/propertiesDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** GET /api/properties/resolve?startUrl=... — upsert property row and return id. */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const startUrl = request.nextUrl.searchParams.get('startUrl')?.trim() || ''; - if (!startUrl) { - return NextResponse.json({ error: 'startUrl required' }, { status: 400 }); - } - try { - const id = await resolvePropertyIdFromStartUrl(startUrl); - const domain = canonicalDomainFromStartUrl(startUrl); - const property = domain ? await getPropertyByDomain(domain) : null; - return NextResponse.json({ - id, - canonical_domain: domain, - default_crawl_preset: property?.default_crawl_preset ?? null, - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/properties/resolve'); }; diff --git a/web/app/api/properties/route.ts b/web/app/api/properties/route.ts index b4a1e721..574b1b7c 100644 --- a/web/app/api/properties/route.ts +++ b/web/app/api/properties/route.ts @@ -1,42 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { listProperties, upsertPropertyByDomain } from '@/server/propertiesDb'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - try { - const rows = await listProperties(); - return NextResponse.json({ properties: rows }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/properties'); }; export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - let body: { name?: string; canonical_domain?: string; site_url?: string }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - const name = String(body.name || '').trim(); - const domain = String(body.canonical_domain || '').trim().toLowerCase(); - if (!name || !domain) { - return NextResponse.json({ error: 'name and canonical_domain required' }, { status: 400 }); - } - try { - const id = await upsertPropertyByDomain(name, domain, body.site_url?.trim() || null); - return NextResponse.json({ id, name, canonical_domain: domain }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/properties'); }; diff --git a/web/app/api/report/audit-tool/route.ts b/web/app/api/report/audit-tool/route.ts index 769a04f6..c8affcd3 100644 --- a/web/app/api/report/audit-tool/route.ts +++ b/web/app/api/report/audit-tool/route.ts @@ -1,45 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { spawnAuditTool } from '@/server/spawnAuditTool'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * POST /api/report/audit-tool — dispatch allowlisted read-only audit tools for report UI. - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: { - toolName?: string; - propertyId?: number; - reportId?: number; - args?: Record; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const toolName = String(body.toolName || '').trim(); - const propertyId = Number(body.propertyId || 0); - if (!toolName || !propertyId) { - return NextResponse.json({ error: 'toolName and propertyId required' }, { status: 400 }); - } - - const result = await spawnAuditTool({ - toolName, - propertyId, - reportId: body.reportId, - args: body.args, - }); - - if (!result.ok) { - return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); - } - return NextResponse.json(result.data); + return proxyToFastAPI(request, '/api/report/audit-tool'); }; diff --git a/web/app/api/report/crawl-payload/route.ts b/web/app/api/report/crawl-payload/route.ts index 2b248f26..79ffdfc0 100644 --- a/web/app/api/report/crawl-payload/route.ts +++ b/web/app/api/report/crawl-payload/route.ts @@ -1,21 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { getCrawlPreviewPayload } from '@/server/reportDb'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const raw = request.nextUrl.searchParams.get('crawlRunId'); - const crawlRunId = raw != null && raw !== '' ? Number(raw) : null; - if (crawlRunId == null || !Number.isFinite(crawlRunId) || crawlRunId <= 0) { - return NextResponse.json({ error: 'Invalid crawlRunId' }, { status: 400 }); - } - try { - const payload = await getCrawlPreviewPayload(crawlRunId); - return NextResponse.json({ payload }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const status = msg === 'Crawl run not found' ? 404 : 500; - return NextResponse.json({ error: msg }, { status }); - } + return proxyToFastAPI(request, '/api/report/crawl-payload'); }; diff --git a/web/app/api/report/export-sitemap/route.ts b/web/app/api/report/export-sitemap/route.ts index 38a7c511..393882a0 100644 --- a/web/app/api/report/export-sitemap/route.ts +++ b/web/app/api/report/export-sitemap/route.ts @@ -1,63 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const REPO_ROOT = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); - -const SITEMAP_SCRIPT = ` -import sys -from website_profiling.db import db_session -from website_profiling.db.report_store import read_report_payload -from website_profiling.tools.export_sitemap import build_sitemap_xml - -rid = int(sys.argv[1]) if sys.argv[1] != 'latest' else None -with db_session() as conn: - payload = read_report_payload(conn, report_id=rid) -if not payload: - raise SystemExit('no report found') -print(build_sitemap_xml(payload), end='') -`; - export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const reportId = request.nextUrl.searchParams.get('reportId'); - const python = resolvePythonExecutable(process.env.PYTHON, REPO_ROOT); - const ridArg = reportId && /^\d+$/.test(reportId) ? reportId : 'latest'; - - return new Promise((resolve) => { - const proc = spawn(python, ['-c', SITEMAP_SCRIPT, ridArg], { - cwd: REPO_ROOT, - env: { ...process.env, PYTHONPATH: path.join(REPO_ROOT, 'src'), PYTHONIOENCODING: 'utf-8' }, - }); - let out = ''; - let err = ''; - proc.stdout.on('data', (c) => { out += c.toString(); }); - proc.stderr.on('data', (c) => { err += c.toString(); }); - proc.on('error', () => { - resolve(NextResponse.json({ error: 'Sitemap export failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - if (code !== 0) { - resolve(NextResponse.json({ error: 'Sitemap export failed' }, { status: 500 })); - return; - } - resolve( - new NextResponse(out, { - status: 200, - headers: { - 'Content-Type': 'application/xml', - 'Content-Disposition': 'attachment; filename="sitemap.xml"', - }, - }), - ); - }); - }); + return proxyToFastAPI(request, '/api/report/export-sitemap'); }; diff --git a/web/app/api/report/export-workbook/route.ts b/web/app/api/report/export-workbook/route.ts index 8c99adab..049a872d 100644 --- a/web/app/api/report/export-workbook/route.ts +++ b/web/app/api/report/export-workbook/route.ts @@ -1,72 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const REPO_ROOT = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); - -const WORKBOOK_SCRIPT = ` -import sys -from website_profiling.db import db_session -from website_profiling.db.report_store import read_report_payload -from website_profiling.tools.export_crawl_workbook import build_crawl_workbook_zip - -rid = int(sys.argv[1]) if sys.argv[1] != 'latest' else None -with db_session() as conn: - payload = read_report_payload(conn, report_id=rid) -if not payload: - raise SystemExit('no report found') -sys.stdout.buffer.write(build_crawl_workbook_zip(payload)) -`; - export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const reportId = request.nextUrl.searchParams.get('reportId'); - const python = resolvePythonExecutable(process.env.PYTHON, REPO_ROOT); - const ridArg = reportId && /^\d+$/.test(reportId) ? reportId : 'latest'; - - return new Promise((resolve) => { - const proc = spawn(python, ['-c', WORKBOOK_SCRIPT, ridArg], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHONPATH: path.join(REPO_ROOT, 'src'), - PYTHONIOENCODING: 'utf-8', - }, - }); - const chunks: Buffer[] = []; - let err = ''; - proc.stdout.on('data', (c: Buffer | string) => { - chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); - }); - proc.stderr.on('data', (c) => { - err += c.toString(); - }); - proc.on('error', () => { - resolve(NextResponse.json({ error: 'Workbook export failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - if (code !== 0) { - resolve(NextResponse.json({ error: 'Workbook export failed' }, { status: 500 })); - return; - } - const body = Buffer.concat(chunks); - resolve( - new NextResponse(body, { - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': 'attachment; filename="crawl-workbook.zip"', - }, - }), - ); - }); - }); + return proxyToFastAPI(request, '/api/report/export-workbook'); }; diff --git a/web/app/api/report/export/route.ts b/web/app/api/report/export/route.ts index f8155abd..9bb717e6 100644 --- a/web/app/api/report/export/route.ts +++ b/web/app/api/report/export/route.ts @@ -1,111 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { resolvePythonExecutable } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const REPO_ROOT = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); - -type ExportFormat = 'csv' | 'json' | 'html' | 'pdf'; - -const FORMATS: ExportFormat[] = ['csv', 'json', 'html', 'pdf']; - -function exportScript(format: ExportFormat): string { - const fn = - format === 'csv' - ? 'export_audit_csv' - : format === 'json' - ? 'export_audit_json' - : format === 'html' - ? 'export_audit_html' - : 'export_audit_pdf'; - if (format === 'pdf') { - return ` -import sys -from website_profiling.tools.export_audit import export_audit_pdf -rid = int(sys.argv[1]) if sys.argv[1] != 'latest' else None -sys.stdout.buffer.write(export_audit_pdf(rid)) -`; - } - return ` -import sys -from website_profiling.tools.export_audit import ${fn} -rid = int(sys.argv[1]) if sys.argv[1] != 'latest' else None -print(${fn}(rid), end='') -`; -} - export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const format = (request.nextUrl.searchParams.get('format') || 'csv').toLowerCase() as ExportFormat; - const reportId = request.nextUrl.searchParams.get('reportId'); - const dispositionParam = request.nextUrl.searchParams.get('disposition'); - if (!FORMATS.includes(format)) { - return NextResponse.json( - { error: 'format must be csv, json, html, or pdf' }, - { status: 400 }, - ); - } - - const python = resolvePythonExecutable(process.env.PYTHON, REPO_ROOT); - const script = exportScript(format); - const ridArg = reportId && /^\d+$/.test(reportId) ? reportId : 'latest'; - const isBinary = format === 'pdf'; - - return new Promise((resolve) => { - const proc = spawn(python, ['-c', script, ridArg], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHONPATH: path.join(REPO_ROOT, 'src'), - PYTHONIOENCODING: 'utf-8', - }, - }); - const chunks: Buffer[] = []; - let err = ''; - proc.stdout.on('data', (c: Buffer | string) => { - chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); - }); - proc.stderr.on('data', (c) => { - err += c.toString(); - }); - proc.on('error', () => { - resolve(NextResponse.json({ error: 'Export failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - if (code !== 0) { - resolve(NextResponse.json({ error: 'Export failed' }, { status: 500 })); - return; - } - const body = Buffer.concat(chunks); - const inline = dispositionParam === 'inline' || format === 'html'; - const disposition = inline ? 'inline' : 'attachment'; - const filenames: Record = { - csv: 'audit-export.csv', - json: 'audit-export.json', - html: 'audit-export.html', - pdf: 'audit-export.pdf', - }; - const contentTypes: Record = { - csv: 'text/csv; charset=utf-8', - json: 'application/json; charset=utf-8', - html: 'text/html; charset=utf-8', - pdf: 'application/pdf', - }; - resolve( - new NextResponse(isBinary ? body : body.toString('utf-8'), { - headers: { - 'Content-Type': contentTypes[format], - 'Content-Disposition': `${disposition}; filename="${filenames[format]}"`, - }, - }), - ); - }); - }); + return proxyToFastAPI(request, '/api/report/export'); }; diff --git a/web/app/api/report/history/route.ts b/web/app/api/report/history/route.ts index 2636e4b2..b0eef82d 100644 --- a/web/app/api/report/history/route.ts +++ b/web/app/api/report/history/route.ts @@ -1,23 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { listAuditHistory } from '@/server/auditHistoryDb'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; -/** - * GET /api/report/history?propertyId=&domain=&limit= - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const sp = request.nextUrl.searchParams; - const propertyId = Number(sp.get('propertyId') || '0') || null; - const domain = sp.get('domain')?.trim() || null; - const limit = Number(sp.get('limit') || '20') || 20; - - try { - const history = await listAuditHistory(propertyId, domain, limit); - return NextResponse.json({ history }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg, history: [] }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/report/history'); }; diff --git a/web/app/api/report/meta/route.ts b/web/app/api/report/meta/route.ts index 90512435..1807e0eb 100644 --- a/web/app/api/report/meta/route.ts +++ b/web/app/api/report/meta/route.ts @@ -1,15 +1,9 @@ -import { NextResponse } from 'next/server'; -import { getReportMeta } from '@/server/reportDb'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; -export const GET: ApiRouteHandler = async (): Promise => { - try { - const data = await getReportMeta(); - return NextResponse.json(data); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + return proxyToFastAPI(request, '/api/report/meta'); }; diff --git a/web/app/api/report/mobile-delta/route.ts b/web/app/api/report/mobile-delta/route.ts index f1ac0a16..b586c136 100644 --- a/web/app/api/report/mobile-delta/route.ts +++ b/web/app/api/report/mobile-delta/route.ts @@ -1,21 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { getMobileDesktopDelta } from '@/server/mobileDeltaDb'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; -/** - * GET /api/report/mobile-delta?id= - */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const id = Number(request.nextUrl.searchParams.get('id') || '0'); - if (!id) return NextResponse.json({ error: 'id required', deltas: [] }, { status: 400 }); - - try { - const deltas = await getMobileDesktopDelta(id); - return NextResponse.json({ deltas }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg, deltas: [] }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/report/mobile-delta'); }; diff --git a/web/app/api/report/payload/route.ts b/web/app/api/report/payload/route.ts index d330531d..38077422 100644 --- a/web/app/api/report/payload/route.ts +++ b/web/app/api/report/payload/route.ts @@ -1,41 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { withReportDb } from '@/server/reportDb'; -import { readReportPayloadFromDatabase, readReportSectionFromDatabase } from '@/lib/loadReportDb'; -import { SECTION_KEYS, type SectionKey } from '@/lib/reportSections'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const raw = request.nextUrl.searchParams.get('reportId'); - const reportId = raw != null && raw !== '' ? Number(raw) : null; - const domain = request.nextUrl.searchParams.get('domain'); - const sectionParam = request.nextUrl.searchParams.get('section'); - - if (raw != null && raw !== '' && !Number.isFinite(reportId)) { - return NextResponse.json({ error: 'Invalid reportId' }, { status: 400 }); - } - - if (sectionParam != null && !(SECTION_KEYS as ReadonlyArray).includes(sectionParam)) { - return NextResponse.json({ error: 'Invalid section' }, { status: 400 }); - } - - try { - if (sectionParam != null) { - const section = sectionParam as SectionKey; - const payload = await withReportDb((db) => - readReportSectionFromDatabase(db, section, reportId, domain), - ); - return NextResponse.json({ payload, section }); - } - - const payload = await withReportDb((db) => - readReportPayloadFromDatabase(db, reportId, domain), - ); - return NextResponse.json({ payload }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const status = msg === 'Report not found' ? 404 : msg.includes('not found') ? 404 : 500; - return NextResponse.json({ error: msg }, { status }); - } + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + return proxyToFastAPI(request, '/api/report/payload'); }; diff --git a/web/app/api/report/portfolio/route.ts b/web/app/api/report/portfolio/route.ts index 7218a51e..25b34958 100644 --- a/web/app/api/report/portfolio/route.ts +++ b/web/app/api/report/portfolio/route.ts @@ -1,141 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { withReportDb } from '@/server/reportDb'; -import { - listReportsFromDatabase, - readReportPayloadFromDatabase, - readReportSectionFromDatabase, - getCrawlRunsRows, - getCrawlRunSummaries, -} from '@/lib/loadReportDb'; -import { - computeDomainGroups, - computeCrawlOnlyGroups, - computePortfolioSummary, - mergePortfolioGroups, - buildPortfolioCard, -} from '@/lib/homePortfolio'; -import { buildCrawlHistoryByDomain } from '@/lib/portfolioCrawlHistory'; -import { strings } from '@/lib/strings'; +/** + * GET /api/report/portfolio — portfolio groups and crawl history via FastAPI. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import type { ApiRouteHandler } from '@/types/api'; -import type { StringsCatalog } from '@/types/strings'; -import type { PoolClient } from 'pg'; export const dynamic = 'force-dynamic'; -const catalog = strings as StringsCatalog; - -const WIDGETS = ['full', 'groups', 'card', 'summary'] as const; -type PortfolioWidget = (typeof WIDGETS)[number]; - -async function loadPortfolioMaps(client: PoolClient) { - const crawlRows = await getCrawlRunsRows(client); - const startUrlByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.start_url])); - const runCreatedAtByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.created_at])); - const runMetaByRunId = new Map( - crawlRows.map((cr) => [ - cr.id, - { render_mode: cr.render_mode, discovery_mode: cr.discovery_mode }, - ]), - ); - const crawlSummaries = await getCrawlRunSummaries(client); - return { startUrlByRunId, runCreatedAtByRunId, runMetaByRunId, crawlSummaries }; -} - -async function buildGroupsBundle( - client: PoolClient, - reportList: Awaited>, - lite: boolean, -) { - const { startUrlByRunId, runCreatedAtByRunId, runMetaByRunId, crawlSummaries } = - await loadPortfolioMaps(client); - const unknownBrand = catalog.views.home.unknownBrand; - const emDash = catalog.common.emDash; - const getPayload = lite - ? (id: number) => readReportSectionFromDatabase(client, 'core', id) - : (id: number) => readReportPayloadFromDatabase(client, id); - - const reportGroups = await computeDomainGroups( - reportList, - startUrlByRunId, - runCreatedAtByRunId, - unknownBrand, - emDash, - getPayload, - runMetaByRunId, - ); - const crawlOnlyGroups = computeCrawlOnlyGroups( - crawlSummaries, - reportGroups, - unknownBrand, - emDash, - ); - const groups = mergePortfolioGroups(reportGroups, crawlOnlyGroups); - const crawlHistoryByDomain = buildCrawlHistoryByDomain(crawlSummaries); - return { groups, crawlHistoryByDomain, crawlSummaries, startUrlByRunId, runCreatedAtByRunId, runMetaByRunId }; -} - export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { - const idsParam = request.nextUrl.searchParams.get('ids'); - const ids = idsParam - ? idsParam - .split(',') - .map((s: string) => Number(String(s).trim())) - .filter((n: number) => Number.isFinite(n) && n > 0) - : []; - - const widgetParam = request.nextUrl.searchParams.get('widget') || 'full'; - if (!WIDGETS.includes(widgetParam as PortfolioWidget)) { - return NextResponse.json({ error: 'Invalid widget' }, { status: 400 }); - } - const widget = widgetParam as PortfolioWidget; - - const reportIdParam = request.nextUrl.searchParams.get('reportId'); - const crawlRunIdParam = request.nextUrl.searchParams.get('crawlRunId'); - const reportId = - reportIdParam != null && reportIdParam !== '' ? Number(reportIdParam) : undefined; - const crawlRunId = - crawlRunIdParam != null && crawlRunIdParam !== '' ? Number(crawlRunIdParam) : undefined; - - if (widget === 'card' && reportId == null && crawlRunId == null) { - return NextResponse.json({ error: 'reportId or crawlRunId required for card widget' }, { status: 400 }); - } - - try { - const result = await withReportDb(async (client) => { - const all = await listReportsFromDatabase(client); - const idSet = new Set(ids); - const reportList = ids.length ? all.filter((r) => idSet.has(r.id)) : all; - - if (widget === 'card') { - const maps = await loadPortfolioMaps(client); - const group = await buildPortfolioCard( - reportList, - maps.startUrlByRunId, - maps.runCreatedAtByRunId, - maps.runMetaByRunId, - maps.crawlSummaries, - catalog.views.home.unknownBrand, - catalog.common.emDash, - (id: number) => readReportPayloadFromDatabase(client, id), - { reportId, crawlRunId }, - ); - if (!group) return { group: null }; - return { group }; - } - - const lite = widget === 'groups' || widget === 'summary'; - const bundle = await buildGroupsBundle(client, reportList, lite); - - if (widget === 'summary') { - return { ...computePortfolioSummary(bundle.groups) }; - } - - return { groups: bundle.groups, crawlHistoryByDomain: bundle.crawlHistoryByDomain }; - }); - - return NextResponse.json(result); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg, groups: [], crawlHistoryByDomain: {} }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/report/portfolio'); }; diff --git a/web/app/api/run/route.ts b/web/app/api/run/route.ts index 7ca04908..87801773 100644 --- a/web/app/api/run/route.ts +++ b/web/app/api/run/route.ts @@ -1,173 +1,19 @@ -import { NextResponse, type NextRequest } from 'next/server'; +/** + * POST /api/run — enqueue a pipeline job via FastAPI. + * FastAPI validates config, saves it, and enqueues to the Python worker. + */ +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { startPipelineJobAsync } from '@/server/pipelineJobs'; -import { logPipelineDbError } from '@/lib/pipelineDebug'; import { requireApiAuth } from '@/server/auth'; -import { writeAuditLog } from '@/server/pipelineJobsDb'; -import { loadPipelineConfig, savePipelineConfig } from '@/server/pipelineConfig'; -import { saveLlmConfig } from '@/server/llmConfig'; -import { ALL_LLM_SCHEMA_KEYS, getLlmFieldByKey } from '@/lib/llmConfigSchema'; -import { - ALL_SCHEMA_KEYS, - INTERNAL_PIPELINE_KEYS, - getFieldByKey, - validatePipelineRun, -} from '@/lib/pipelineConfigSchema'; -import { resolvePropertyIdFromStartUrl } from '@/server/propertiesDb'; -import type { - ApiRouteHandler, - LlmConfigState, - PipelineConfigState, - PipelineUnknownKey, - RunPostBody, -} from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; -function isUnknownKeyEntry(value: unknown): value is PipelineUnknownKey { - return ( - value != null && - typeof value === 'object' && - typeof (value as PipelineUnknownKey).key === 'string' && - typeof (value as PipelineUnknownKey).value === 'string' - ); -} - -/** - * POST /api/run - * Body: { command?: string, state: Record, - * unknownKeys?: Array<{key,value}>, python?: string, repoRoot?: string } - * - * Saves state to PostgreSQL (pipeline_config table) + shadow pipeline-config.txt, - * then spawns `python -m src` (Python reads config via DATABASE_URL). - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; const authDenied = requireApiAuth(request); if (authDenied) return authDenied; - - let body: RunPostBody; - try { - body = (await request.json().catch(() => ({}))) as RunPostBody; - } catch { - body = {}; - } - - const { command = null, state: rawState, unknownKeys = [], llmState: rawLlmState, python, repoRoot } = body; - - let resolvedState = rawState; - let resolvedUnknownKeys = unknownKeys; - - if (!resolvedState || typeof resolvedState !== 'object') { - // Integrations "Fetch data now" and similar callers may omit state — use saved config. - try { - const loaded = await loadPipelineConfig(); - resolvedState = loaded.state; - resolvedUnknownKeys = loaded.unknownKeys; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: `Missing state object and could not load config: ${msg}` }, { status: 400 }); - } - } - - if (!resolvedState || typeof resolvedState !== 'object') { - return NextResponse.json({ error: 'Missing state object' }, { status: 400 }); - } - - const internalKeySet = new Set(INTERNAL_PIPELINE_KEYS); - - // Coerce state per field type - const state: PipelineConfigState = {}; - for (const [key, rawValue] of Object.entries(resolvedState)) { - if (key.startsWith('llm_')) continue; - if (!ALL_SCHEMA_KEYS.has(key)) continue; - const field = getFieldByKey(key); - if (field) { - if (field.type === 'bool') { - state[key] = rawValue === true || rawValue === 'true'; - } else if (field.type === 'tristate') { - const s = String(rawValue ?? 'auto').toLowerCase(); - if (s === 'true') state[key] = 'true'; - else if (s === 'false') state[key] = 'false'; - else state[key] = 'auto'; - } else { - state[key] = rawValue == null ? '' : String(rawValue); - } - continue; - } - if (internalKeySet.has(key)) { - state[key] = rawValue == null ? '' : String(rawValue); - } - } - - const startUrl = String(state.start_url || '').trim(); - let resolvedPropertyId: number | null = - body.propertyId != null && Number.isFinite(body.propertyId) ? body.propertyId : null; - if (startUrl) { - resolvedPropertyId = await resolvePropertyIdFromStartUrl(startUrl); - state.active_property_id = String(resolvedPropertyId); - } - - const safeUnknownKeys: PipelineUnknownKey[] = Array.isArray(resolvedUnknownKeys) - ? resolvedUnknownKeys.filter( - (u) => - isUnknownKeyEntry(u) && - !u.key.startsWith('llm_') && - !u.key.startsWith('ml_'), - ) - : []; - - const validationErrors = validatePipelineRun({ state, command: command || null }); - if (validationErrors.length > 0) { - return NextResponse.json({ error: validationErrors.join(' ') }, { status: 400 }); - } - - try { - await savePipelineConfig(state, { unknownKeys: safeUnknownKeys }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: `Failed to save config: ${msg}` }, { status: 500 }); - } - - if (rawLlmState && typeof rawLlmState === 'object') { - const llmCoerced: LlmConfigState = {}; - for (const [key, rawValue] of Object.entries(rawLlmState)) { - if (!ALL_LLM_SCHEMA_KEYS.has(key)) continue; - if (key.endsWith('_masked')) continue; - const field = getLlmFieldByKey(key); - if (!field) continue; - if (field.type === 'bool') { - llmCoerced[key] = rawValue === true || rawValue === 'true'; - } else { - llmCoerced[key] = rawValue == null ? '' : String(rawValue); - if (rawLlmState[`${key}_masked`] === true) { - llmCoerced[`${key}_masked`] = true; - } - } - } - try { - await saveLlmConfig(llmCoerced); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: `Failed to save LLM config: ${msg}` }, { status: 500 }); - } - } - - try { - const id = await startPipelineJobAsync(command ?? null, null, { - python, - repoRoot, - propertyId: resolvedPropertyId, - }); - void writeAuditLog('audit_run_started', null, resolvedPropertyId, { - command: command ?? null, - jobId: id, - }).catch((err) => logPipelineDbError('writeAuditLog', err)); - return NextResponse.json({ jobId: id }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isSlotTaken = msg === 'An audit job is already running'; - return NextResponse.json({ error: msg }, { status: isSlotTaken ? 400 : 500 }); - } + return proxyToFastAPI(request, '/api/run'); }; diff --git a/web/app/api/schedule/check/route.ts b/web/app/api/schedule/check/route.ts index 58be5f91..7cf4d2b3 100644 --- a/web/app/api/schedule/check/route.ts +++ b/web/app/api/schedule/check/route.ts @@ -1,67 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { spawn } from 'child_process'; -import path from 'path'; -import { resolvePythonExecutable, formatPythonSpawnError } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** - * POST /api/schedule/check — run due scheduled audits (calls Python schedule_runner). - */ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - const repoRoot = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); - const pythonExe = resolvePythonExecutable(null, repoRoot); - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-m', 'src.website_profiling.tools.schedule_runner'], { - cwd: repoRoot, - shell: false, - }); - let out = ''; - proc.stdout?.on('data', (c) => { out += c.toString(); }); - proc.stderr?.on('data', (c) => { out += c.toString(); }); - proc.on('error', (err: Error) => { - resolve(NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 })); - }); - proc.on('close', (code) => { - const staleProc = spawn( - pythonExe, - [ - '-c', - 'from website_profiling.tools.schedule_runner import run_gsc_links_staleness_alerts; import json; print(json.dumps(run_gsc_links_staleness_alerts()))', - ], - { cwd: repoRoot, shell: false }, - ); - let staleOut = ''; - staleProc.stdout?.on('data', (c) => { staleOut += c.toString(); }); - staleProc.on('error', () => { - // Secondary staleness enrichment failed to spawn — degrade gracefully - // rather than hang, returning the primary result with an empty list. - resolve( - NextResponse.json( - { ok: code === 0, output: out.trim(), gscLinksStale: [] }, - { status: code === 0 ? 200 : 500 }, - ), - ); - }); - staleProc.on('close', () => { - let stale: unknown[] = []; - try { - stale = JSON.parse(staleOut.trim() || '[]'); - } catch { - stale = []; - } - resolve( - NextResponse.json( - { ok: code === 0, output: out.trim(), gscLinksStale: stale }, - { status: code === 0 ? 200 : 500 }, - ), - ); - }); - }); - }); + return proxyToFastAPI(request, '/api/schedule/check'); }; diff --git a/web/app/api/secrets/route.ts b/web/app/api/secrets/route.ts index 495e7435..68967b95 100644 --- a/web/app/api/secrets/route.ts +++ b/web/app/api/secrets/route.ts @@ -1,63 +1,18 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; +import { proxyToFastAPI } from '@/server/proxyToFastAPI'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { loadSecrets, saveSecrets } from '@/server/secrets'; -import { ALL_SECRETS_KEYS } from '@/lib/secretsConfigSchema'; -import type { ApiRouteHandler, SecretsPutBody, SecretsState } from '@/types/api'; +import type { ApiRouteHandler } from '@/types/api'; -export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -/** GET /api/secrets — aggregated credentials (masked). */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - try { - const result = await loadSecrets(); - return NextResponse.json(result); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/secrets'); }; -/** PUT /api/secrets — Body: { state: SecretsState } */ export const PUT: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - - let body: SecretsPutBody; - try { - body = (await request.json()) as SecretsPutBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { state: rawState } = body; - if (!rawState || typeof rawState !== 'object') { - return NextResponse.json({ error: 'Missing state object' }, { status: 400 }); - } - - const state: SecretsState = {}; - for (const [key, rawValue] of Object.entries(rawState)) { - if (!ALL_SECRETS_KEYS.has(key) && !key.endsWith('_masked') && key !== 'google_has_service_account') { - continue; - } - if (key.endsWith('_masked') || key === 'google_has_service_account') { - state[key] = rawValue === true; - continue; - } - state[key] = rawValue == null ? '' : String(rawValue); - if (rawState[`${key}_masked`] === true) { - state[`${key}_masked`] = true; - } - } - - try { - await saveSecrets(state); - const result = await loadSecrets(); - return NextResponse.json({ ok: true, ...result }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return NextResponse.json({ error: msg }, { status: 500 }); - } + return proxyToFastAPI(request, '/api/secrets'); }; diff --git a/web/openapi.json b/web/openapi.json new file mode 100644 index 00000000..fd126edf --- /dev/null +++ b/web/openapi.json @@ -0,0 +1,6061 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Website Profiling API", + "version": "1.0.0" + }, + "paths": { + "/api/health": { + "get": { + "tags": [ + "health" + ], + "summary": "Health Check", + "operationId": "health_check_api_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Health Check Api Health Get" + } + } + } + } + } + } + }, + "/api/report/meta": { + "get": { + "tags": [ + "report" + ], + "summary": "Report Meta", + "operationId": "report_meta_api_report_meta_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Report Meta Api Report Meta Get" + } + } + } + } + } + } + }, + "/api/report/payload": { + "get": { + "tags": [ + "report" + ], + "summary": "Report Payload", + "operationId": "report_payload_api_report_payload_get", + "parameters": [ + { + "name": "reportId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + }, + { + "name": "section", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Section" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Report Payload Api Report Payload Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/history": { + "get": { + "tags": [ + "report" + ], + "summary": "Report History", + "operationId": "report_history_api_report_history_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 20, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Report History Api Report History Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/crawl-payload": { + "get": { + "tags": [ + "report" + ], + "summary": "Crawl Payload", + "operationId": "crawl_payload_api_report_crawl_payload_get", + "parameters": [ + { + "name": "crawlRunId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Crawlrunid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Crawl Payload Api Report Crawl Payload Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/mobile-delta": { + "get": { + "tags": [ + "report" + ], + "summary": "Mobile Delta", + "operationId": "mobile_delta_api_report_mobile_delta_get", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Mobile Delta Api Report Mobile Delta Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/run": { + "post": { + "tags": [ + "pipeline" + ], + "summary": "Run Pipeline", + "operationId": "run_pipeline_api_run_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunPostBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/jobs": { + "get": { + "tags": [ + "pipeline" + ], + "summary": "List Pipeline Jobs", + "operationId": "list_pipeline_jobs_api_jobs_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 20, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobsListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/jobs/{job_id}": { + "get": { + "tags": [ + "pipeline" + ], + "summary": "Get Pipeline Job", + "operationId": "get_pipeline_job_api_jobs__job_id__get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Pipeline Job Api Jobs Job Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/jobs/{job_id}/cancel": { + "post": { + "tags": [ + "pipeline" + ], + "summary": "Cancel Pipeline Job", + "operationId": "cancel_pipeline_job_api_jobs__job_id__cancel_post", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/jobs/{job_id}/pause": { + "post": { + "tags": [ + "pipeline" + ], + "summary": "Pause Pipeline Job", + "operationId": "pause_pipeline_job_api_jobs__job_id__pause_post", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/jobs/{job_id}/resume": { + "post": { + "tags": [ + "pipeline" + ], + "summary": "Resume Pipeline Job", + "operationId": "resume_pipeline_job_api_jobs__job_id__resume_post", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/chat/": { + "post": { + "tags": [ + "chat" + ], + "summary": "Chat Turn", + "operationId": "chat_turn_api_chat__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/chat/sessions": { + "get": { + "tags": [ + "chat" + ], + "summary": "List Sessions", + "operationId": "list_sessions_api_chat_sessions_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Sessions Api Chat Sessions Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "chat" + ], + "summary": "Create Session", + "operationId": "create_session_api_chat_sessions_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatSessionCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Create Session Api Chat Sessions Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/chat/sessions/{session_id}": { + "get": { + "tags": [ + "chat" + ], + "summary": "Get Session Route", + "operationId": "get_session_route_api_chat_sessions__session_id__get", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Session Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Session Route Api Chat Sessions Session Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "chat" + ], + "summary": "Delete Session Route", + "operationId": "delete_session_route_api_chat_sessions__session_id__delete", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Session Id" + } + }, + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Session Route Api Chat Sessions Session Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/chat/sessions/{session_id}/messages": { + "get": { + "tags": [ + "chat" + ], + "summary": "Get Session Messages", + "operationId": "get_session_messages_api_chat_sessions__session_id__messages_get", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Session Id" + } + }, + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Session Messages Api Chat Sessions Session Id Messages Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/chat/artifacts/{artifact_id}": { + "get": { + "tags": [ + "chat" + ], + "summary": "Get Artifact", + "operationId": "get_artifact_api_chat_artifacts__artifact_id__get", + "parameters": [ + { + "name": "artifact_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Artifact Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Get Artifact Api Chat Artifacts Artifact Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/crawl/browser-status": { + "get": { + "tags": [ + "crawl" + ], + "summary": "Browser Status Check", + "description": "Return whether Playwright + Chromium are available.", + "operationId": "browser_status_check_api_crawl_browser_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Browser Status Check Api Crawl Browser Status Get" + } + } + } + } + } + } + }, + "/api/crawl/page-html": { + "get": { + "tags": [ + "crawl" + ], + "summary": "Get Page Html", + "description": "Return stored HTML and metadata for a URL within a crawl run.", + "operationId": "get_page_html_api_crawl_page_html_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Page URL to retrieve stored HTML for", + "title": "Url" + }, + "description": "Page URL to retrieve stored HTML for" + }, + { + "name": "crawlRunId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Crawl run ID", + "title": "Crawlrunid" + }, + "description": "Crawl run ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Page Html Api Crawl Page Html Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/pipeline-config": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Pipeline Config", + "operationId": "get_pipeline_config_api_pipeline_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Get Pipeline Config Api Pipeline Config Get" + } + } + } + } + } + }, + "put": { + "tags": [ + "config" + ], + "summary": "Put Pipeline Config", + "operationId": "put_pipeline_config_api_pipeline_config_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineConfigBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Put Pipeline Config Api Pipeline Config Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/llm-config": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Llm Config", + "operationId": "get_llm_config_api_llm_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Get Llm Config Api Llm Config Get" + } + } + } + } + } + }, + "put": { + "tags": [ + "config" + ], + "summary": "Put Llm Config", + "operationId": "put_llm_config_api_llm_config_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LlmConfigBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Put Llm Config Api Llm Config Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/secrets": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Secrets", + "operationId": "get_secrets_api_secrets_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Get Secrets Api Secrets Get" + } + } + } + } + } + }, + "put": { + "tags": [ + "config" + ], + "summary": "Put Secrets", + "operationId": "put_secrets_api_secrets_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretsBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Put Secrets Api Secrets Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/app-settings": { + "get": { + "tags": [ + "config" + ], + "summary": "Get App Setting", + "operationId": "get_app_setting_api_app_settings_get", + "parameters": [ + { + "name": "key", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Settings key to retrieve", + "title": "Key" + }, + "description": "Settings key to retrieve" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get App Setting Api App Settings Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "config" + ], + "summary": "Put App Setting", + "operationId": "put_app_setting_api_app_settings_put", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppSettingBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Put App Setting Api App Settings Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties": { + "get": { + "tags": [ + "properties" + ], + "summary": "List Properties", + "operationId": "list_properties_api_properties_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response List Properties Api Properties Get" + } + } + } + } + } + }, + "post": { + "tags": [ + "properties" + ], + "summary": "Create Property", + "operationId": "create_property_api_properties_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyUpsertBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Create Property Api Properties Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/resolve": { + "get": { + "tags": [ + "properties" + ], + "summary": "Resolve Property", + "operationId": "resolve_property_api_properties_resolve_get", + "parameters": [ + { + "name": "startUrl", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Start URL to resolve a property from", + "title": "Starturl" + }, + "description": "Start URL to resolve a property from" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Resolve Property Api Properties Resolve Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}": { + "get": { + "tags": [ + "properties" + ], + "summary": "Get Property", + "operationId": "get_property_api_properties__property_id__get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Property Api Properties Property Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "properties" + ], + "summary": "Delete Property", + "operationId": "delete_property_api_properties__property_id__delete", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Property Api Properties Property Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/ops": { + "get": { + "tags": [ + "properties" + ], + "summary": "Get Property Ops", + "operationId": "get_property_ops_api_properties__property_id__ops_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Property Ops Api Properties Property Id Ops Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "properties" + ], + "summary": "Update Property Ops", + "operationId": "update_property_ops_api_properties__property_id__ops_put", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpsSettingsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Property Ops Api Properties Property Id Ops Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/preset": { + "get": { + "tags": [ + "properties" + ], + "summary": "Get Property Preset", + "operationId": "get_property_preset_api_properties__property_id__preset_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Property Preset Api Properties Property Id Preset Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "properties" + ], + "summary": "Update Property Preset", + "operationId": "update_property_preset_api_properties__property_id__preset_put", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresetBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Property Preset Api Properties Property Id Preset Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/authorize": { + "post": { + "tags": [ + "properties" + ], + "summary": "Authorize Property Crawl", + "description": "Mark property as crawl-authorized (used by OAuth flow).", + "operationId": "authorize_property_crawl_api_properties__property_id__authorize_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Authorize Property Crawl Api Properties Property Id Authorize Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/status": { + "get": { + "tags": [ + "properties" + ], + "summary": "Property Google Status", + "description": "Return property-level Google integration status.", + "operationId": "property_google_status_api_properties__property_id__google_status_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Property Google Status Api Properties Property Id Google Status Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/test": { + "post": { + "tags": [ + "properties" + ], + "summary": "Property Google Test", + "description": "Run a quick Google API connectivity test for the property.", + "operationId": "property_google_test_api_properties__property_id__google_test_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Property Google Test Api Properties Property Id Google Test Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/properties": { + "get": { + "tags": [ + "properties" + ], + "summary": "Property Google Properties", + "description": "List GA4 / GSC properties available for this account.", + "operationId": "property_google_properties_api_properties__property_id__google_properties_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Property Google Properties Api Properties Property Id Google Properties Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/links/status": { + "get": { + "tags": [ + "properties" + ], + "summary": "Property Google Links Status", + "description": "Return the status of GSC backlinks import for this property.", + "operationId": "property_google_links_status_api_properties__property_id__google_links_status_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Property Google Links Status Api Properties Property Id Google Links Status Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/links/import": { + "post": { + "tags": [ + "properties" + ], + "summary": "Property Google Links Import", + "description": "Trigger a GSC backlinks import for this property.", + "operationId": "property_google_links_import_api_properties__property_id__google_links_import_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Property Google Links Import Api Properties Property Id Google Links Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/credentials": { + "patch": { + "tags": [ + "properties" + ], + "summary": "Patch Property Google Credentials", + "description": "Update Google credentials/settings for a property (used by OAuth callback).", + "operationId": "patch_property_google_credentials_api_properties__property_id__google_credentials_patch", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleCredentialsPatch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Patch Property Google Credentials Api Properties Property Id Google Credentials Patch" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "properties" + ], + "summary": "Post Property Google Credentials", + "description": "Update Google site/property settings from the integrations UI.", + "operationId": "post_property_google_credentials_api_properties__property_id__google_credentials_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleCredentialsPostBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Post Property Google Credentials Api Properties Property Id Google Credentials Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/properties/{property_id}/google/disconnect": { + "post": { + "tags": [ + "properties" + ], + "summary": "Post Property Google Disconnect", + "description": "Clear OAuth tokens for a property.", + "operationId": "post_property_google_disconnect_api_properties__property_id__google_disconnect_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Post Property Google Disconnect Api Properties Property Id Google Disconnect Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/dashboards": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "List Dashboards", + "operationId": "list_dashboards_api_dashboards_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Dashboards Api Dashboards Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "dashboards" + ], + "summary": "Create Dashboard", + "operationId": "create_dashboard_api_dashboards_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardCreateBody" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Create Dashboard Api Dashboards Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/dashboards/{dashboard_id}": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "Get Dashboard", + "operationId": "get_dashboard_api_dashboards__dashboard_id__get", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Dashboard Id" + } + }, + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Dashboard Api Dashboards Dashboard Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "dashboards" + ], + "summary": "Update Dashboard", + "operationId": "update_dashboard_api_dashboards__dashboard_id__put", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Dashboard Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardUpdateBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Dashboard Api Dashboards Dashboard Id Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "dashboards" + ], + "summary": "Delete Dashboard", + "operationId": "delete_dashboard_api_dashboards__dashboard_id__delete", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Dashboard Id" + } + }, + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Dashboard Api Dashboards Dashboard Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/dashboards/ai-generate": { + "post": { + "tags": [ + "dashboards" + ], + "summary": "Dashboards Ai Generate", + "description": "Generate DashScript, a widget, or a full dashboard via LLM.", + "operationId": "dashboards_ai_generate_api_dashboards_ai_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardAiGenerateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/filters": { + "get": { + "tags": [ + "filters" + ], + "summary": "List Filters", + "operationId": "list_filters_api_filters_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Filters Api Filters Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "filters" + ], + "summary": "Upsert Filter", + "operationId": "upsert_filter_api_filters_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterUpsertBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Upsert Filter Api Filters Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "filters" + ], + "summary": "Delete Filter", + "operationId": "delete_filter_api_filters_delete", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterDeleteBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Filter Api Filters Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/status": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Status", + "operationId": "google_status_api_integrations_google_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Status Api Integrations Google Status Get" + } + } + } + } + } + } + }, + "/api/integrations/google/credentials": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Save Google Credentials", + "operationId": "save_google_credentials_api_integrations_google_credentials_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Save Google Credentials Api Integrations Google Credentials Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/credentials/upload": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Upload Google Credentials", + "operationId": "upload_google_credentials_api_integrations_google_credentials_upload_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Upload Google Credentials Api Integrations Google Credentials Upload Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/disconnect": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Google Disconnect", + "description": "Global disconnect is deprecated \u2014 use per-property disconnect.", + "operationId": "google_disconnect_api_integrations_google_disconnect_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Disconnect Api Integrations Google Disconnect Post" + } + } + } + } + } + } + }, + "/api/integrations/google/properties": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Properties Deprecated", + "description": "Deprecated \u2014 use /api/properties/{id}/google/properties.", + "operationId": "google_properties_deprecated_api_integrations_google_properties_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Properties Deprecated Api Integrations Google Properties Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/test": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Google Test", + "description": "Run `python -m src google --test` and return stdout log.", + "operationId": "google_test_api_integrations_google_test_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Test Api Integrations Google Test Post" + } + } + } + } + } + } + }, + "/api/integrations/google/page-data": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Page Data", + "operationId": "google_page_data_api_integrations_google_page_data_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "googleSnapshotId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Googlesnapshotid" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Page Data Api Integrations Google Page Data Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/page-data/history": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Page Data History", + "operationId": "google_page_data_history_api_integrations_google_page_data_history_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Page Data History Api Integrations Google Page Data History Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/page-live": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Google Page Live", + "operationId": "google_page_live_api_integrations_google_page_live_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Page Live Api Integrations Google Page Live Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/keywords/by-page": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Keywords By Page", + "operationId": "google_keywords_by_page_api_integrations_google_keywords_by_page_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Keywords By Page Api Integrations Google Keywords By Page Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/keywords/history": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Keywords History", + "operationId": "google_keywords_history_api_integrations_google_keywords_history_get", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Keyword" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 90, + "minimum": 1, + "default": 30, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Keywords History Api Integrations Google Keywords History Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/bing/sync": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Bing Sync", + "description": "Fetch Bing Webmaster backlinks summary using config from DB.", + "operationId": "bing_sync_api_integrations_bing_sync_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Bing Sync Api Integrations Bing Sync Post" + } + } + } + } + } + } + }, + "/api/integrations/google/page-compare": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Page Compare", + "description": "Compare two page Google data snapshots.", + "operationId": "google_page_compare_api_integrations_google_page_compare_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "currentType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "snapshot", + "title": "Currenttype" + } + }, + { + "name": "currentId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Currentid" + } + }, + { + "name": "baselineType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "snapshot", + "title": "Baselinetype" + } + }, + { + "name": "baselineId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Baselineid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Page Compare Api Integrations Google Page Compare Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/page-live/history": { + "get": { + "tags": [ + "integrations" + ], + "summary": "Google Page Live History", + "description": "Return history of page Google snapshots for a URL.", + "operationId": "google_page_live_history_api_integrations_google_page_live_history_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 50, + "minimum": 1, + "default": 15, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Google Page Live History Api Integrations Google Page Live History Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/keywords/history/batch": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Google Keywords History Batch", + "description": "Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str }", + "operationId": "google_keywords_history_batch_api_integrations_google_keywords_history_batch_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Keywords History Batch Api Integrations Google Keywords History Batch Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/keywords/expand": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Google Keywords Expand", + "description": "Expand keyword ideas from Google Keyword Planner or suggest API.", + "operationId": "google_keywords_expand_api_integrations_google_keywords_expand_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Keywords Expand Api Integrations Google Keywords Expand Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/integrations/google/keywords/planner": { + "post": { + "tags": [ + "integrations" + ], + "summary": "Google Keywords Planner", + "description": "Fetch keyword planner data from Google Ads API.", + "operationId": "google_keywords_planner_api_integrations_google_keywords_planner_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Google Keywords Planner Api Integrations Google Keywords Planner Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/issues/status": { + "get": { + "tags": [ + "issues" + ], + "summary": "List Issue Status", + "operationId": "list_issue_status_api_issues_status_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Issue Status Api Issues Status Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "issues" + ], + "summary": "Upsert Issue Status", + "operationId": "upsert_issue_status_api_issues_status_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Upsert Issue Status Api Issues Status Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/issues/fix-suggestion": { + "post": { + "tags": [ + "issues" + ], + "summary": "Issues Fix Suggestion", + "operationId": "issues_fix_suggestion_api_issues_fix_suggestion_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Issues Fix Suggestion Api Issues Fix Suggestion Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/issues/action-plan": { + "post": { + "tags": [ + "issues" + ], + "summary": "Issues Action Plan", + "operationId": "issues_action_plan_api_issues_action_plan_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Issues Action Plan Api Issues Action Plan Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/ai/fix-suggestion": { + "post": { + "tags": [ + "issues" + ], + "summary": "Ai Fix Suggestion", + "operationId": "ai_fix_suggestion_api_ai_fix_suggestion_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Ai Fix Suggestion Api Ai Fix Suggestion Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/keywords/competitor-import": { + "post": { + "tags": [ + "keywords" + ], + "summary": "Keywords Competitor Import", + "operationId": "keywords_competitor_import_api_keywords_competitor_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Keywords Competitor Import Api Keywords Competitor Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/keywords/content-brief": { + "post": { + "tags": [ + "keywords" + ], + "summary": "Keywords Content Brief", + "operationId": "keywords_content_brief_api_keywords_content_brief_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Keywords Content Brief Api Keywords Content Brief Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/backlinks/velocity": { + "get": { + "tags": [ + "content" + ], + "summary": "Backlinks Velocity", + "operationId": "backlinks_velocity_api_backlinks_velocity_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Backlinks Velocity Api Backlinks Velocity Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/backlinks/competitor-import": { + "post": { + "tags": [ + "content" + ], + "summary": "Backlinks Competitor Import", + "operationId": "backlinks_competitor_import_api_backlinks_competitor_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Backlinks Competitor Import Api Backlinks Competitor Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/backlinks/third-party-import": { + "post": { + "tags": [ + "content" + ], + "summary": "Backlinks Third Party Import", + "operationId": "backlinks_third_party_import_api_backlinks_third_party_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Backlinks Third Party Import Api Backlinks Third Party Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/content/analyze": { + "post": { + "tags": [ + "content" + ], + "summary": "Content Analyze", + "operationId": "content_analyze_api_content_analyze_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Content Analyze Api Content Analyze Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/content/score": { + "post": { + "tags": [ + "content" + ], + "summary": "Content Score", + "operationId": "content_score_api_content_score_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Content Score Api Content Score Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/content/wizard": { + "post": { + "tags": [ + "content" + ], + "summary": "Content Wizard", + "operationId": "content_wizard_api_content_wizard_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Content Wizard Api Content Wizard Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/content-drafts": { + "get": { + "tags": [ + "content" + ], + "summary": "List Content Drafts", + "operationId": "list_content_drafts_api_content_drafts_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Content Drafts Api Content Drafts Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "content" + ], + "summary": "Create Content Draft", + "operationId": "create_content_draft_api_content_drafts_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Create Content Draft Api Content Drafts Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/content-drafts/{draft_id}": { + "get": { + "tags": [ + "content" + ], + "summary": "Get Content Draft", + "operationId": "get_content_draft_api_content_drafts__draft_id__get", + "parameters": [ + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Draft Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Content Draft Api Content Drafts Draft Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "content" + ], + "summary": "Update Content Draft", + "operationId": "update_content_draft_api_content_drafts__draft_id__patch", + "parameters": [ + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Draft Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Content Draft Api Content Drafts Draft Id Patch" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "content" + ], + "summary": "Delete Content Draft", + "operationId": "delete_content_draft_api_content_drafts__draft_id__delete", + "parameters": [ + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Draft Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Content Draft Api Content Drafts Draft Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/page-markdown": { + "get": { + "tags": [ + "page-markdown" + ], + "summary": "List Page Markdown", + "operationId": "list_page_markdown_api_page_markdown_get", + "parameters": [ + { + "name": "crawlRunId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Crawlrunid" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 25, + "title": "Limit" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Page Markdown Api Page Markdown Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "page-markdown" + ], + "summary": "Delete Page Markdown", + "operationId": "delete_page_markdown_api_page_markdown_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Page Markdown Api Page Markdown Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/page-markdown/content": { + "get": { + "tags": [ + "page-markdown" + ], + "summary": "Page Markdown Content", + "operationId": "page_markdown_content_api_page_markdown_content_get", + "parameters": [ + { + "name": "crawlRunId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Crawlrunid" + } + }, + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Page Markdown Content Api Page Markdown Content Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/page-markdown/extract": { + "post": { + "tags": [ + "page-markdown" + ], + "summary": "Page Markdown Extract", + "operationId": "page_markdown_extract_api_page_markdown_extract_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Page Markdown Extract Api Page Markdown Extract Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/page-markdown/runs": { + "get": { + "tags": [ + "page-markdown" + ], + "summary": "Page Markdown Runs", + "operationId": "page_markdown_runs_api_page_markdown_runs_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Page Markdown Runs Api Page Markdown Runs Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/ollama/status": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Ollama Status", + "operationId": "ollama_status_api_ollama_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Ollama Status Api Ollama Status Get" + } + } + } + } + } + } + }, + "/api/mcp-tools": { + "get": { + "tags": [ + "mcp-tools" + ], + "summary": "Mcp Tools", + "operationId": "mcp_tools_api_mcp_tools_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Mcp Tools Api Mcp Tools Get" + } + } + } + } + } + } + }, + "/api/portfolio/delete": { + "delete": { + "tags": [ + "portfolio" + ], + "summary": "Delete Portfolio Item", + "operationId": "delete_portfolio_item_api_portfolio_delete_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePortfolioBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Delete Portfolio Item Api Portfolio Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/alerts/check": { + "post": { + "tags": [ + "alerts" + ], + "summary": "Alerts Check", + "operationId": "alerts_check_api_alerts_check_post", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Propertyid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Alerts Check Api Alerts Check Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/schedule/check": { + "post": { + "tags": [ + "schedule" + ], + "summary": "Schedule Check", + "operationId": "schedule_check_api_schedule_check_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Schedule Check Api Schedule Check Post" + } + } + } + } + } + } + }, + "/api/logs/upload": { + "post": { + "tags": [ + "logs" + ], + "summary": "Logs Upload", + "operationId": "logs_upload_api_logs_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_logs_upload_api_logs_upload_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Logs Upload Api Logs Upload Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/compare/export": { + "post": { + "tags": [ + "compare" + ], + "summary": "Compare Export", + "operationId": "compare_export_api_compare_export_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompareExportBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/links/page-coach": { + "post": { + "tags": [ + "page-coach" + ], + "summary": "Page Coach", + "operationId": "page_coach_api_links_page_coach_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageCoachBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Page Coach Api Links Page Coach Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/audit-tool": { + "post": { + "tags": [ + "report-audit-tool" + ], + "summary": "Run Audit Tool", + "operationId": "run_audit_tool_api_report_audit_tool_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditToolBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Run Audit Tool Api Report Audit Tool Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/export": { + "get": { + "tags": [ + "report-export" + ], + "summary": "Export Report", + "operationId": "export_report_api_report_export_get", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "csv", + "title": "Format" + } + }, + { + "name": "reportId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/export-sitemap": { + "get": { + "tags": [ + "report-export" + ], + "summary": "Export Sitemap", + "operationId": "export_sitemap_api_report_export_sitemap_get", + "parameters": [ + { + "name": "reportId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/export-workbook": { + "get": { + "tags": [ + "report-export" + ], + "summary": "Export Workbook", + "operationId": "export_workbook_api_report_export_workbook_get", + "parameters": [ + { + "name": "reportId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/report/portfolio": { + "get": { + "tags": [ + "report-portfolio" + ], + "summary": "Report Portfolio", + "description": "Return portfolio data \u2014 groups, crawl history, summary, or single card.", + "operationId": "report_portfolio_api_report_portfolio_get", + "parameters": [ + { + "name": "widget", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "full", + "title": "Widget" + } + }, + { + "name": "ids", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ids" + } + }, + { + "name": "reportId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + } + }, + { + "name": "crawlRunId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Crawlrunid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Report Portfolio Api Report Portfolio Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AppSettingBody": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "value": { + "type": "string", + "title": "Value" + } + }, + "type": "object", + "required": [ + "key", + "value" + ], + "title": "AppSettingBody" + }, + "AuditToolBody": { + "properties": { + "toolName": { + "type": "string", + "title": "Toolname" + }, + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "reportId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + }, + "args": { + "additionalProperties": true, + "type": "object", + "title": "Args", + "default": {} + } + }, + "type": "object", + "required": [ + "toolName", + "propertyId" + ], + "title": "AuditToolBody" + }, + "Body_logs_upload_api_logs_upload_post": { + "properties": { + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": [ + "propertyId", + "file" + ], + "title": "Body_logs_upload_api_logs_upload_post" + }, + "CancelResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "status": { + "type": "string", + "title": "Status" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "ok", + "status" + ], + "title": "CancelResponse" + }, + "ChatRequest": { + "properties": { + "sessionId": { + "type": "integer", + "title": "Sessionid" + }, + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "message": { + "type": "string", + "title": "Message" + }, + "reportId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + } + }, + "type": "object", + "required": [ + "sessionId", + "propertyId", + "message" + ], + "title": "ChatRequest" + }, + "ChatSessionCreate": { + "properties": { + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "title": { + "type": "string", + "title": "Title", + "default": "New chat" + } + }, + "type": "object", + "required": [ + "propertyId" + ], + "title": "ChatSessionCreate" + }, + "CompareExportBody": { + "properties": { + "reportIdA": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportida" + }, + "reportIdB": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportidb" + } + }, + "type": "object", + "title": "CompareExportBody" + }, + "DashboardAiGenerateBody": { + "properties": { + "mode": { + "type": "string", + "title": "Mode" + }, + "prompt": { + "type": "string", + "title": "Prompt" + }, + "catalog": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Catalog" + }, + "viz_types": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Viz Types" + }, + "dashscript_help": { + "type": "string", + "title": "Dashscript Help" + }, + "toolName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Toolname" + }, + "propertyId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + }, + "reportId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + }, + "current": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Current" + }, + "sample": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Sample" + } + }, + "type": "object", + "required": [ + "mode", + "prompt", + "catalog", + "viz_types", + "dashscript_help" + ], + "title": "DashboardAiGenerateBody" + }, + "DashboardCreateBody": { + "properties": { + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "layoutJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Layoutjson" + } + }, + "type": "object", + "required": [ + "propertyId" + ], + "title": "DashboardCreateBody" + }, + "DashboardUpdateBody": { + "properties": { + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "layoutJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Layoutjson" + }, + "isDefault": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Isdefault" + } + }, + "type": "object", + "required": [ + "propertyId" + ], + "title": "DashboardUpdateBody" + }, + "DeletePortfolioBody": { + "properties": { + "reportId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reportid" + }, + "crawlRunId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Crawlrunid" + } + }, + "type": "object", + "title": "DeletePortfolioBody" + }, + "FilterDeleteBody": { + "properties": { + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "propertyId", + "name" + ], + "title": "FilterDeleteBody" + }, + "FilterUpsertBody": { + "properties": { + "propertyId": { + "type": "integer", + "title": "Propertyid" + }, + "name": { + "type": "string", + "title": "Name" + }, + "filterJson": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Filterjson" + } + }, + "type": "object", + "required": [ + "propertyId", + "name" + ], + "title": "FilterUpsertBody" + }, + "GoogleCredentialsPatch": { + "properties": { + "refreshToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Refreshtoken" + }, + "authMode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authmode" + }, + "gscSiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gscsiteurl" + }, + "ga4PropertyId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ga4Propertyid" + }, + "dateRangeDays": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Daterangedays" + }, + "connectedEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connectedemail" + } + }, + "type": "object", + "title": "GoogleCredentialsPatch" + }, + "GoogleCredentialsPostBody": { + "properties": { + "gscSiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gscsiteurl" + }, + "ga4PropertyId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ga4Propertyid" + }, + "dateRangeDays": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Daterangedays" + }, + "refreshToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Refreshtoken" + } + }, + "type": "object", + "title": "GoogleCredentialsPostBody" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "JobsListResponse": { + "properties": { + "jobs": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Jobs" + }, + "active": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Active" + }, + "reconciled": { + "type": "integer", + "title": "Reconciled", + "default": 0 + } + }, + "type": "object", + "required": [ + "jobs" + ], + "title": "JobsListResponse" + }, + "LlmConfigBody": { + "properties": { + "state": { + "additionalProperties": true, + "type": "object", + "title": "State" + } + }, + "type": "object", + "required": [ + "state" + ], + "title": "LlmConfigBody" + }, + "OpsSettingsBody": { + "properties": { + "scheduleCron": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Schedulecron" + }, + "alertWebhookUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alertwebhookurl" + }, + "alertEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alertemail" + } + }, + "type": "object", + "title": "OpsSettingsBody" + }, + "PageCoachBody": { + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "refresh": { + "type": "boolean", + "title": "Refresh", + "default": false + }, + "currentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Currenttype" + }, + "currentId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Currentid" + }, + "baselineType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Baselinetype" + }, + "baselineId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Baselineid" + }, + "propertyId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + "type": "object", + "title": "PageCoachBody" + }, + "PauseResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "ok" + ], + "title": "PauseResponse" + }, + "PipelineConfigBody": { + "properties": { + "state": { + "additionalProperties": true, + "type": "object", + "title": "State" + }, + "unknownKeys": { + "anyOf": [ + { + "items": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Unknownkeys" + } + }, + "type": "object", + "required": [ + "state" + ], + "title": "PipelineConfigBody" + }, + "PresetBody": { + "properties": { + "preset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Preset" + } + }, + "type": "object", + "title": "PresetBody" + }, + "PropertyUpsertBody": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "canonical_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Canonical Domain" + }, + "site_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Url" + } + }, + "type": "object", + "title": "PropertyUpsertBody" + }, + "ResumeResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "newJobId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Newjobid" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "ok" + ], + "title": "ResumeResponse" + }, + "RunPostBody": { + "properties": { + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Command" + }, + "state": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "State" + }, + "unknownKeys": { + "items": { + "$ref": "#/components/schemas/UnknownKeyEntry" + }, + "type": "array", + "title": "Unknownkeys" + }, + "llmState": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Llmstate" + }, + "propertyId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + }, + "python": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Python" + }, + "repoRoot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reporoot" + } + }, + "type": "object", + "title": "RunPostBody" + }, + "RunResponse": { + "properties": { + "jobId": { + "type": "string", + "title": "Jobid" + } + }, + "type": "object", + "required": [ + "jobId" + ], + "title": "RunResponse" + }, + "SecretsBody": { + "properties": { + "state": { + "additionalProperties": true, + "type": "object", + "title": "State" + } + }, + "type": "object", + "required": [ + "state" + ], + "title": "SecretsBody" + }, + "UnknownKeyEntry": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "value": { + "type": "string", + "title": "Value" + } + }, + "type": "object", + "required": [ + "key", + "value" + ], + "title": "UnknownKeyEntry" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index eccd015f..0b70d3db 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,7 +33,6 @@ "echarts": "^6.1.0", "lucide-react": "^0.577.0", "next": "15.5.14", - "pg": "^8.21.0", "react": "19.1.0", "react-chartjs-2": "^5.3.1", "react-dom": "19.1.0", @@ -45,10 +44,10 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@hey-api/openapi-ts": "^0.98.2", "@tailwindcss/postcss": "^4", "@types/d3": "^7.4.3", "@types/node": "^25.9.1", - "@types/pg": "^8.20.0", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "eslint": "^9", @@ -780,6 +779,127 @@ "license": "MIT", "optional": true }, + "node_modules/@hey-api/codegen-core": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.0.tgz", + "integrity": "sha512-OK9/R8WuujwgvnrDIPnEiIf6WnfUOi3GaEr6kIngqoI5FUQwYbeDKHE/frTVUl2A76ZQPCrMknHtPx6Gqtwf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "c12": "3.3.4", + "color-support": "1.1.3" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.3.tgz", + "integrity": "sha512-UzGSDzh3QUhrnwl4atnHc2YqDO6KemYVEOwl1Ynowm/tcr0XlpdHOpyWr5UaWIJfiXTXdYRIC9k2Yxm19pcPzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "7.1.3", + "@types/json-schema": "7.0.15", + "js-yaml": "4.1.1" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.98.2", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.98.2.tgz", + "integrity": "sha512-2nVJXH8tpFPGTBOhxyjEd1Jw0hsRqJqeTQW3kltAjVdSU4YWxeu97x5sgNOmsbsfeg6Dqz7Wfzs26walBOuswA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "0.9.0", + "@hey-api/json-schema-ref-parser": "1.4.3", + "@hey-api/shared": "0.4.8", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "@lukeed/ms": "2.0.2", + "ansi-colors": "4.1.3", + "color-support": "1.1.3", + "commander": "15.0.0", + "get-tsconfig": "4.14.0" + }, + "bin": { + "openapi-ts": "bin/run.js" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@hey-api/shared": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.4.8.tgz", + "integrity": "sha512-29Pg2FB0UW20pplYgcfiQn1hQYpbZ9D2gdDJc7nDK3xh3pvHOTGP0v3R2ueFpFnw9GN1SRhIdhiVuAYWMDimjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "0.9.0", + "@hey-api/json-schema-ref-parser": "1.4.3", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "cross-spawn": "7.0.6", + "open": "11.0.0", + "semver": "7.8.2" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/spec-types": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.2.0.tgz", + "integrity": "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/types": "0.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/types": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", + "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1348,12 +1468,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3205,18 +3342,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -3990,6 +4115,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4287,6 +4422,51 @@ "node": ">=8" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4483,6 +4663,22 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4518,6 +4714,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -4544,6 +4750,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5114,6 +5327,36 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5132,6 +5375,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -5150,6 +5406,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -5168,6 +5431,13 @@ "node": ">=6" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5204,6 +5474,19 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5957,6 +6240,13 @@ "node": ">=12.0.0" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6272,9 +6562,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6284,6 +6574,16 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "dev": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6794,6 +7094,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6863,6 +7179,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -7073,6 +7421,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -8852,6 +9216,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9008,95 +9400,13 @@ "node": ">= 14.16" } }, - "node_modules/pg": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", - "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.13.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.14.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", - "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", - "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, "license": "MIT" }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9116,6 +9426,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -9167,43 +9489,17 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" + "node": ">=20" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/preact": { @@ -9426,6 +9722,17 @@ ], "license": "MIT" }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -9562,6 +9869,20 @@ "react": ">= 0.14.0" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9818,6 +10139,19 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9916,9 +10250,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "devOptional": true, "license": "ISC", "bin": { @@ -10147,15 +10481,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -11314,13 +11639,21 @@ "node": ">=0.10.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, "engines": { - "node": ">=0.4" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yocto-queue": { diff --git a/web/package.json b/web/package.json index 682d4cc3..2f4ce426 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,9 @@ "lint": "eslint", "test": "vitest run", "typecheck": "tsc --noEmit", - "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json" + "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json", + "generate:openapi": "python ../scripts/generate_openapi.py", + "generate:api": "openapi-ts -i openapi.json -o src/client" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -37,7 +39,6 @@ "echarts": "^6.1.0", "lucide-react": "^0.577.0", "next": "15.5.14", - "pg": "^8.21.0", "react": "19.1.0", "react-chartjs-2": "^5.3.1", "react-dom": "19.1.0", @@ -49,10 +50,10 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@hey-api/openapi-ts": "^0.98.2", "@tailwindcss/postcss": "^4", "@types/d3": "^7.4.3", "@types/node": "^25.9.1", - "@types/pg": "^8.20.0", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "eslint": "^9", diff --git a/web/src/client/client.gen.ts b/web/src/client/client.gen.ts new file mode 100644 index 00000000..47828b74 --- /dev/null +++ b/web/src/client/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client: Client = createClient(createConfig()); diff --git a/web/src/client/client/client.gen.ts b/web/src/client/client/client.gen.ts new file mode 100644 index 00000000..fc3f037f --- /dev/null +++ b/web/src/client/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/web/src/client/client/index.ts b/web/src/client/client/index.ts new file mode 100644 index 00000000..8c693310 --- /dev/null +++ b/web/src/client/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; +export type { ClientMeta } from '../core/types.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/web/src/client/client/types.gen.ts b/web/src/client/client/types.gen.ts new file mode 100644 index 00000000..193646cd --- /dev/null +++ b/web/src/client/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/web/src/client/client/utils.gen.ts b/web/src/client/client/utils.gen.ts new file mode 100644 index 00000000..d4a72843 --- /dev/null +++ b/web/src/client/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/web/src/client/core/auth.gen.ts b/web/src/client/core/auth.gen.ts new file mode 100644 index 00000000..c6636644 --- /dev/null +++ b/web/src/client/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/web/src/client/core/bodySerializer.gen.ts b/web/src/client/core/bodySerializer.gen.ts new file mode 100644 index 00000000..67daca60 --- /dev/null +++ b/web/src/client/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/web/src/client/core/params.gen.ts b/web/src/client/core/params.gen.ts new file mode 100644 index 00000000..0f50047b --- /dev/null +++ b/web/src/client/core/params.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +type ParamsSlotMap = Record; + +function stripEmptySlots(params: ParamsSlotMap): void { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: ParamsSlotMap = { + body: Object.create(null), + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[key.slice(prefix.length)] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params as Params; +} diff --git a/web/src/client/core/pathSerializer.gen.ts b/web/src/client/core/pathSerializer.gen.ts new file mode 100644 index 00000000..fab1ed4b --- /dev/null +++ b/web/src/client/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/web/src/client/core/queryKeySerializer.gen.ts b/web/src/client/core/queryKeySerializer.gen.ts new file mode 100644 index 00000000..773b0650 --- /dev/null +++ b/web/src/client/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/web/src/client/core/serverSentEvents.gen.ts b/web/src/client/core/serverSentEvents.gen.ts new file mode 100644 index 00000000..ddf3c4d1 --- /dev/null +++ b/web/src/client/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/web/src/client/core/types.gen.ts b/web/src/client/core/types.gen.ts new file mode 100644 index 00000000..c657c859 --- /dev/null +++ b/web/src/client/core/types.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/web/src/client/core/utils.gen.ts b/web/src/client/core/utils.gen.ts new file mode 100644 index 00000000..af56e071 --- /dev/null +++ b/web/src/client/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/web/src/client/index.ts b/web/src/client/index.ts new file mode 100644 index 00000000..d9708e81 --- /dev/null +++ b/web/src/client/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { aiFixSuggestionApiAiFixSuggestionPost, alertsCheckApiAlertsCheckPost, authorizePropertyCrawlApiPropertiesPropertyIdAuthorizePost, backlinksCompetitorImportApiBacklinksCompetitorImportPost, backlinksThirdPartyImportApiBacklinksThirdPartyImportPost, backlinksVelocityApiBacklinksVelocityGet, bingSyncApiIntegrationsBingSyncPost, browserStatusCheckApiCrawlBrowserStatusGet, cancelPipelineJobApiJobsJobIdCancelPost, chatTurnApiChatPost, compareExportApiCompareExportPost, contentAnalyzeApiContentAnalyzePost, contentScoreApiContentScorePost, contentWizardApiContentWizardPost, crawlPayloadApiReportCrawlPayloadGet, createContentDraftApiContentDraftsPost, createDashboardApiDashboardsPost, createPropertyApiPropertiesPost, createSessionApiChatSessionsPost, dashboardsAiGenerateApiDashboardsAiGeneratePost, deleteContentDraftApiContentDraftsDraftIdDelete, deleteDashboardApiDashboardsDashboardIdDelete, deleteFilterApiFiltersDelete, deletePageMarkdownApiPageMarkdownDelete, deletePortfolioItemApiPortfolioDeleteDelete, deletePropertyApiPropertiesPropertyIdDelete, deleteSessionRouteApiChatSessionsSessionIdDelete, exportReportApiReportExportGet, exportSitemapApiReportExportSitemapGet, exportWorkbookApiReportExportWorkbookGet, getAppSettingApiAppSettingsGet, getArtifactApiChatArtifactsArtifactIdGet, getContentDraftApiContentDraftsDraftIdGet, getDashboardApiDashboardsDashboardIdGet, getLlmConfigApiLlmConfigGet, getPageHtmlApiCrawlPageHtmlGet, getPipelineConfigApiPipelineConfigGet, getPipelineJobApiJobsJobIdGet, getPropertyApiPropertiesPropertyIdGet, getPropertyOpsApiPropertiesPropertyIdOpsGet, getPropertyPresetApiPropertiesPropertyIdPresetGet, getSecretsApiSecretsGet, getSessionMessagesApiChatSessionsSessionIdMessagesGet, getSessionRouteApiChatSessionsSessionIdGet, googleDisconnectApiIntegrationsGoogleDisconnectPost, googleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGet, googleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPost, googleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGet, googleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPost, googleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPost, googlePageCompareApiIntegrationsGooglePageCompareGet, googlePageDataApiIntegrationsGooglePageDataGet, googlePageDataHistoryApiIntegrationsGooglePageDataHistoryGet, googlePageLiveApiIntegrationsGooglePageLivePost, googlePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGet, googlePropertiesDeprecatedApiIntegrationsGooglePropertiesGet, googleStatusApiIntegrationsGoogleStatusGet, googleTestApiIntegrationsGoogleTestPost, healthCheckApiHealthGet, issuesActionPlanApiIssuesActionPlanPost, issuesFixSuggestionApiIssuesFixSuggestionPost, keywordsCompetitorImportApiKeywordsCompetitorImportPost, keywordsContentBriefApiKeywordsContentBriefPost, listContentDraftsApiContentDraftsGet, listDashboardsApiDashboardsGet, listFiltersApiFiltersGet, listIssueStatusApiIssuesStatusGet, listPageMarkdownApiPageMarkdownGet, listPipelineJobsApiJobsGet, listPropertiesApiPropertiesGet, listSessionsApiChatSessionsGet, logsUploadApiLogsUploadPost, mcpToolsApiMcpToolsGet, mobileDeltaApiReportMobileDeltaGet, ollamaStatusApiOllamaStatusGet, type Options, pageCoachApiLinksPageCoachPost, pageMarkdownContentApiPageMarkdownContentGet, pageMarkdownExtractApiPageMarkdownExtractPost, pageMarkdownRunsApiPageMarkdownRunsGet, patchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatch, pausePipelineJobApiJobsJobIdPausePost, postPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPost, postPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPost, propertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPost, propertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGet, propertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGet, propertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGet, propertyGoogleTestApiPropertiesPropertyIdGoogleTestPost, putAppSettingApiAppSettingsPut, putLlmConfigApiLlmConfigPut, putPipelineConfigApiPipelineConfigPut, putSecretsApiSecretsPut, reportHistoryApiReportHistoryGet, reportMetaApiReportMetaGet, reportPayloadApiReportPayloadGet, reportPortfolioApiReportPortfolioGet, resolvePropertyApiPropertiesResolveGet, resumePipelineJobApiJobsJobIdResumePost, runAuditToolApiReportAuditToolPost, runPipelineApiRunPost, saveGoogleCredentialsApiIntegrationsGoogleCredentialsPost, scheduleCheckApiScheduleCheckPost, updateContentDraftApiContentDraftsDraftIdPatch, updateDashboardApiDashboardsDashboardIdPut, updatePropertyOpsApiPropertiesPropertyIdOpsPut, updatePropertyPresetApiPropertiesPropertyIdPresetPut, uploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPost, upsertFilterApiFiltersPost, upsertIssueStatusApiIssuesStatusPut } from './sdk.gen'; +export type { AiFixSuggestionApiAiFixSuggestionPostData, AiFixSuggestionApiAiFixSuggestionPostError, AiFixSuggestionApiAiFixSuggestionPostErrors, AiFixSuggestionApiAiFixSuggestionPostResponses, AlertsCheckApiAlertsCheckPostData, AlertsCheckApiAlertsCheckPostError, AlertsCheckApiAlertsCheckPostErrors, AlertsCheckApiAlertsCheckPostResponse, AlertsCheckApiAlertsCheckPostResponses, AppSettingBody, AuditToolBody, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostError, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponse, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses, BacklinksCompetitorImportApiBacklinksCompetitorImportPostData, BacklinksCompetitorImportApiBacklinksCompetitorImportPostError, BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponse, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostError, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponse, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses, BacklinksVelocityApiBacklinksVelocityGetData, BacklinksVelocityApiBacklinksVelocityGetError, BacklinksVelocityApiBacklinksVelocityGetErrors, BacklinksVelocityApiBacklinksVelocityGetResponse, BacklinksVelocityApiBacklinksVelocityGetResponses, BingSyncApiIntegrationsBingSyncPostData, BingSyncApiIntegrationsBingSyncPostResponse, BingSyncApiIntegrationsBingSyncPostResponses, BodyLogsUploadApiLogsUploadPost, BrowserStatusCheckApiCrawlBrowserStatusGetData, BrowserStatusCheckApiCrawlBrowserStatusGetResponse, BrowserStatusCheckApiCrawlBrowserStatusGetResponses, CancelPipelineJobApiJobsJobIdCancelPostData, CancelPipelineJobApiJobsJobIdCancelPostError, CancelPipelineJobApiJobsJobIdCancelPostErrors, CancelPipelineJobApiJobsJobIdCancelPostResponse, CancelPipelineJobApiJobsJobIdCancelPostResponses, CancelResponse, ChatRequest, ChatSessionCreate, ChatTurnApiChatPostData, ChatTurnApiChatPostError, ChatTurnApiChatPostErrors, ChatTurnApiChatPostResponses, ClientOptions, CompareExportApiCompareExportPostData, CompareExportApiCompareExportPostError, CompareExportApiCompareExportPostErrors, CompareExportApiCompareExportPostResponses, CompareExportBody, ContentAnalyzeApiContentAnalyzePostData, ContentAnalyzeApiContentAnalyzePostError, ContentAnalyzeApiContentAnalyzePostErrors, ContentAnalyzeApiContentAnalyzePostResponse, ContentAnalyzeApiContentAnalyzePostResponses, ContentScoreApiContentScorePostData, ContentScoreApiContentScorePostError, ContentScoreApiContentScorePostErrors, ContentScoreApiContentScorePostResponse, ContentScoreApiContentScorePostResponses, ContentWizardApiContentWizardPostData, ContentWizardApiContentWizardPostError, ContentWizardApiContentWizardPostErrors, ContentWizardApiContentWizardPostResponse, ContentWizardApiContentWizardPostResponses, CrawlPayloadApiReportCrawlPayloadGetData, CrawlPayloadApiReportCrawlPayloadGetError, CrawlPayloadApiReportCrawlPayloadGetErrors, CrawlPayloadApiReportCrawlPayloadGetResponse, CrawlPayloadApiReportCrawlPayloadGetResponses, CreateContentDraftApiContentDraftsPostData, CreateContentDraftApiContentDraftsPostError, CreateContentDraftApiContentDraftsPostErrors, CreateContentDraftApiContentDraftsPostResponse, CreateContentDraftApiContentDraftsPostResponses, CreateDashboardApiDashboardsPostData, CreateDashboardApiDashboardsPostError, CreateDashboardApiDashboardsPostErrors, CreateDashboardApiDashboardsPostResponse, CreateDashboardApiDashboardsPostResponses, CreatePropertyApiPropertiesPostData, CreatePropertyApiPropertiesPostError, CreatePropertyApiPropertiesPostErrors, CreatePropertyApiPropertiesPostResponse, CreatePropertyApiPropertiesPostResponses, CreateSessionApiChatSessionsPostData, CreateSessionApiChatSessionsPostError, CreateSessionApiChatSessionsPostErrors, CreateSessionApiChatSessionsPostResponse, CreateSessionApiChatSessionsPostResponses, DashboardAiGenerateBody, DashboardCreateBody, DashboardsAiGenerateApiDashboardsAiGeneratePostData, DashboardsAiGenerateApiDashboardsAiGeneratePostError, DashboardsAiGenerateApiDashboardsAiGeneratePostErrors, DashboardsAiGenerateApiDashboardsAiGeneratePostResponses, DashboardUpdateBody, DeleteContentDraftApiContentDraftsDraftIdDeleteData, DeleteContentDraftApiContentDraftsDraftIdDeleteError, DeleteContentDraftApiContentDraftsDraftIdDeleteErrors, DeleteContentDraftApiContentDraftsDraftIdDeleteResponse, DeleteContentDraftApiContentDraftsDraftIdDeleteResponses, DeleteDashboardApiDashboardsDashboardIdDeleteData, DeleteDashboardApiDashboardsDashboardIdDeleteError, DeleteDashboardApiDashboardsDashboardIdDeleteErrors, DeleteDashboardApiDashboardsDashboardIdDeleteResponse, DeleteDashboardApiDashboardsDashboardIdDeleteResponses, DeleteFilterApiFiltersDeleteData, DeleteFilterApiFiltersDeleteError, DeleteFilterApiFiltersDeleteErrors, DeleteFilterApiFiltersDeleteResponse, DeleteFilterApiFiltersDeleteResponses, DeletePageMarkdownApiPageMarkdownDeleteData, DeletePageMarkdownApiPageMarkdownDeleteError, DeletePageMarkdownApiPageMarkdownDeleteErrors, DeletePageMarkdownApiPageMarkdownDeleteResponse, DeletePageMarkdownApiPageMarkdownDeleteResponses, DeletePortfolioBody, DeletePortfolioItemApiPortfolioDeleteDeleteData, DeletePortfolioItemApiPortfolioDeleteDeleteError, DeletePortfolioItemApiPortfolioDeleteDeleteErrors, DeletePortfolioItemApiPortfolioDeleteDeleteResponse, DeletePortfolioItemApiPortfolioDeleteDeleteResponses, DeletePropertyApiPropertiesPropertyIdDeleteData, DeletePropertyApiPropertiesPropertyIdDeleteError, DeletePropertyApiPropertiesPropertyIdDeleteErrors, DeletePropertyApiPropertiesPropertyIdDeleteResponse, DeletePropertyApiPropertiesPropertyIdDeleteResponses, DeleteSessionRouteApiChatSessionsSessionIdDeleteData, DeleteSessionRouteApiChatSessionsSessionIdDeleteError, DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors, DeleteSessionRouteApiChatSessionsSessionIdDeleteResponse, DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses, ExportReportApiReportExportGetData, ExportReportApiReportExportGetError, ExportReportApiReportExportGetErrors, ExportReportApiReportExportGetResponses, ExportSitemapApiReportExportSitemapGetData, ExportSitemapApiReportExportSitemapGetError, ExportSitemapApiReportExportSitemapGetErrors, ExportSitemapApiReportExportSitemapGetResponses, ExportWorkbookApiReportExportWorkbookGetData, ExportWorkbookApiReportExportWorkbookGetError, ExportWorkbookApiReportExportWorkbookGetErrors, ExportWorkbookApiReportExportWorkbookGetResponses, FilterDeleteBody, FilterUpsertBody, GetAppSettingApiAppSettingsGetData, GetAppSettingApiAppSettingsGetError, GetAppSettingApiAppSettingsGetErrors, GetAppSettingApiAppSettingsGetResponse, GetAppSettingApiAppSettingsGetResponses, GetArtifactApiChatArtifactsArtifactIdGetData, GetArtifactApiChatArtifactsArtifactIdGetError, GetArtifactApiChatArtifactsArtifactIdGetErrors, GetArtifactApiChatArtifactsArtifactIdGetResponses, GetContentDraftApiContentDraftsDraftIdGetData, GetContentDraftApiContentDraftsDraftIdGetError, GetContentDraftApiContentDraftsDraftIdGetErrors, GetContentDraftApiContentDraftsDraftIdGetResponse, GetContentDraftApiContentDraftsDraftIdGetResponses, GetDashboardApiDashboardsDashboardIdGetData, GetDashboardApiDashboardsDashboardIdGetError, GetDashboardApiDashboardsDashboardIdGetErrors, GetDashboardApiDashboardsDashboardIdGetResponse, GetDashboardApiDashboardsDashboardIdGetResponses, GetLlmConfigApiLlmConfigGetData, GetLlmConfigApiLlmConfigGetResponse, GetLlmConfigApiLlmConfigGetResponses, GetPageHtmlApiCrawlPageHtmlGetData, GetPageHtmlApiCrawlPageHtmlGetError, GetPageHtmlApiCrawlPageHtmlGetErrors, GetPageHtmlApiCrawlPageHtmlGetResponse, GetPageHtmlApiCrawlPageHtmlGetResponses, GetPipelineConfigApiPipelineConfigGetData, GetPipelineConfigApiPipelineConfigGetResponse, GetPipelineConfigApiPipelineConfigGetResponses, GetPipelineJobApiJobsJobIdGetData, GetPipelineJobApiJobsJobIdGetError, GetPipelineJobApiJobsJobIdGetErrors, GetPipelineJobApiJobsJobIdGetResponse, GetPipelineJobApiJobsJobIdGetResponses, GetPropertyApiPropertiesPropertyIdGetData, GetPropertyApiPropertiesPropertyIdGetError, GetPropertyApiPropertiesPropertyIdGetErrors, GetPropertyApiPropertiesPropertyIdGetResponse, GetPropertyApiPropertiesPropertyIdGetResponses, GetPropertyOpsApiPropertiesPropertyIdOpsGetData, GetPropertyOpsApiPropertiesPropertyIdOpsGetError, GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors, GetPropertyOpsApiPropertiesPropertyIdOpsGetResponse, GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses, GetPropertyPresetApiPropertiesPropertyIdPresetGetData, GetPropertyPresetApiPropertiesPropertyIdPresetGetError, GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponse, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses, GetSecretsApiSecretsGetData, GetSecretsApiSecretsGetResponse, GetSecretsApiSecretsGetResponses, GetSessionMessagesApiChatSessionsSessionIdMessagesGetData, GetSessionMessagesApiChatSessionsSessionIdMessagesGetError, GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors, GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponse, GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses, GetSessionRouteApiChatSessionsSessionIdGetData, GetSessionRouteApiChatSessionsSessionIdGetError, GetSessionRouteApiChatSessionsSessionIdGetErrors, GetSessionRouteApiChatSessionsSessionIdGetResponse, GetSessionRouteApiChatSessionsSessionIdGetResponses, GoogleCredentialsPatch, GoogleCredentialsPostBody, GoogleDisconnectApiIntegrationsGoogleDisconnectPostData, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponse, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetError, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponse, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostError, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponse, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetError, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponse, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostError, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponse, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostError, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponse, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses, GooglePageCompareApiIntegrationsGooglePageCompareGetData, GooglePageCompareApiIntegrationsGooglePageCompareGetError, GooglePageCompareApiIntegrationsGooglePageCompareGetErrors, GooglePageCompareApiIntegrationsGooglePageCompareGetResponse, GooglePageCompareApiIntegrationsGooglePageCompareGetResponses, GooglePageDataApiIntegrationsGooglePageDataGetData, GooglePageDataApiIntegrationsGooglePageDataGetError, GooglePageDataApiIntegrationsGooglePageDataGetErrors, GooglePageDataApiIntegrationsGooglePageDataGetResponse, GooglePageDataApiIntegrationsGooglePageDataGetResponses, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetError, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponse, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses, GooglePageLiveApiIntegrationsGooglePageLivePostData, GooglePageLiveApiIntegrationsGooglePageLivePostError, GooglePageLiveApiIntegrationsGooglePageLivePostErrors, GooglePageLiveApiIntegrationsGooglePageLivePostResponse, GooglePageLiveApiIntegrationsGooglePageLivePostResponses, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetError, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponse, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetError, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponse, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses, GoogleStatusApiIntegrationsGoogleStatusGetData, GoogleStatusApiIntegrationsGoogleStatusGetResponse, GoogleStatusApiIntegrationsGoogleStatusGetResponses, GoogleTestApiIntegrationsGoogleTestPostData, GoogleTestApiIntegrationsGoogleTestPostResponse, GoogleTestApiIntegrationsGoogleTestPostResponses, HealthCheckApiHealthGetData, HealthCheckApiHealthGetResponse, HealthCheckApiHealthGetResponses, HttpValidationError, IssuesActionPlanApiIssuesActionPlanPostData, IssuesActionPlanApiIssuesActionPlanPostError, IssuesActionPlanApiIssuesActionPlanPostErrors, IssuesActionPlanApiIssuesActionPlanPostResponses, IssuesFixSuggestionApiIssuesFixSuggestionPostData, IssuesFixSuggestionApiIssuesFixSuggestionPostError, IssuesFixSuggestionApiIssuesFixSuggestionPostErrors, IssuesFixSuggestionApiIssuesFixSuggestionPostResponses, JobsListResponse, KeywordsCompetitorImportApiKeywordsCompetitorImportPostData, KeywordsCompetitorImportApiKeywordsCompetitorImportPostError, KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponse, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses, KeywordsContentBriefApiKeywordsContentBriefPostData, KeywordsContentBriefApiKeywordsContentBriefPostError, KeywordsContentBriefApiKeywordsContentBriefPostErrors, KeywordsContentBriefApiKeywordsContentBriefPostResponse, KeywordsContentBriefApiKeywordsContentBriefPostResponses, ListContentDraftsApiContentDraftsGetData, ListContentDraftsApiContentDraftsGetError, ListContentDraftsApiContentDraftsGetErrors, ListContentDraftsApiContentDraftsGetResponse, ListContentDraftsApiContentDraftsGetResponses, ListDashboardsApiDashboardsGetData, ListDashboardsApiDashboardsGetError, ListDashboardsApiDashboardsGetErrors, ListDashboardsApiDashboardsGetResponse, ListDashboardsApiDashboardsGetResponses, ListFiltersApiFiltersGetData, ListFiltersApiFiltersGetError, ListFiltersApiFiltersGetErrors, ListFiltersApiFiltersGetResponse, ListFiltersApiFiltersGetResponses, ListIssueStatusApiIssuesStatusGetData, ListIssueStatusApiIssuesStatusGetError, ListIssueStatusApiIssuesStatusGetErrors, ListIssueStatusApiIssuesStatusGetResponse, ListIssueStatusApiIssuesStatusGetResponses, ListPageMarkdownApiPageMarkdownGetData, ListPageMarkdownApiPageMarkdownGetError, ListPageMarkdownApiPageMarkdownGetErrors, ListPageMarkdownApiPageMarkdownGetResponse, ListPageMarkdownApiPageMarkdownGetResponses, ListPipelineJobsApiJobsGetData, ListPipelineJobsApiJobsGetError, ListPipelineJobsApiJobsGetErrors, ListPipelineJobsApiJobsGetResponse, ListPipelineJobsApiJobsGetResponses, ListPropertiesApiPropertiesGetData, ListPropertiesApiPropertiesGetResponse, ListPropertiesApiPropertiesGetResponses, ListSessionsApiChatSessionsGetData, ListSessionsApiChatSessionsGetError, ListSessionsApiChatSessionsGetErrors, ListSessionsApiChatSessionsGetResponse, ListSessionsApiChatSessionsGetResponses, LlmConfigBody, LogsUploadApiLogsUploadPostData, LogsUploadApiLogsUploadPostError, LogsUploadApiLogsUploadPostErrors, LogsUploadApiLogsUploadPostResponse, LogsUploadApiLogsUploadPostResponses, McpToolsApiMcpToolsGetData, McpToolsApiMcpToolsGetResponse, McpToolsApiMcpToolsGetResponses, MobileDeltaApiReportMobileDeltaGetData, MobileDeltaApiReportMobileDeltaGetError, MobileDeltaApiReportMobileDeltaGetErrors, MobileDeltaApiReportMobileDeltaGetResponse, MobileDeltaApiReportMobileDeltaGetResponses, OllamaStatusApiOllamaStatusGetData, OllamaStatusApiOllamaStatusGetResponse, OllamaStatusApiOllamaStatusGetResponses, OpsSettingsBody, PageCoachApiLinksPageCoachPostData, PageCoachApiLinksPageCoachPostError, PageCoachApiLinksPageCoachPostErrors, PageCoachApiLinksPageCoachPostResponse, PageCoachApiLinksPageCoachPostResponses, PageCoachBody, PageMarkdownContentApiPageMarkdownContentGetData, PageMarkdownContentApiPageMarkdownContentGetError, PageMarkdownContentApiPageMarkdownContentGetErrors, PageMarkdownContentApiPageMarkdownContentGetResponse, PageMarkdownContentApiPageMarkdownContentGetResponses, PageMarkdownExtractApiPageMarkdownExtractPostData, PageMarkdownExtractApiPageMarkdownExtractPostError, PageMarkdownExtractApiPageMarkdownExtractPostErrors, PageMarkdownExtractApiPageMarkdownExtractPostResponse, PageMarkdownExtractApiPageMarkdownExtractPostResponses, PageMarkdownRunsApiPageMarkdownRunsGetData, PageMarkdownRunsApiPageMarkdownRunsGetError, PageMarkdownRunsApiPageMarkdownRunsGetErrors, PageMarkdownRunsApiPageMarkdownRunsGetResponse, PageMarkdownRunsApiPageMarkdownRunsGetResponses, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchError, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponse, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses, PausePipelineJobApiJobsJobIdPausePostData, PausePipelineJobApiJobsJobIdPausePostError, PausePipelineJobApiJobsJobIdPausePostErrors, PausePipelineJobApiJobsJobIdPausePostResponse, PausePipelineJobApiJobsJobIdPausePostResponses, PauseResponse, PipelineConfigBody, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostError, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponse, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostError, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponse, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses, PresetBody, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostError, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponse, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetError, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponse, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetError, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponse, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetError, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponse, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostError, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponse, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses, PropertyUpsertBody, PutAppSettingApiAppSettingsPutData, PutAppSettingApiAppSettingsPutError, PutAppSettingApiAppSettingsPutErrors, PutAppSettingApiAppSettingsPutResponse, PutAppSettingApiAppSettingsPutResponses, PutLlmConfigApiLlmConfigPutData, PutLlmConfigApiLlmConfigPutError, PutLlmConfigApiLlmConfigPutErrors, PutLlmConfigApiLlmConfigPutResponse, PutLlmConfigApiLlmConfigPutResponses, PutPipelineConfigApiPipelineConfigPutData, PutPipelineConfigApiPipelineConfigPutError, PutPipelineConfigApiPipelineConfigPutErrors, PutPipelineConfigApiPipelineConfigPutResponse, PutPipelineConfigApiPipelineConfigPutResponses, PutSecretsApiSecretsPutData, PutSecretsApiSecretsPutError, PutSecretsApiSecretsPutErrors, PutSecretsApiSecretsPutResponse, PutSecretsApiSecretsPutResponses, ReportHistoryApiReportHistoryGetData, ReportHistoryApiReportHistoryGetError, ReportHistoryApiReportHistoryGetErrors, ReportHistoryApiReportHistoryGetResponse, ReportHistoryApiReportHistoryGetResponses, ReportMetaApiReportMetaGetData, ReportMetaApiReportMetaGetResponse, ReportMetaApiReportMetaGetResponses, ReportPayloadApiReportPayloadGetData, ReportPayloadApiReportPayloadGetError, ReportPayloadApiReportPayloadGetErrors, ReportPayloadApiReportPayloadGetResponse, ReportPayloadApiReportPayloadGetResponses, ReportPortfolioApiReportPortfolioGetData, ReportPortfolioApiReportPortfolioGetError, ReportPortfolioApiReportPortfolioGetErrors, ReportPortfolioApiReportPortfolioGetResponse, ReportPortfolioApiReportPortfolioGetResponses, ResolvePropertyApiPropertiesResolveGetData, ResolvePropertyApiPropertiesResolveGetError, ResolvePropertyApiPropertiesResolveGetErrors, ResolvePropertyApiPropertiesResolveGetResponse, ResolvePropertyApiPropertiesResolveGetResponses, ResumePipelineJobApiJobsJobIdResumePostData, ResumePipelineJobApiJobsJobIdResumePostError, ResumePipelineJobApiJobsJobIdResumePostErrors, ResumePipelineJobApiJobsJobIdResumePostResponse, ResumePipelineJobApiJobsJobIdResumePostResponses, ResumeResponse, RunAuditToolApiReportAuditToolPostData, RunAuditToolApiReportAuditToolPostError, RunAuditToolApiReportAuditToolPostErrors, RunAuditToolApiReportAuditToolPostResponse, RunAuditToolApiReportAuditToolPostResponses, RunPipelineApiRunPostData, RunPipelineApiRunPostError, RunPipelineApiRunPostErrors, RunPipelineApiRunPostResponse, RunPipelineApiRunPostResponses, RunPostBody, RunResponse, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostData, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostError, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponse, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses, ScheduleCheckApiScheduleCheckPostData, ScheduleCheckApiScheduleCheckPostResponse, ScheduleCheckApiScheduleCheckPostResponses, SecretsBody, UnknownKeyEntry, UpdateContentDraftApiContentDraftsDraftIdPatchData, UpdateContentDraftApiContentDraftsDraftIdPatchError, UpdateContentDraftApiContentDraftsDraftIdPatchErrors, UpdateContentDraftApiContentDraftsDraftIdPatchResponse, UpdateContentDraftApiContentDraftsDraftIdPatchResponses, UpdateDashboardApiDashboardsDashboardIdPutData, UpdateDashboardApiDashboardsDashboardIdPutError, UpdateDashboardApiDashboardsDashboardIdPutErrors, UpdateDashboardApiDashboardsDashboardIdPutResponse, UpdateDashboardApiDashboardsDashboardIdPutResponses, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutError, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponse, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutError, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponse, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostData, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostError, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponse, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses, UpsertFilterApiFiltersPostData, UpsertFilterApiFiltersPostError, UpsertFilterApiFiltersPostErrors, UpsertFilterApiFiltersPostResponse, UpsertFilterApiFiltersPostResponses, UpsertIssueStatusApiIssuesStatusPutData, UpsertIssueStatusApiIssuesStatusPutError, UpsertIssueStatusApiIssuesStatusPutErrors, UpsertIssueStatusApiIssuesStatusPutResponse, UpsertIssueStatusApiIssuesStatusPutResponses, ValidationError } from './types.gen'; diff --git a/web/src/client/sdk.gen.ts b/web/src/client/sdk.gen.ts new file mode 100644 index 00000000..0586f231 --- /dev/null +++ b/web/src/client/sdk.gen.ts @@ -0,0 +1,910 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientMeta, formDataBodySerializer, type Options as Options2, type RequestResult, type TDataShape } from './client'; +import { client } from './client.gen'; +import type { AiFixSuggestionApiAiFixSuggestionPostData, AiFixSuggestionApiAiFixSuggestionPostErrors, AiFixSuggestionApiAiFixSuggestionPostResponses, AlertsCheckApiAlertsCheckPostData, AlertsCheckApiAlertsCheckPostErrors, AlertsCheckApiAlertsCheckPostResponses, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses, BacklinksCompetitorImportApiBacklinksCompetitorImportPostData, BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses, BacklinksVelocityApiBacklinksVelocityGetData, BacklinksVelocityApiBacklinksVelocityGetErrors, BacklinksVelocityApiBacklinksVelocityGetResponses, BingSyncApiIntegrationsBingSyncPostData, BingSyncApiIntegrationsBingSyncPostResponses, BrowserStatusCheckApiCrawlBrowserStatusGetData, BrowserStatusCheckApiCrawlBrowserStatusGetResponses, CancelPipelineJobApiJobsJobIdCancelPostData, CancelPipelineJobApiJobsJobIdCancelPostErrors, CancelPipelineJobApiJobsJobIdCancelPostResponses, ChatTurnApiChatPostData, ChatTurnApiChatPostErrors, ChatTurnApiChatPostResponses, CompareExportApiCompareExportPostData, CompareExportApiCompareExportPostErrors, CompareExportApiCompareExportPostResponses, ContentAnalyzeApiContentAnalyzePostData, ContentAnalyzeApiContentAnalyzePostErrors, ContentAnalyzeApiContentAnalyzePostResponses, ContentScoreApiContentScorePostData, ContentScoreApiContentScorePostErrors, ContentScoreApiContentScorePostResponses, ContentWizardApiContentWizardPostData, ContentWizardApiContentWizardPostErrors, ContentWizardApiContentWizardPostResponses, CrawlPayloadApiReportCrawlPayloadGetData, CrawlPayloadApiReportCrawlPayloadGetErrors, CrawlPayloadApiReportCrawlPayloadGetResponses, CreateContentDraftApiContentDraftsPostData, CreateContentDraftApiContentDraftsPostErrors, CreateContentDraftApiContentDraftsPostResponses, CreateDashboardApiDashboardsPostData, CreateDashboardApiDashboardsPostErrors, CreateDashboardApiDashboardsPostResponses, CreatePropertyApiPropertiesPostData, CreatePropertyApiPropertiesPostErrors, CreatePropertyApiPropertiesPostResponses, CreateSessionApiChatSessionsPostData, CreateSessionApiChatSessionsPostErrors, CreateSessionApiChatSessionsPostResponses, DashboardsAiGenerateApiDashboardsAiGeneratePostData, DashboardsAiGenerateApiDashboardsAiGeneratePostErrors, DashboardsAiGenerateApiDashboardsAiGeneratePostResponses, DeleteContentDraftApiContentDraftsDraftIdDeleteData, DeleteContentDraftApiContentDraftsDraftIdDeleteErrors, DeleteContentDraftApiContentDraftsDraftIdDeleteResponses, DeleteDashboardApiDashboardsDashboardIdDeleteData, DeleteDashboardApiDashboardsDashboardIdDeleteErrors, DeleteDashboardApiDashboardsDashboardIdDeleteResponses, DeleteFilterApiFiltersDeleteData, DeleteFilterApiFiltersDeleteErrors, DeleteFilterApiFiltersDeleteResponses, DeletePageMarkdownApiPageMarkdownDeleteData, DeletePageMarkdownApiPageMarkdownDeleteErrors, DeletePageMarkdownApiPageMarkdownDeleteResponses, DeletePortfolioItemApiPortfolioDeleteDeleteData, DeletePortfolioItemApiPortfolioDeleteDeleteErrors, DeletePortfolioItemApiPortfolioDeleteDeleteResponses, DeletePropertyApiPropertiesPropertyIdDeleteData, DeletePropertyApiPropertiesPropertyIdDeleteErrors, DeletePropertyApiPropertiesPropertyIdDeleteResponses, DeleteSessionRouteApiChatSessionsSessionIdDeleteData, DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors, DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses, ExportReportApiReportExportGetData, ExportReportApiReportExportGetErrors, ExportReportApiReportExportGetResponses, ExportSitemapApiReportExportSitemapGetData, ExportSitemapApiReportExportSitemapGetErrors, ExportSitemapApiReportExportSitemapGetResponses, ExportWorkbookApiReportExportWorkbookGetData, ExportWorkbookApiReportExportWorkbookGetErrors, ExportWorkbookApiReportExportWorkbookGetResponses, GetAppSettingApiAppSettingsGetData, GetAppSettingApiAppSettingsGetErrors, GetAppSettingApiAppSettingsGetResponses, GetArtifactApiChatArtifactsArtifactIdGetData, GetArtifactApiChatArtifactsArtifactIdGetErrors, GetArtifactApiChatArtifactsArtifactIdGetResponses, GetContentDraftApiContentDraftsDraftIdGetData, GetContentDraftApiContentDraftsDraftIdGetErrors, GetContentDraftApiContentDraftsDraftIdGetResponses, GetDashboardApiDashboardsDashboardIdGetData, GetDashboardApiDashboardsDashboardIdGetErrors, GetDashboardApiDashboardsDashboardIdGetResponses, GetLlmConfigApiLlmConfigGetData, GetLlmConfigApiLlmConfigGetResponses, GetPageHtmlApiCrawlPageHtmlGetData, GetPageHtmlApiCrawlPageHtmlGetErrors, GetPageHtmlApiCrawlPageHtmlGetResponses, GetPipelineConfigApiPipelineConfigGetData, GetPipelineConfigApiPipelineConfigGetResponses, GetPipelineJobApiJobsJobIdGetData, GetPipelineJobApiJobsJobIdGetErrors, GetPipelineJobApiJobsJobIdGetResponses, GetPropertyApiPropertiesPropertyIdGetData, GetPropertyApiPropertiesPropertyIdGetErrors, GetPropertyApiPropertiesPropertyIdGetResponses, GetPropertyOpsApiPropertiesPropertyIdOpsGetData, GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors, GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses, GetPropertyPresetApiPropertiesPropertyIdPresetGetData, GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses, GetSecretsApiSecretsGetData, GetSecretsApiSecretsGetResponses, GetSessionMessagesApiChatSessionsSessionIdMessagesGetData, GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors, GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses, GetSessionRouteApiChatSessionsSessionIdGetData, GetSessionRouteApiChatSessionsSessionIdGetErrors, GetSessionRouteApiChatSessionsSessionIdGetResponses, GoogleDisconnectApiIntegrationsGoogleDisconnectPostData, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses, GooglePageCompareApiIntegrationsGooglePageCompareGetData, GooglePageCompareApiIntegrationsGooglePageCompareGetErrors, GooglePageCompareApiIntegrationsGooglePageCompareGetResponses, GooglePageDataApiIntegrationsGooglePageDataGetData, GooglePageDataApiIntegrationsGooglePageDataGetErrors, GooglePageDataApiIntegrationsGooglePageDataGetResponses, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses, GooglePageLiveApiIntegrationsGooglePageLivePostData, GooglePageLiveApiIntegrationsGooglePageLivePostErrors, GooglePageLiveApiIntegrationsGooglePageLivePostResponses, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses, GoogleStatusApiIntegrationsGoogleStatusGetData, GoogleStatusApiIntegrationsGoogleStatusGetResponses, GoogleTestApiIntegrationsGoogleTestPostData, GoogleTestApiIntegrationsGoogleTestPostResponses, HealthCheckApiHealthGetData, HealthCheckApiHealthGetResponses, IssuesActionPlanApiIssuesActionPlanPostData, IssuesActionPlanApiIssuesActionPlanPostErrors, IssuesActionPlanApiIssuesActionPlanPostResponses, IssuesFixSuggestionApiIssuesFixSuggestionPostData, IssuesFixSuggestionApiIssuesFixSuggestionPostErrors, IssuesFixSuggestionApiIssuesFixSuggestionPostResponses, KeywordsCompetitorImportApiKeywordsCompetitorImportPostData, KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses, KeywordsContentBriefApiKeywordsContentBriefPostData, KeywordsContentBriefApiKeywordsContentBriefPostErrors, KeywordsContentBriefApiKeywordsContentBriefPostResponses, ListContentDraftsApiContentDraftsGetData, ListContentDraftsApiContentDraftsGetErrors, ListContentDraftsApiContentDraftsGetResponses, ListDashboardsApiDashboardsGetData, ListDashboardsApiDashboardsGetErrors, ListDashboardsApiDashboardsGetResponses, ListFiltersApiFiltersGetData, ListFiltersApiFiltersGetErrors, ListFiltersApiFiltersGetResponses, ListIssueStatusApiIssuesStatusGetData, ListIssueStatusApiIssuesStatusGetErrors, ListIssueStatusApiIssuesStatusGetResponses, ListPageMarkdownApiPageMarkdownGetData, ListPageMarkdownApiPageMarkdownGetErrors, ListPageMarkdownApiPageMarkdownGetResponses, ListPipelineJobsApiJobsGetData, ListPipelineJobsApiJobsGetErrors, ListPipelineJobsApiJobsGetResponses, ListPropertiesApiPropertiesGetData, ListPropertiesApiPropertiesGetResponses, ListSessionsApiChatSessionsGetData, ListSessionsApiChatSessionsGetErrors, ListSessionsApiChatSessionsGetResponses, LogsUploadApiLogsUploadPostData, LogsUploadApiLogsUploadPostErrors, LogsUploadApiLogsUploadPostResponses, McpToolsApiMcpToolsGetData, McpToolsApiMcpToolsGetResponses, MobileDeltaApiReportMobileDeltaGetData, MobileDeltaApiReportMobileDeltaGetErrors, MobileDeltaApiReportMobileDeltaGetResponses, OllamaStatusApiOllamaStatusGetData, OllamaStatusApiOllamaStatusGetResponses, PageCoachApiLinksPageCoachPostData, PageCoachApiLinksPageCoachPostErrors, PageCoachApiLinksPageCoachPostResponses, PageMarkdownContentApiPageMarkdownContentGetData, PageMarkdownContentApiPageMarkdownContentGetErrors, PageMarkdownContentApiPageMarkdownContentGetResponses, PageMarkdownExtractApiPageMarkdownExtractPostData, PageMarkdownExtractApiPageMarkdownExtractPostErrors, PageMarkdownExtractApiPageMarkdownExtractPostResponses, PageMarkdownRunsApiPageMarkdownRunsGetData, PageMarkdownRunsApiPageMarkdownRunsGetErrors, PageMarkdownRunsApiPageMarkdownRunsGetResponses, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses, PausePipelineJobApiJobsJobIdPausePostData, PausePipelineJobApiJobsJobIdPausePostErrors, PausePipelineJobApiJobsJobIdPausePostResponses, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses, PutAppSettingApiAppSettingsPutData, PutAppSettingApiAppSettingsPutErrors, PutAppSettingApiAppSettingsPutResponses, PutLlmConfigApiLlmConfigPutData, PutLlmConfigApiLlmConfigPutErrors, PutLlmConfigApiLlmConfigPutResponses, PutPipelineConfigApiPipelineConfigPutData, PutPipelineConfigApiPipelineConfigPutErrors, PutPipelineConfigApiPipelineConfigPutResponses, PutSecretsApiSecretsPutData, PutSecretsApiSecretsPutErrors, PutSecretsApiSecretsPutResponses, ReportHistoryApiReportHistoryGetData, ReportHistoryApiReportHistoryGetErrors, ReportHistoryApiReportHistoryGetResponses, ReportMetaApiReportMetaGetData, ReportMetaApiReportMetaGetResponses, ReportPayloadApiReportPayloadGetData, ReportPayloadApiReportPayloadGetErrors, ReportPayloadApiReportPayloadGetResponses, ReportPortfolioApiReportPortfolioGetData, ReportPortfolioApiReportPortfolioGetErrors, ReportPortfolioApiReportPortfolioGetResponses, ResolvePropertyApiPropertiesResolveGetData, ResolvePropertyApiPropertiesResolveGetErrors, ResolvePropertyApiPropertiesResolveGetResponses, ResumePipelineJobApiJobsJobIdResumePostData, ResumePipelineJobApiJobsJobIdResumePostErrors, ResumePipelineJobApiJobsJobIdResumePostResponses, RunAuditToolApiReportAuditToolPostData, RunAuditToolApiReportAuditToolPostErrors, RunAuditToolApiReportAuditToolPostResponses, RunPipelineApiRunPostData, RunPipelineApiRunPostErrors, RunPipelineApiRunPostResponses, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostData, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses, ScheduleCheckApiScheduleCheckPostData, ScheduleCheckApiScheduleCheckPostResponses, UpdateContentDraftApiContentDraftsDraftIdPatchData, UpdateContentDraftApiContentDraftsDraftIdPatchErrors, UpdateContentDraftApiContentDraftsDraftIdPatchResponses, UpdateDashboardApiDashboardsDashboardIdPutData, UpdateDashboardApiDashboardsDashboardIdPutErrors, UpdateDashboardApiDashboardsDashboardIdPutResponses, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostData, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses, UpsertFilterApiFiltersPostData, UpsertFilterApiFiltersPostErrors, UpsertFilterApiFiltersPostResponses, UpsertIssueStatusApiIssuesStatusPutData, UpsertIssueStatusApiIssuesStatusPutErrors, UpsertIssueStatusApiIssuesStatusPutResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +/** + * Health Check + */ +export const healthCheckApiHealthGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/health', ...options }); + +/** + * Report Meta + */ +export const reportMetaApiReportMetaGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/meta', ...options }); + +/** + * Report Payload + */ +export const reportPayloadApiReportPayloadGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/payload', ...options }); + +/** + * Report History + */ +export const reportHistoryApiReportHistoryGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/history', ...options }); + +/** + * Crawl Payload + */ +export const crawlPayloadApiReportCrawlPayloadGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/crawl-payload', ...options }); + +/** + * Mobile Delta + */ +export const mobileDeltaApiReportMobileDeltaGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/mobile-delta', ...options }); + +/** + * Run Pipeline + */ +export const runPipelineApiRunPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/run', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List Pipeline Jobs + */ +export const listPipelineJobsApiJobsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/jobs', ...options }); + +/** + * Get Pipeline Job + */ +export const getPipelineJobApiJobsJobIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/jobs/{job_id}', ...options }); + +/** + * Cancel Pipeline Job + */ +export const cancelPipelineJobApiJobsJobIdCancelPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/jobs/{job_id}/cancel', ...options }); + +/** + * Pause Pipeline Job + */ +export const pausePipelineJobApiJobsJobIdPausePost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/jobs/{job_id}/pause', ...options }); + +/** + * Resume Pipeline Job + */ +export const resumePipelineJobApiJobsJobIdResumePost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/jobs/{job_id}/resume', ...options }); + +/** + * Chat Turn + */ +export const chatTurnApiChatPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/chat/', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List Sessions + */ +export const listSessionsApiChatSessionsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/sessions', ...options }); + +/** + * Create Session + */ +export const createSessionApiChatSessionsPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/chat/sessions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete Session Route + */ +export const deleteSessionRouteApiChatSessionsSessionIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/chat/sessions/{session_id}', ...options }); + +/** + * Get Session Route + */ +export const getSessionRouteApiChatSessionsSessionIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/sessions/{session_id}', ...options }); + +/** + * Get Session Messages + */ +export const getSessionMessagesApiChatSessionsSessionIdMessagesGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/sessions/{session_id}/messages', ...options }); + +/** + * Get Artifact + */ +export const getArtifactApiChatArtifactsArtifactIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/artifacts/{artifact_id}', ...options }); + +/** + * Browser Status Check + * + * Return whether Playwright + Chromium are available. + */ +export const browserStatusCheckApiCrawlBrowserStatusGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/crawl/browser-status', ...options }); + +/** + * Get Page Html + * + * Return stored HTML and metadata for a URL within a crawl run. + */ +export const getPageHtmlApiCrawlPageHtmlGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/crawl/page-html', ...options }); + +/** + * Get Pipeline Config + */ +export const getPipelineConfigApiPipelineConfigGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/pipeline-config', ...options }); + +/** + * Put Pipeline Config + */ +export const putPipelineConfigApiPipelineConfigPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/pipeline-config', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get Llm Config + */ +export const getLlmConfigApiLlmConfigGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/llm-config', ...options }); + +/** + * Put Llm Config + */ +export const putLlmConfigApiLlmConfigPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/llm-config', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get Secrets + */ +export const getSecretsApiSecretsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/secrets', ...options }); + +/** + * Put Secrets + */ +export const putSecretsApiSecretsPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/secrets', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get App Setting + */ +export const getAppSettingApiAppSettingsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/app-settings', ...options }); + +/** + * Put App Setting + */ +export const putAppSettingApiAppSettingsPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/app-settings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List Properties + */ +export const listPropertiesApiPropertiesGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/properties', ...options }); + +/** + * Create Property + */ +export const createPropertyApiPropertiesPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/properties', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Resolve Property + */ +export const resolvePropertyApiPropertiesResolveGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/resolve', ...options }); + +/** + * Delete Property + */ +export const deletePropertyApiPropertiesPropertyIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/properties/{property_id}', ...options }); + +/** + * Get Property + */ +export const getPropertyApiPropertiesPropertyIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}', ...options }); + +/** + * Get Property Ops + */ +export const getPropertyOpsApiPropertiesPropertyIdOpsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/ops', ...options }); + +/** + * Update Property Ops + */ +export const updatePropertyOpsApiPropertiesPropertyIdOpsPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/properties/{property_id}/ops', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get Property Preset + */ +export const getPropertyPresetApiPropertiesPropertyIdPresetGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/preset', ...options }); + +/** + * Update Property Preset + */ +export const updatePropertyPresetApiPropertiesPropertyIdPresetPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/properties/{property_id}/preset', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Authorize Property Crawl + * + * Mark property as crawl-authorized (used by OAuth flow). + */ +export const authorizePropertyCrawlApiPropertiesPropertyIdAuthorizePost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/authorize', ...options }); + +/** + * Property Google Status + * + * Return property-level Google integration status. + */ +export const propertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/google/status', ...options }); + +/** + * Property Google Test + * + * Run a quick Google API connectivity test for the property. + */ +export const propertyGoogleTestApiPropertiesPropertyIdGoogleTestPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/test', ...options }); + +/** + * Property Google Properties + * + * List GA4 / GSC properties available for this account. + */ +export const propertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/google/properties', ...options }); + +/** + * Property Google Links Status + * + * Return the status of GSC backlinks import for this property. + */ +export const propertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/google/links/status', ...options }); + +/** + * Property Google Links Import + * + * Trigger a GSC backlinks import for this property. + */ +export const propertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/links/import', ...options }); + +/** + * Patch Property Google Credentials + * + * Update Google credentials/settings for a property (used by OAuth callback). + */ +export const patchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatch = (options: Options): RequestResult => (options.client ?? client).patch({ + url: '/api/properties/{property_id}/google/credentials', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post Property Google Credentials + * + * Update Google site/property settings from the integrations UI. + */ +export const postPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/properties/{property_id}/google/credentials', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Post Property Google Disconnect + * + * Clear OAuth tokens for a property. + */ +export const postPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/disconnect', ...options }); + +/** + * List Dashboards + */ +export const listDashboardsApiDashboardsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/dashboards', ...options }); + +/** + * Create Dashboard + */ +export const createDashboardApiDashboardsPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/dashboards', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete Dashboard + */ +export const deleteDashboardApiDashboardsDashboardIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/dashboards/{dashboard_id}', ...options }); + +/** + * Get Dashboard + */ +export const getDashboardApiDashboardsDashboardIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/dashboards/{dashboard_id}', ...options }); + +/** + * Update Dashboard + */ +export const updateDashboardApiDashboardsDashboardIdPut = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/api/dashboards/{dashboard_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Dashboards Ai Generate + * + * Generate DashScript, a widget, or a full dashboard via LLM. + */ +export const dashboardsAiGenerateApiDashboardsAiGeneratePost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/dashboards/ai-generate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete Filter + */ +export const deleteFilterApiFiltersDelete = (options: Options): RequestResult => (options.client ?? client).delete({ + url: '/api/filters', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List Filters + */ +export const listFiltersApiFiltersGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/filters', ...options }); + +/** + * Upsert Filter + */ +export const upsertFilterApiFiltersPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/filters', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Google Status + */ +export const googleStatusApiIntegrationsGoogleStatusGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/integrations/google/status', ...options }); + +/** + * Save Google Credentials + */ +export const saveGoogleCredentialsApiIntegrationsGoogleCredentialsPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/integrations/google/credentials', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Upload Google Credentials + */ +export const uploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/integrations/google/credentials/upload', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Google Disconnect + * + * Global disconnect is deprecated — use per-property disconnect. + */ +export const googleDisconnectApiIntegrationsGoogleDisconnectPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/integrations/google/disconnect', ...options }); + +/** + * Google Properties Deprecated + * + * Deprecated — use /api/properties/{id}/google/properties. + */ +export const googlePropertiesDeprecatedApiIntegrationsGooglePropertiesGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/integrations/google/properties', ...options }); + +/** + * Google Test + * + * Run `python -m src google --test` and return stdout log. + */ +export const googleTestApiIntegrationsGoogleTestPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/integrations/google/test', ...options }); + +/** + * Google Page Data + */ +export const googlePageDataApiIntegrationsGooglePageDataGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/integrations/google/page-data', ...options }); + +/** + * Google Page Data History + */ +export const googlePageDataHistoryApiIntegrationsGooglePageDataHistoryGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/integrations/google/page-data/history', ...options }); + +/** + * Google Page Live + */ +export const googlePageLiveApiIntegrationsGooglePageLivePost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/integrations/google/page-live', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Google Keywords By Page + */ +export const googleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/integrations/google/keywords/by-page', ...options }); + +/** + * Google Keywords History + */ +export const googleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/integrations/google/keywords/history', ...options }); + +/** + * Bing Sync + * + * Fetch Bing Webmaster backlinks summary using config from DB. + */ +export const bingSyncApiIntegrationsBingSyncPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/integrations/bing/sync', ...options }); + +/** + * Google Page Compare + * + * Compare two page Google data snapshots. + */ +export const googlePageCompareApiIntegrationsGooglePageCompareGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/integrations/google/page-compare', ...options }); + +/** + * Google Page Live History + * + * Return history of page Google snapshots for a URL. + */ +export const googlePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/integrations/google/page-live/history', ...options }); + +/** + * Google Keywords History Batch + * + * Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str } + */ +export const googleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/integrations/google/keywords/history/batch', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Google Keywords Expand + * + * Expand keyword ideas from Google Keyword Planner or suggest API. + */ +export const googleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/integrations/google/keywords/expand', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Google Keywords Planner + * + * Fetch keyword planner data from Google Ads API. + */ +export const googleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/integrations/google/keywords/planner', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List Issue Status + */ +export const listIssueStatusApiIssuesStatusGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/issues/status', ...options }); + +/** + * Upsert Issue Status + */ +export const upsertIssueStatusApiIssuesStatusPut = (options?: Options): RequestResult => (options?.client ?? client).put({ + url: '/api/issues/status', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Issues Fix Suggestion + */ +export const issuesFixSuggestionApiIssuesFixSuggestionPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/issues/fix-suggestion', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Issues Action Plan + */ +export const issuesActionPlanApiIssuesActionPlanPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/issues/action-plan', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Ai Fix Suggestion + */ +export const aiFixSuggestionApiAiFixSuggestionPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/ai/fix-suggestion', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Keywords Competitor Import + */ +export const keywordsCompetitorImportApiKeywordsCompetitorImportPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/keywords/competitor-import', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Keywords Content Brief + */ +export const keywordsContentBriefApiKeywordsContentBriefPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/keywords/content-brief', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Backlinks Velocity + */ +export const backlinksVelocityApiBacklinksVelocityGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/backlinks/velocity', ...options }); + +/** + * Backlinks Competitor Import + */ +export const backlinksCompetitorImportApiBacklinksCompetitorImportPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/backlinks/competitor-import', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Backlinks Third Party Import + */ +export const backlinksThirdPartyImportApiBacklinksThirdPartyImportPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/backlinks/third-party-import', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Content Analyze + */ +export const contentAnalyzeApiContentAnalyzePost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/content/analyze', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Content Score + */ +export const contentScoreApiContentScorePost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/content/score', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Content Wizard + */ +export const contentWizardApiContentWizardPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/content/wizard', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * List Content Drafts + */ +export const listContentDraftsApiContentDraftsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/content-drafts', ...options }); + +/** + * Create Content Draft + */ +export const createContentDraftApiContentDraftsPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/content-drafts', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Delete Content Draft + */ +export const deleteContentDraftApiContentDraftsDraftIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/content-drafts/{draft_id}', ...options }); + +/** + * Get Content Draft + */ +export const getContentDraftApiContentDraftsDraftIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/content-drafts/{draft_id}', ...options }); + +/** + * Update Content Draft + */ +export const updateContentDraftApiContentDraftsDraftIdPatch = (options: Options): RequestResult => (options.client ?? client).patch({ + url: '/api/content-drafts/{draft_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete Page Markdown + */ +export const deletePageMarkdownApiPageMarkdownDelete = (options?: Options): RequestResult => (options?.client ?? client).delete({ + url: '/api/page-markdown', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * List Page Markdown + */ +export const listPageMarkdownApiPageMarkdownGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/page-markdown', ...options }); + +/** + * Page Markdown Content + */ +export const pageMarkdownContentApiPageMarkdownContentGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/page-markdown/content', ...options }); + +/** + * Page Markdown Extract + */ +export const pageMarkdownExtractApiPageMarkdownExtractPost = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/api/page-markdown/extract', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Page Markdown Runs + */ +export const pageMarkdownRunsApiPageMarkdownRunsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/page-markdown/runs', ...options }); + +/** + * Ollama Status + */ +export const ollamaStatusApiOllamaStatusGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/ollama/status', ...options }); + +/** + * Mcp Tools + */ +export const mcpToolsApiMcpToolsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/mcp-tools', ...options }); + +/** + * Delete Portfolio Item + */ +export const deletePortfolioItemApiPortfolioDeleteDelete = (options: Options): RequestResult => (options.client ?? client).delete({ + url: '/api/portfolio/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Alerts Check + */ +export const alertsCheckApiAlertsCheckPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/alerts/check', ...options }); + +/** + * Schedule Check + */ +export const scheduleCheckApiScheduleCheckPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/schedule/check', ...options }); + +/** + * Logs Upload + */ +export const logsUploadApiLogsUploadPost = (options: Options): RequestResult => (options.client ?? client).post({ + ...formDataBodySerializer, + url: '/api/logs/upload', + ...options, + headers: { + 'Content-Type': null, + ...options.headers + } +}); + +/** + * Compare Export + */ +export const compareExportApiCompareExportPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/compare/export', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Page Coach + */ +export const pageCoachApiLinksPageCoachPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/links/page-coach', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Run Audit Tool + */ +export const runAuditToolApiReportAuditToolPost = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/report/audit-tool', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Export Report + */ +export const exportReportApiReportExportGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/export', ...options }); + +/** + * Export Sitemap + */ +export const exportSitemapApiReportExportSitemapGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/export-sitemap', ...options }); + +/** + * Export Workbook + */ +export const exportWorkbookApiReportExportWorkbookGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/export-workbook', ...options }); + +/** + * Report Portfolio + * + * Return portfolio data — groups, crawl history, summary, or single card. + */ +export const reportPortfolioApiReportPortfolioGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/portfolio', ...options }); diff --git a/web/src/client/types.gen.ts b/web/src/client/types.gen.ts new file mode 100644 index 00000000..ca20365c --- /dev/null +++ b/web/src/client/types.gen.ts @@ -0,0 +1,4097 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://openapi.json` | (string & {}); +}; + +/** + * AppSettingBody + */ +export type AppSettingBody = { + /** + * Key + */ + key: string; + /** + * Value + */ + value: string; +}; + +/** + * AuditToolBody + */ +export type AuditToolBody = { + /** + * Toolname + */ + toolName: string; + /** + * Propertyid + */ + propertyId: number; + /** + * Reportid + */ + reportId?: number | null; + /** + * Args + */ + args?: { + [key: string]: unknown; + }; +}; + +/** + * Body_logs_upload_api_logs_upload_post + */ +export type BodyLogsUploadApiLogsUploadPost = { + /** + * Propertyid + */ + propertyId: number; + /** + * File + */ + file: Blob | File; +}; + +/** + * CancelResponse + */ +export type CancelResponse = { + /** + * Ok + */ + ok: boolean; + /** + * Status + */ + status: string; + /** + * Error + */ + error?: string | null; +}; + +/** + * ChatRequest + */ +export type ChatRequest = { + /** + * Sessionid + */ + sessionId: number; + /** + * Propertyid + */ + propertyId: number; + /** + * Message + */ + message: string; + /** + * Reportid + */ + reportId?: number | null; +}; + +/** + * ChatSessionCreate + */ +export type ChatSessionCreate = { + /** + * Propertyid + */ + propertyId: number; + /** + * Title + */ + title?: string; +}; + +/** + * CompareExportBody + */ +export type CompareExportBody = { + /** + * Reportida + */ + reportIdA?: number | null; + /** + * Reportidb + */ + reportIdB?: number | null; +}; + +/** + * DashboardAiGenerateBody + */ +export type DashboardAiGenerateBody = { + /** + * Mode + */ + mode: string; + /** + * Prompt + */ + prompt: string; + /** + * Catalog + */ + catalog: Array<{ + [key: string]: unknown; + }>; + /** + * Viz Types + */ + viz_types: { + [key: string]: string; + }; + /** + * Dashscript Help + */ + dashscript_help: string; + /** + * Toolname + */ + toolName?: string | null; + /** + * Propertyid + */ + propertyId?: number | null; + /** + * Reportid + */ + reportId?: number | null; + /** + * Current + */ + current?: unknown | null; + /** + * Sample + */ + sample?: { + [key: string]: unknown; + } | null; +}; + +/** + * DashboardCreateBody + */ +export type DashboardCreateBody = { + /** + * Propertyid + */ + propertyId: number; + /** + * Name + */ + name?: string | null; + /** + * Layoutjson + */ + layoutJson?: unknown | null; +}; + +/** + * DashboardUpdateBody + */ +export type DashboardUpdateBody = { + /** + * Propertyid + */ + propertyId: number; + /** + * Name + */ + name?: string | null; + /** + * Layoutjson + */ + layoutJson?: unknown | null; + /** + * Isdefault + */ + isDefault?: boolean | null; +}; + +/** + * DeletePortfolioBody + */ +export type DeletePortfolioBody = { + /** + * Reportid + */ + reportId?: number | null; + /** + * Crawlrunid + */ + crawlRunId?: number | null; +}; + +/** + * FilterDeleteBody + */ +export type FilterDeleteBody = { + /** + * Propertyid + */ + propertyId: number; + /** + * Name + */ + name: string; +}; + +/** + * FilterUpsertBody + */ +export type FilterUpsertBody = { + /** + * Propertyid + */ + propertyId: number; + /** + * Name + */ + name: string; + /** + * Filterjson + */ + filterJson?: unknown | null; +}; + +/** + * GoogleCredentialsPatch + */ +export type GoogleCredentialsPatch = { + /** + * Refreshtoken + */ + refreshToken?: string | null; + /** + * Authmode + */ + authMode?: string | null; + /** + * Gscsiteurl + */ + gscSiteUrl?: string | null; + /** + * Ga4Propertyid + */ + ga4PropertyId?: string | null; + /** + * Daterangedays + */ + dateRangeDays?: number | null; + /** + * Connectedemail + */ + connectedEmail?: string | null; +}; + +/** + * GoogleCredentialsPostBody + */ +export type GoogleCredentialsPostBody = { + /** + * Gscsiteurl + */ + gscSiteUrl?: string | null; + /** + * Ga4Propertyid + */ + ga4PropertyId?: string | null; + /** + * Daterangedays + */ + dateRangeDays?: number | null; + /** + * Refreshtoken + */ + refreshToken?: string | null; +}; + +/** + * HTTPValidationError + */ +export type HttpValidationError = { + /** + * Detail + */ + detail?: Array; +}; + +/** + * JobsListResponse + */ +export type JobsListResponse = { + /** + * Jobs + */ + jobs: Array<{ + [key: string]: unknown; + }>; + /** + * Active + */ + active?: { + [key: string]: unknown; + } | null; + /** + * Reconciled + */ + reconciled?: number; +}; + +/** + * LlmConfigBody + */ +export type LlmConfigBody = { + /** + * State + */ + state: { + [key: string]: unknown; + }; +}; + +/** + * OpsSettingsBody + */ +export type OpsSettingsBody = { + /** + * Schedulecron + */ + scheduleCron?: string | null; + /** + * Alertwebhookurl + */ + alertWebhookUrl?: string | null; + /** + * Alertemail + */ + alertEmail?: string | null; +}; + +/** + * PageCoachBody + */ +export type PageCoachBody = { + /** + * Url + */ + url?: string | null; + /** + * Refresh + */ + refresh?: boolean; + /** + * Currenttype + */ + currentType?: string | null; + /** + * Currentid + */ + currentId?: number | null; + /** + * Baselinetype + */ + baselineType?: string | null; + /** + * Baselineid + */ + baselineId?: number | null; + /** + * Propertyid + */ + propertyId?: number | null; +}; + +/** + * PauseResponse + */ +export type PauseResponse = { + /** + * Ok + */ + ok: boolean; + /** + * Error + */ + error?: string | null; +}; + +/** + * PipelineConfigBody + */ +export type PipelineConfigBody = { + /** + * State + */ + state: { + [key: string]: unknown; + }; + /** + * Unknownkeys + */ + unknownKeys?: Array<{ + [key: string]: string; + }> | null; +}; + +/** + * PresetBody + */ +export type PresetBody = { + /** + * Preset + */ + preset?: string | null; +}; + +/** + * PropertyUpsertBody + */ +export type PropertyUpsertBody = { + /** + * Name + */ + name?: string | null; + /** + * Canonical Domain + */ + canonical_domain?: string | null; + /** + * Site Url + */ + site_url?: string | null; +}; + +/** + * ResumeResponse + */ +export type ResumeResponse = { + /** + * Ok + */ + ok: boolean; + /** + * Newjobid + */ + newJobId?: string | null; + /** + * Error + */ + error?: string | null; +}; + +/** + * RunPostBody + */ +export type RunPostBody = { + /** + * Command + */ + command?: string | null; + /** + * State + */ + state?: { + [key: string]: unknown; + } | null; + /** + * Unknownkeys + */ + unknownKeys?: Array; + /** + * Llmstate + */ + llmState?: { + [key: string]: unknown; + } | null; + /** + * Propertyid + */ + propertyId?: number | null; + /** + * Python + */ + python?: string | null; + /** + * Reporoot + */ + repoRoot?: string | null; +}; + +/** + * RunResponse + */ +export type RunResponse = { + /** + * Jobid + */ + jobId: string; +}; + +/** + * SecretsBody + */ +export type SecretsBody = { + /** + * State + */ + state: { + [key: string]: unknown; + }; +}; + +/** + * UnknownKeyEntry + */ +export type UnknownKeyEntry = { + /** + * Key + */ + key: string; + /** + * Value + */ + value: string; +}; + +/** + * ValidationError + */ +export type ValidationError = { + /** + * Location + */ + loc: Array; + /** + * Message + */ + msg: string; + /** + * Error Type + */ + type: string; + /** + * Input + */ + input?: unknown; + /** + * Context + */ + ctx?: { + [key: string]: unknown; + }; +}; + +export type HealthCheckApiHealthGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/health'; +}; + +export type HealthCheckApiHealthGetResponses = { + /** + * Response Health Check Api Health Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type HealthCheckApiHealthGetResponse = HealthCheckApiHealthGetResponses[keyof HealthCheckApiHealthGetResponses]; + +export type ReportMetaApiReportMetaGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/report/meta'; +}; + +export type ReportMetaApiReportMetaGetResponses = { + /** + * Response Report Meta Api Report Meta Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ReportMetaApiReportMetaGetResponse = ReportMetaApiReportMetaGetResponses[keyof ReportMetaApiReportMetaGetResponses]; + +export type ReportPayloadApiReportPayloadGetData = { + body?: never; + path?: never; + query?: { + /** + * Reportid + */ + reportId?: number | null; + /** + * Domain + */ + domain?: string | null; + /** + * Section + */ + section?: string | null; + }; + url: '/api/report/payload'; +}; + +export type ReportPayloadApiReportPayloadGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReportPayloadApiReportPayloadGetError = ReportPayloadApiReportPayloadGetErrors[keyof ReportPayloadApiReportPayloadGetErrors]; + +export type ReportPayloadApiReportPayloadGetResponses = { + /** + * Response Report Payload Api Report Payload Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ReportPayloadApiReportPayloadGetResponse = ReportPayloadApiReportPayloadGetResponses[keyof ReportPayloadApiReportPayloadGetResponses]; + +export type ReportHistoryApiReportHistoryGetData = { + body?: never; + path?: never; + query?: { + /** + * Propertyid + */ + propertyId?: number | null; + /** + * Domain + */ + domain?: string | null; + /** + * Limit + */ + limit?: number; + }; + url: '/api/report/history'; +}; + +export type ReportHistoryApiReportHistoryGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReportHistoryApiReportHistoryGetError = ReportHistoryApiReportHistoryGetErrors[keyof ReportHistoryApiReportHistoryGetErrors]; + +export type ReportHistoryApiReportHistoryGetResponses = { + /** + * Response Report History Api Report History Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ReportHistoryApiReportHistoryGetResponse = ReportHistoryApiReportHistoryGetResponses[keyof ReportHistoryApiReportHistoryGetResponses]; + +export type CrawlPayloadApiReportCrawlPayloadGetData = { + body?: never; + path?: never; + query?: { + /** + * Crawlrunid + */ + crawlRunId?: number | null; + }; + url: '/api/report/crawl-payload'; +}; + +export type CrawlPayloadApiReportCrawlPayloadGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CrawlPayloadApiReportCrawlPayloadGetError = CrawlPayloadApiReportCrawlPayloadGetErrors[keyof CrawlPayloadApiReportCrawlPayloadGetErrors]; + +export type CrawlPayloadApiReportCrawlPayloadGetResponses = { + /** + * Response Crawl Payload Api Report Crawl Payload Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type CrawlPayloadApiReportCrawlPayloadGetResponse = CrawlPayloadApiReportCrawlPayloadGetResponses[keyof CrawlPayloadApiReportCrawlPayloadGetResponses]; + +export type MobileDeltaApiReportMobileDeltaGetData = { + body?: never; + path?: never; + query?: { + /** + * Id + */ + id?: number | null; + }; + url: '/api/report/mobile-delta'; +}; + +export type MobileDeltaApiReportMobileDeltaGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type MobileDeltaApiReportMobileDeltaGetError = MobileDeltaApiReportMobileDeltaGetErrors[keyof MobileDeltaApiReportMobileDeltaGetErrors]; + +export type MobileDeltaApiReportMobileDeltaGetResponses = { + /** + * Response Mobile Delta Api Report Mobile Delta Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type MobileDeltaApiReportMobileDeltaGetResponse = MobileDeltaApiReportMobileDeltaGetResponses[keyof MobileDeltaApiReportMobileDeltaGetResponses]; + +export type RunPipelineApiRunPostData = { + body: RunPostBody; + path?: never; + query?: never; + url: '/api/run'; +}; + +export type RunPipelineApiRunPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type RunPipelineApiRunPostError = RunPipelineApiRunPostErrors[keyof RunPipelineApiRunPostErrors]; + +export type RunPipelineApiRunPostResponses = { + /** + * Successful Response + */ + 200: RunResponse; +}; + +export type RunPipelineApiRunPostResponse = RunPipelineApiRunPostResponses[keyof RunPipelineApiRunPostResponses]; + +export type ListPipelineJobsApiJobsGetData = { + body?: never; + path?: never; + query?: { + /** + * Limit + */ + limit?: number; + }; + url: '/api/jobs'; +}; + +export type ListPipelineJobsApiJobsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListPipelineJobsApiJobsGetError = ListPipelineJobsApiJobsGetErrors[keyof ListPipelineJobsApiJobsGetErrors]; + +export type ListPipelineJobsApiJobsGetResponses = { + /** + * Successful Response + */ + 200: JobsListResponse; +}; + +export type ListPipelineJobsApiJobsGetResponse = ListPipelineJobsApiJobsGetResponses[keyof ListPipelineJobsApiJobsGetResponses]; + +export type GetPipelineJobApiJobsJobIdGetData = { + body?: never; + path: { + /** + * Job Id + */ + job_id: string; + }; + query?: never; + url: '/api/jobs/{job_id}'; +}; + +export type GetPipelineJobApiJobsJobIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPipelineJobApiJobsJobIdGetError = GetPipelineJobApiJobsJobIdGetErrors[keyof GetPipelineJobApiJobsJobIdGetErrors]; + +export type GetPipelineJobApiJobsJobIdGetResponses = { + /** + * Response Get Pipeline Job Api Jobs Job Id Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetPipelineJobApiJobsJobIdGetResponse = GetPipelineJobApiJobsJobIdGetResponses[keyof GetPipelineJobApiJobsJobIdGetResponses]; + +export type CancelPipelineJobApiJobsJobIdCancelPostData = { + body?: never; + path: { + /** + * Job Id + */ + job_id: string; + }; + query?: never; + url: '/api/jobs/{job_id}/cancel'; +}; + +export type CancelPipelineJobApiJobsJobIdCancelPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CancelPipelineJobApiJobsJobIdCancelPostError = CancelPipelineJobApiJobsJobIdCancelPostErrors[keyof CancelPipelineJobApiJobsJobIdCancelPostErrors]; + +export type CancelPipelineJobApiJobsJobIdCancelPostResponses = { + /** + * Successful Response + */ + 200: CancelResponse; +}; + +export type CancelPipelineJobApiJobsJobIdCancelPostResponse = CancelPipelineJobApiJobsJobIdCancelPostResponses[keyof CancelPipelineJobApiJobsJobIdCancelPostResponses]; + +export type PausePipelineJobApiJobsJobIdPausePostData = { + body?: never; + path: { + /** + * Job Id + */ + job_id: string; + }; + query?: never; + url: '/api/jobs/{job_id}/pause'; +}; + +export type PausePipelineJobApiJobsJobIdPausePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PausePipelineJobApiJobsJobIdPausePostError = PausePipelineJobApiJobsJobIdPausePostErrors[keyof PausePipelineJobApiJobsJobIdPausePostErrors]; + +export type PausePipelineJobApiJobsJobIdPausePostResponses = { + /** + * Successful Response + */ + 200: PauseResponse; +}; + +export type PausePipelineJobApiJobsJobIdPausePostResponse = PausePipelineJobApiJobsJobIdPausePostResponses[keyof PausePipelineJobApiJobsJobIdPausePostResponses]; + +export type ResumePipelineJobApiJobsJobIdResumePostData = { + body?: never; + path: { + /** + * Job Id + */ + job_id: string; + }; + query?: never; + url: '/api/jobs/{job_id}/resume'; +}; + +export type ResumePipelineJobApiJobsJobIdResumePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ResumePipelineJobApiJobsJobIdResumePostError = ResumePipelineJobApiJobsJobIdResumePostErrors[keyof ResumePipelineJobApiJobsJobIdResumePostErrors]; + +export type ResumePipelineJobApiJobsJobIdResumePostResponses = { + /** + * Successful Response + */ + 200: ResumeResponse; +}; + +export type ResumePipelineJobApiJobsJobIdResumePostResponse = ResumePipelineJobApiJobsJobIdResumePostResponses[keyof ResumePipelineJobApiJobsJobIdResumePostResponses]; + +export type ChatTurnApiChatPostData = { + body: ChatRequest; + path?: never; + query?: never; + url: '/api/chat/'; +}; + +export type ChatTurnApiChatPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ChatTurnApiChatPostError = ChatTurnApiChatPostErrors[keyof ChatTurnApiChatPostErrors]; + +export type ChatTurnApiChatPostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ListSessionsApiChatSessionsGetData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/chat/sessions'; +}; + +export type ListSessionsApiChatSessionsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListSessionsApiChatSessionsGetError = ListSessionsApiChatSessionsGetErrors[keyof ListSessionsApiChatSessionsGetErrors]; + +export type ListSessionsApiChatSessionsGetResponses = { + /** + * Response List Sessions Api Chat Sessions Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListSessionsApiChatSessionsGetResponse = ListSessionsApiChatSessionsGetResponses[keyof ListSessionsApiChatSessionsGetResponses]; + +export type CreateSessionApiChatSessionsPostData = { + body: ChatSessionCreate; + path?: never; + query?: never; + url: '/api/chat/sessions'; +}; + +export type CreateSessionApiChatSessionsPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateSessionApiChatSessionsPostError = CreateSessionApiChatSessionsPostErrors[keyof CreateSessionApiChatSessionsPostErrors]; + +export type CreateSessionApiChatSessionsPostResponses = { + /** + * Response Create Session Api Chat Sessions Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type CreateSessionApiChatSessionsPostResponse = CreateSessionApiChatSessionsPostResponses[keyof CreateSessionApiChatSessionsPostResponses]; + +export type DeleteSessionRouteApiChatSessionsSessionIdDeleteData = { + body?: never; + path: { + /** + * Session Id + */ + session_id: number; + }; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/chat/sessions/{session_id}'; +}; + +export type DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteSessionRouteApiChatSessionsSessionIdDeleteError = DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors[keyof DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors]; + +export type DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses = { + /** + * Response Delete Session Route Api Chat Sessions Session Id Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeleteSessionRouteApiChatSessionsSessionIdDeleteResponse = DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses[keyof DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses]; + +export type GetSessionRouteApiChatSessionsSessionIdGetData = { + body?: never; + path: { + /** + * Session Id + */ + session_id: number; + }; + query?: never; + url: '/api/chat/sessions/{session_id}'; +}; + +export type GetSessionRouteApiChatSessionsSessionIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetSessionRouteApiChatSessionsSessionIdGetError = GetSessionRouteApiChatSessionsSessionIdGetErrors[keyof GetSessionRouteApiChatSessionsSessionIdGetErrors]; + +export type GetSessionRouteApiChatSessionsSessionIdGetResponses = { + /** + * Response Get Session Route Api Chat Sessions Session Id Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetSessionRouteApiChatSessionsSessionIdGetResponse = GetSessionRouteApiChatSessionsSessionIdGetResponses[keyof GetSessionRouteApiChatSessionsSessionIdGetResponses]; + +export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetData = { + body?: never; + path: { + /** + * Session Id + */ + session_id: number; + }; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/chat/sessions/{session_id}/messages'; +}; + +export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetError = GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors[keyof GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors]; + +export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses = { + /** + * Response Get Session Messages Api Chat Sessions Session Id Messages Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponse = GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses[keyof GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses]; + +export type GetArtifactApiChatArtifactsArtifactIdGetData = { + body?: never; + path: { + /** + * Artifact Id + */ + artifact_id: string; + }; + query?: never; + url: '/api/chat/artifacts/{artifact_id}'; +}; + +export type GetArtifactApiChatArtifactsArtifactIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetArtifactApiChatArtifactsArtifactIdGetError = GetArtifactApiChatArtifactsArtifactIdGetErrors[keyof GetArtifactApiChatArtifactsArtifactIdGetErrors]; + +export type GetArtifactApiChatArtifactsArtifactIdGetResponses = { + /** + * Response Get Artifact Api Chat Artifacts Artifact Id Get + * + * Successful Response + */ + 200: unknown; +}; + +export type BrowserStatusCheckApiCrawlBrowserStatusGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/crawl/browser-status'; +}; + +export type BrowserStatusCheckApiCrawlBrowserStatusGetResponses = { + /** + * Response Browser Status Check Api Crawl Browser Status Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type BrowserStatusCheckApiCrawlBrowserStatusGetResponse = BrowserStatusCheckApiCrawlBrowserStatusGetResponses[keyof BrowserStatusCheckApiCrawlBrowserStatusGetResponses]; + +export type GetPageHtmlApiCrawlPageHtmlGetData = { + body?: never; + path?: never; + query: { + /** + * Url + * + * Page URL to retrieve stored HTML for + */ + url: string; + /** + * Crawlrunid + * + * Crawl run ID + */ + crawlRunId?: number | null; + }; + url: '/api/crawl/page-html'; +}; + +export type GetPageHtmlApiCrawlPageHtmlGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPageHtmlApiCrawlPageHtmlGetError = GetPageHtmlApiCrawlPageHtmlGetErrors[keyof GetPageHtmlApiCrawlPageHtmlGetErrors]; + +export type GetPageHtmlApiCrawlPageHtmlGetResponses = { + /** + * Response Get Page Html Api Crawl Page Html Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetPageHtmlApiCrawlPageHtmlGetResponse = GetPageHtmlApiCrawlPageHtmlGetResponses[keyof GetPageHtmlApiCrawlPageHtmlGetResponses]; + +export type GetPipelineConfigApiPipelineConfigGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/pipeline-config'; +}; + +export type GetPipelineConfigApiPipelineConfigGetResponses = { + /** + * Response Get Pipeline Config Api Pipeline Config Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetPipelineConfigApiPipelineConfigGetResponse = GetPipelineConfigApiPipelineConfigGetResponses[keyof GetPipelineConfigApiPipelineConfigGetResponses]; + +export type PutPipelineConfigApiPipelineConfigPutData = { + body: PipelineConfigBody; + path?: never; + query?: never; + url: '/api/pipeline-config'; +}; + +export type PutPipelineConfigApiPipelineConfigPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PutPipelineConfigApiPipelineConfigPutError = PutPipelineConfigApiPipelineConfigPutErrors[keyof PutPipelineConfigApiPipelineConfigPutErrors]; + +export type PutPipelineConfigApiPipelineConfigPutResponses = { + /** + * Response Put Pipeline Config Api Pipeline Config Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PutPipelineConfigApiPipelineConfigPutResponse = PutPipelineConfigApiPipelineConfigPutResponses[keyof PutPipelineConfigApiPipelineConfigPutResponses]; + +export type GetLlmConfigApiLlmConfigGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/llm-config'; +}; + +export type GetLlmConfigApiLlmConfigGetResponses = { + /** + * Response Get Llm Config Api Llm Config Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetLlmConfigApiLlmConfigGetResponse = GetLlmConfigApiLlmConfigGetResponses[keyof GetLlmConfigApiLlmConfigGetResponses]; + +export type PutLlmConfigApiLlmConfigPutData = { + body: LlmConfigBody; + path?: never; + query?: never; + url: '/api/llm-config'; +}; + +export type PutLlmConfigApiLlmConfigPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PutLlmConfigApiLlmConfigPutError = PutLlmConfigApiLlmConfigPutErrors[keyof PutLlmConfigApiLlmConfigPutErrors]; + +export type PutLlmConfigApiLlmConfigPutResponses = { + /** + * Response Put Llm Config Api Llm Config Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PutLlmConfigApiLlmConfigPutResponse = PutLlmConfigApiLlmConfigPutResponses[keyof PutLlmConfigApiLlmConfigPutResponses]; + +export type GetSecretsApiSecretsGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/secrets'; +}; + +export type GetSecretsApiSecretsGetResponses = { + /** + * Response Get Secrets Api Secrets Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetSecretsApiSecretsGetResponse = GetSecretsApiSecretsGetResponses[keyof GetSecretsApiSecretsGetResponses]; + +export type PutSecretsApiSecretsPutData = { + body: SecretsBody; + path?: never; + query?: never; + url: '/api/secrets'; +}; + +export type PutSecretsApiSecretsPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PutSecretsApiSecretsPutError = PutSecretsApiSecretsPutErrors[keyof PutSecretsApiSecretsPutErrors]; + +export type PutSecretsApiSecretsPutResponses = { + /** + * Response Put Secrets Api Secrets Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PutSecretsApiSecretsPutResponse = PutSecretsApiSecretsPutResponses[keyof PutSecretsApiSecretsPutResponses]; + +export type GetAppSettingApiAppSettingsGetData = { + body?: never; + path?: never; + query: { + /** + * Key + * + * Settings key to retrieve + */ + key: string; + }; + url: '/api/app-settings'; +}; + +export type GetAppSettingApiAppSettingsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetAppSettingApiAppSettingsGetError = GetAppSettingApiAppSettingsGetErrors[keyof GetAppSettingApiAppSettingsGetErrors]; + +export type GetAppSettingApiAppSettingsGetResponses = { + /** + * Response Get App Setting Api App Settings Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetAppSettingApiAppSettingsGetResponse = GetAppSettingApiAppSettingsGetResponses[keyof GetAppSettingApiAppSettingsGetResponses]; + +export type PutAppSettingApiAppSettingsPutData = { + body: AppSettingBody; + path?: never; + query?: never; + url: '/api/app-settings'; +}; + +export type PutAppSettingApiAppSettingsPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PutAppSettingApiAppSettingsPutError = PutAppSettingApiAppSettingsPutErrors[keyof PutAppSettingApiAppSettingsPutErrors]; + +export type PutAppSettingApiAppSettingsPutResponses = { + /** + * Response Put App Setting Api App Settings Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PutAppSettingApiAppSettingsPutResponse = PutAppSettingApiAppSettingsPutResponses[keyof PutAppSettingApiAppSettingsPutResponses]; + +export type ListPropertiesApiPropertiesGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/properties'; +}; + +export type ListPropertiesApiPropertiesGetResponses = { + /** + * Response List Properties Api Properties Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListPropertiesApiPropertiesGetResponse = ListPropertiesApiPropertiesGetResponses[keyof ListPropertiesApiPropertiesGetResponses]; + +export type CreatePropertyApiPropertiesPostData = { + body: PropertyUpsertBody; + path?: never; + query?: never; + url: '/api/properties'; +}; + +export type CreatePropertyApiPropertiesPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreatePropertyApiPropertiesPostError = CreatePropertyApiPropertiesPostErrors[keyof CreatePropertyApiPropertiesPostErrors]; + +export type CreatePropertyApiPropertiesPostResponses = { + /** + * Response Create Property Api Properties Post + * + * Successful Response + */ + 201: { + [key: string]: unknown; + }; +}; + +export type CreatePropertyApiPropertiesPostResponse = CreatePropertyApiPropertiesPostResponses[keyof CreatePropertyApiPropertiesPostResponses]; + +export type ResolvePropertyApiPropertiesResolveGetData = { + body?: never; + path?: never; + query: { + /** + * Starturl + * + * Start URL to resolve a property from + */ + startUrl: string; + }; + url: '/api/properties/resolve'; +}; + +export type ResolvePropertyApiPropertiesResolveGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ResolvePropertyApiPropertiesResolveGetError = ResolvePropertyApiPropertiesResolveGetErrors[keyof ResolvePropertyApiPropertiesResolveGetErrors]; + +export type ResolvePropertyApiPropertiesResolveGetResponses = { + /** + * Response Resolve Property Api Properties Resolve Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ResolvePropertyApiPropertiesResolveGetResponse = ResolvePropertyApiPropertiesResolveGetResponses[keyof ResolvePropertyApiPropertiesResolveGetResponses]; + +export type DeletePropertyApiPropertiesPropertyIdDeleteData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}'; +}; + +export type DeletePropertyApiPropertiesPropertyIdDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeletePropertyApiPropertiesPropertyIdDeleteError = DeletePropertyApiPropertiesPropertyIdDeleteErrors[keyof DeletePropertyApiPropertiesPropertyIdDeleteErrors]; + +export type DeletePropertyApiPropertiesPropertyIdDeleteResponses = { + /** + * Response Delete Property Api Properties Property Id Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeletePropertyApiPropertiesPropertyIdDeleteResponse = DeletePropertyApiPropertiesPropertyIdDeleteResponses[keyof DeletePropertyApiPropertiesPropertyIdDeleteResponses]; + +export type GetPropertyApiPropertiesPropertyIdGetData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}'; +}; + +export type GetPropertyApiPropertiesPropertyIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPropertyApiPropertiesPropertyIdGetError = GetPropertyApiPropertiesPropertyIdGetErrors[keyof GetPropertyApiPropertiesPropertyIdGetErrors]; + +export type GetPropertyApiPropertiesPropertyIdGetResponses = { + /** + * Response Get Property Api Properties Property Id Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetPropertyApiPropertiesPropertyIdGetResponse = GetPropertyApiPropertiesPropertyIdGetResponses[keyof GetPropertyApiPropertiesPropertyIdGetResponses]; + +export type GetPropertyOpsApiPropertiesPropertyIdOpsGetData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/ops'; +}; + +export type GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPropertyOpsApiPropertiesPropertyIdOpsGetError = GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors[keyof GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors]; + +export type GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses = { + /** + * Response Get Property Ops Api Properties Property Id Ops Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetPropertyOpsApiPropertiesPropertyIdOpsGetResponse = GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses[keyof GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses]; + +export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData = { + body: OpsSettingsBody; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/ops'; +}; + +export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutError = UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors[keyof UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors]; + +export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses = { + /** + * Response Update Property Ops Api Properties Property Id Ops Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponse = UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses[keyof UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses]; + +export type GetPropertyPresetApiPropertiesPropertyIdPresetGetData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/preset'; +}; + +export type GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPropertyPresetApiPropertiesPropertyIdPresetGetError = GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors[keyof GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors]; + +export type GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses = { + /** + * Response Get Property Preset Api Properties Property Id Preset Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetPropertyPresetApiPropertiesPropertyIdPresetGetResponse = GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses[keyof GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses]; + +export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData = { + body: PresetBody; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/preset'; +}; + +export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutError = UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors[keyof UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors]; + +export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses = { + /** + * Response Update Property Preset Api Properties Property Id Preset Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponse = UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses[keyof UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses]; + +export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/authorize'; +}; + +export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostError = AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors[keyof AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors]; + +export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses = { + /** + * Response Authorize Property Crawl Api Properties Property Id Authorize Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponse = AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses[keyof AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses]; + +export type PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/status'; +}; + +export type PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetError = PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors[keyof PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors]; + +export type PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses = { + /** + * Response Property Google Status Api Properties Property Id Google Status Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponse = PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses[keyof PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses]; + +export type PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/test'; +}; + +export type PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostError = PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors[keyof PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors]; + +export type PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses = { + /** + * Response Property Google Test Api Properties Property Id Google Test Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponse = PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses[keyof PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses]; + +export type PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/properties'; +}; + +export type PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetError = PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors[keyof PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors]; + +export type PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses = { + /** + * Response Property Google Properties Api Properties Property Id Google Properties Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponse = PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses[keyof PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses]; + +export type PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/links/status'; +}; + +export type PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetError = PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors[keyof PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors]; + +export type PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses = { + /** + * Response Property Google Links Status Api Properties Property Id Google Links Status Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponse = PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses[keyof PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses]; + +export type PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/links/import'; +}; + +export type PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostError = PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors[keyof PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors]; + +export type PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses = { + /** + * Response Property Google Links Import Api Properties Property Id Google Links Import Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponse = PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses[keyof PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses]; + +export type PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData = { + body: GoogleCredentialsPatch; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/credentials'; +}; + +export type PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchError = PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors[keyof PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors]; + +export type PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses = { + /** + * Response Patch Property Google Credentials Api Properties Property Id Google Credentials Patch + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponse = PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses[keyof PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses]; + +export type PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData = { + body: GoogleCredentialsPostBody; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/credentials'; +}; + +export type PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostError = PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors[keyof PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors]; + +export type PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses = { + /** + * Response Post Property Google Credentials Api Properties Property Id Google Credentials Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponse = PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses[keyof PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses]; + +export type PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData = { + body?: never; + path: { + /** + * Property Id + */ + property_id: number; + }; + query?: never; + url: '/api/properties/{property_id}/google/disconnect'; +}; + +export type PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostError = PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors[keyof PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors]; + +export type PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses = { + /** + * Response Post Property Google Disconnect Api Properties Property Id Google Disconnect Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponse = PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses[keyof PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses]; + +export type ListDashboardsApiDashboardsGetData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + * + * Property ID + */ + propertyId: number; + }; + url: '/api/dashboards'; +}; + +export type ListDashboardsApiDashboardsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListDashboardsApiDashboardsGetError = ListDashboardsApiDashboardsGetErrors[keyof ListDashboardsApiDashboardsGetErrors]; + +export type ListDashboardsApiDashboardsGetResponses = { + /** + * Response List Dashboards Api Dashboards Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListDashboardsApiDashboardsGetResponse = ListDashboardsApiDashboardsGetResponses[keyof ListDashboardsApiDashboardsGetResponses]; + +export type CreateDashboardApiDashboardsPostData = { + body: DashboardCreateBody; + path?: never; + query?: never; + url: '/api/dashboards'; +}; + +export type CreateDashboardApiDashboardsPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateDashboardApiDashboardsPostError = CreateDashboardApiDashboardsPostErrors[keyof CreateDashboardApiDashboardsPostErrors]; + +export type CreateDashboardApiDashboardsPostResponses = { + /** + * Response Create Dashboard Api Dashboards Post + * + * Successful Response + */ + 201: { + [key: string]: unknown; + }; +}; + +export type CreateDashboardApiDashboardsPostResponse = CreateDashboardApiDashboardsPostResponses[keyof CreateDashboardApiDashboardsPostResponses]; + +export type DeleteDashboardApiDashboardsDashboardIdDeleteData = { + body?: never; + path: { + /** + * Dashboard Id + */ + dashboard_id: number; + }; + query: { + /** + * Propertyid + * + * Property ID + */ + propertyId: number; + }; + url: '/api/dashboards/{dashboard_id}'; +}; + +export type DeleteDashboardApiDashboardsDashboardIdDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteDashboardApiDashboardsDashboardIdDeleteError = DeleteDashboardApiDashboardsDashboardIdDeleteErrors[keyof DeleteDashboardApiDashboardsDashboardIdDeleteErrors]; + +export type DeleteDashboardApiDashboardsDashboardIdDeleteResponses = { + /** + * Response Delete Dashboard Api Dashboards Dashboard Id Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeleteDashboardApiDashboardsDashboardIdDeleteResponse = DeleteDashboardApiDashboardsDashboardIdDeleteResponses[keyof DeleteDashboardApiDashboardsDashboardIdDeleteResponses]; + +export type GetDashboardApiDashboardsDashboardIdGetData = { + body?: never; + path: { + /** + * Dashboard Id + */ + dashboard_id: number; + }; + query: { + /** + * Propertyid + * + * Property ID + */ + propertyId: number; + }; + url: '/api/dashboards/{dashboard_id}'; +}; + +export type GetDashboardApiDashboardsDashboardIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetDashboardApiDashboardsDashboardIdGetError = GetDashboardApiDashboardsDashboardIdGetErrors[keyof GetDashboardApiDashboardsDashboardIdGetErrors]; + +export type GetDashboardApiDashboardsDashboardIdGetResponses = { + /** + * Response Get Dashboard Api Dashboards Dashboard Id Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetDashboardApiDashboardsDashboardIdGetResponse = GetDashboardApiDashboardsDashboardIdGetResponses[keyof GetDashboardApiDashboardsDashboardIdGetResponses]; + +export type UpdateDashboardApiDashboardsDashboardIdPutData = { + body: DashboardUpdateBody; + path: { + /** + * Dashboard Id + */ + dashboard_id: number; + }; + query?: never; + url: '/api/dashboards/{dashboard_id}'; +}; + +export type UpdateDashboardApiDashboardsDashboardIdPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateDashboardApiDashboardsDashboardIdPutError = UpdateDashboardApiDashboardsDashboardIdPutErrors[keyof UpdateDashboardApiDashboardsDashboardIdPutErrors]; + +export type UpdateDashboardApiDashboardsDashboardIdPutResponses = { + /** + * Response Update Dashboard Api Dashboards Dashboard Id Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UpdateDashboardApiDashboardsDashboardIdPutResponse = UpdateDashboardApiDashboardsDashboardIdPutResponses[keyof UpdateDashboardApiDashboardsDashboardIdPutResponses]; + +export type DashboardsAiGenerateApiDashboardsAiGeneratePostData = { + body: DashboardAiGenerateBody; + path?: never; + query?: never; + url: '/api/dashboards/ai-generate'; +}; + +export type DashboardsAiGenerateApiDashboardsAiGeneratePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DashboardsAiGenerateApiDashboardsAiGeneratePostError = DashboardsAiGenerateApiDashboardsAiGeneratePostErrors[keyof DashboardsAiGenerateApiDashboardsAiGeneratePostErrors]; + +export type DashboardsAiGenerateApiDashboardsAiGeneratePostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type DeleteFilterApiFiltersDeleteData = { + body: FilterDeleteBody; + path?: never; + query?: never; + url: '/api/filters'; +}; + +export type DeleteFilterApiFiltersDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteFilterApiFiltersDeleteError = DeleteFilterApiFiltersDeleteErrors[keyof DeleteFilterApiFiltersDeleteErrors]; + +export type DeleteFilterApiFiltersDeleteResponses = { + /** + * Response Delete Filter Api Filters Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeleteFilterApiFiltersDeleteResponse = DeleteFilterApiFiltersDeleteResponses[keyof DeleteFilterApiFiltersDeleteResponses]; + +export type ListFiltersApiFiltersGetData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + * + * Property ID + */ + propertyId: number; + }; + url: '/api/filters'; +}; + +export type ListFiltersApiFiltersGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListFiltersApiFiltersGetError = ListFiltersApiFiltersGetErrors[keyof ListFiltersApiFiltersGetErrors]; + +export type ListFiltersApiFiltersGetResponses = { + /** + * Response List Filters Api Filters Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListFiltersApiFiltersGetResponse = ListFiltersApiFiltersGetResponses[keyof ListFiltersApiFiltersGetResponses]; + +export type UpsertFilterApiFiltersPostData = { + body: FilterUpsertBody; + path?: never; + query?: never; + url: '/api/filters'; +}; + +export type UpsertFilterApiFiltersPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpsertFilterApiFiltersPostError = UpsertFilterApiFiltersPostErrors[keyof UpsertFilterApiFiltersPostErrors]; + +export type UpsertFilterApiFiltersPostResponses = { + /** + * Response Upsert Filter Api Filters Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UpsertFilterApiFiltersPostResponse = UpsertFilterApiFiltersPostResponses[keyof UpsertFilterApiFiltersPostResponses]; + +export type GoogleStatusApiIntegrationsGoogleStatusGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/integrations/google/status'; +}; + +export type GoogleStatusApiIntegrationsGoogleStatusGetResponses = { + /** + * Response Google Status Api Integrations Google Status Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleStatusApiIntegrationsGoogleStatusGetResponse = GoogleStatusApiIntegrationsGoogleStatusGetResponses[keyof GoogleStatusApiIntegrationsGoogleStatusGetResponses]; + +export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/integrations/google/credentials'; +}; + +export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostError = SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors[keyof SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors]; + +export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses = { + /** + * Response Save Google Credentials Api Integrations Google Credentials Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponse = SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses[keyof SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses]; + +export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/integrations/google/credentials/upload'; +}; + +export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostError = UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors[keyof UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors]; + +export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses = { + /** + * Response Upload Google Credentials Api Integrations Google Credentials Upload Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponse = UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses[keyof UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses]; + +export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostData = { + body?: never; + path?: never; + query?: never; + url: '/api/integrations/google/disconnect'; +}; + +export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses = { + /** + * Response Google Disconnect Api Integrations Google Disconnect Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponse = GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses[keyof GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses]; + +export type GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData = { + body?: never; + path?: never; + query?: { + /** + * Propertyid + */ + propertyId?: number | null; + }; + url: '/api/integrations/google/properties'; +}; + +export type GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetError = GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors[keyof GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors]; + +export type GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses = { + /** + * Response Google Properties Deprecated Api Integrations Google Properties Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponse = GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses[keyof GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses]; + +export type GoogleTestApiIntegrationsGoogleTestPostData = { + body?: never; + path?: never; + query?: never; + url: '/api/integrations/google/test'; +}; + +export type GoogleTestApiIntegrationsGoogleTestPostResponses = { + /** + * Response Google Test Api Integrations Google Test Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleTestApiIntegrationsGoogleTestPostResponse = GoogleTestApiIntegrationsGoogleTestPostResponses[keyof GoogleTestApiIntegrationsGoogleTestPostResponses]; + +export type GooglePageDataApiIntegrationsGooglePageDataGetData = { + body?: never; + path?: never; + query: { + /** + * Url + */ + url: string; + /** + * Googlesnapshotid + */ + googleSnapshotId?: number | null; + /** + * Propertyid + */ + propertyId?: string | null; + /** + * Domain + */ + domain?: string | null; + }; + url: '/api/integrations/google/page-data'; +}; + +export type GooglePageDataApiIntegrationsGooglePageDataGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GooglePageDataApiIntegrationsGooglePageDataGetError = GooglePageDataApiIntegrationsGooglePageDataGetErrors[keyof GooglePageDataApiIntegrationsGooglePageDataGetErrors]; + +export type GooglePageDataApiIntegrationsGooglePageDataGetResponses = { + /** + * Response Google Page Data Api Integrations Google Page Data Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GooglePageDataApiIntegrationsGooglePageDataGetResponse = GooglePageDataApiIntegrationsGooglePageDataGetResponses[keyof GooglePageDataApiIntegrationsGooglePageDataGetResponses]; + +export type GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData = { + body?: never; + path?: never; + query: { + /** + * Url + */ + url: string; + /** + * Propertyid + */ + propertyId?: string | null; + /** + * Domain + */ + domain?: string | null; + }; + url: '/api/integrations/google/page-data/history'; +}; + +export type GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetError = GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors[keyof GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors]; + +export type GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses = { + /** + * Response Google Page Data History Api Integrations Google Page Data History Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponse = GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses[keyof GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses]; + +export type GooglePageLiveApiIntegrationsGooglePageLivePostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/integrations/google/page-live'; +}; + +export type GooglePageLiveApiIntegrationsGooglePageLivePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GooglePageLiveApiIntegrationsGooglePageLivePostError = GooglePageLiveApiIntegrationsGooglePageLivePostErrors[keyof GooglePageLiveApiIntegrationsGooglePageLivePostErrors]; + +export type GooglePageLiveApiIntegrationsGooglePageLivePostResponses = { + /** + * Response Google Page Live Api Integrations Google Page Live Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GooglePageLiveApiIntegrationsGooglePageLivePostResponse = GooglePageLiveApiIntegrationsGooglePageLivePostResponses[keyof GooglePageLiveApiIntegrationsGooglePageLivePostResponses]; + +export type GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData = { + body?: never; + path?: never; + query: { + /** + * Url + */ + url: string; + /** + * Propertyid + */ + propertyId?: string | null; + /** + * Domain + */ + domain?: string | null; + }; + url: '/api/integrations/google/keywords/by-page'; +}; + +export type GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetError = GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors[keyof GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors]; + +export type GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses = { + /** + * Response Google Keywords By Page Api Integrations Google Keywords By Page Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponse = GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses[keyof GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses]; + +export type GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData = { + body?: never; + path?: never; + query: { + /** + * Keyword + */ + keyword: string; + /** + * Propertyid + */ + propertyId?: string | null; + /** + * Domain + */ + domain?: string | null; + /** + * Limit + */ + limit?: number; + }; + url: '/api/integrations/google/keywords/history'; +}; + +export type GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetError = GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors[keyof GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors]; + +export type GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses = { + /** + * Response Google Keywords History Api Integrations Google Keywords History Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponse = GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses[keyof GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses]; + +export type BingSyncApiIntegrationsBingSyncPostData = { + body?: never; + path?: never; + query?: never; + url: '/api/integrations/bing/sync'; +}; + +export type BingSyncApiIntegrationsBingSyncPostResponses = { + /** + * Response Bing Sync Api Integrations Bing Sync Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type BingSyncApiIntegrationsBingSyncPostResponse = BingSyncApiIntegrationsBingSyncPostResponses[keyof BingSyncApiIntegrationsBingSyncPostResponses]; + +export type GooglePageCompareApiIntegrationsGooglePageCompareGetData = { + body?: never; + path?: never; + query: { + /** + * Url + */ + url: string; + /** + * Currenttype + */ + currentType?: string; + /** + * Currentid + */ + currentId: number; + /** + * Baselinetype + */ + baselineType?: string; + /** + * Baselineid + */ + baselineId: number; + }; + url: '/api/integrations/google/page-compare'; +}; + +export type GooglePageCompareApiIntegrationsGooglePageCompareGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GooglePageCompareApiIntegrationsGooglePageCompareGetError = GooglePageCompareApiIntegrationsGooglePageCompareGetErrors[keyof GooglePageCompareApiIntegrationsGooglePageCompareGetErrors]; + +export type GooglePageCompareApiIntegrationsGooglePageCompareGetResponses = { + /** + * Response Google Page Compare Api Integrations Google Page Compare Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GooglePageCompareApiIntegrationsGooglePageCompareGetResponse = GooglePageCompareApiIntegrationsGooglePageCompareGetResponses[keyof GooglePageCompareApiIntegrationsGooglePageCompareGetResponses]; + +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData = { + body?: never; + path?: never; + query: { + /** + * Url + */ + url: string; + /** + * Limit + */ + limit?: number; + }; + url: '/api/integrations/google/page-live/history'; +}; + +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetError = GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors[keyof GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors]; + +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses = { + /** + * Response Google Page Live History Api Integrations Google Page Live History Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponse = GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses[keyof GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses]; + +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData = { + /** + * Body + */ + body: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/integrations/google/keywords/history/batch'; +}; + +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostError = GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors[keyof GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors]; + +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses = { + /** + * Response Google Keywords History Batch Api Integrations Google Keywords History Batch Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponse = GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses[keyof GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses]; + +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData = { + /** + * Body + */ + body: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/integrations/google/keywords/expand'; +}; + +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostError = GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors[keyof GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors]; + +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses = { + /** + * Response Google Keywords Expand Api Integrations Google Keywords Expand Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponse = GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses[keyof GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses]; + +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData = { + /** + * Body + */ + body: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/integrations/google/keywords/planner'; +}; + +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostError = GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors[keyof GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors]; + +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses = { + /** + * Response Google Keywords Planner Api Integrations Google Keywords Planner Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponse = GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses[keyof GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses]; + +export type ListIssueStatusApiIssuesStatusGetData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/issues/status'; +}; + +export type ListIssueStatusApiIssuesStatusGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListIssueStatusApiIssuesStatusGetError = ListIssueStatusApiIssuesStatusGetErrors[keyof ListIssueStatusApiIssuesStatusGetErrors]; + +export type ListIssueStatusApiIssuesStatusGetResponses = { + /** + * Response List Issue Status Api Issues Status Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListIssueStatusApiIssuesStatusGetResponse = ListIssueStatusApiIssuesStatusGetResponses[keyof ListIssueStatusApiIssuesStatusGetResponses]; + +export type UpsertIssueStatusApiIssuesStatusPutData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/issues/status'; +}; + +export type UpsertIssueStatusApiIssuesStatusPutErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpsertIssueStatusApiIssuesStatusPutError = UpsertIssueStatusApiIssuesStatusPutErrors[keyof UpsertIssueStatusApiIssuesStatusPutErrors]; + +export type UpsertIssueStatusApiIssuesStatusPutResponses = { + /** + * Response Upsert Issue Status Api Issues Status Put + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UpsertIssueStatusApiIssuesStatusPutResponse = UpsertIssueStatusApiIssuesStatusPutResponses[keyof UpsertIssueStatusApiIssuesStatusPutResponses]; + +export type IssuesFixSuggestionApiIssuesFixSuggestionPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/issues/fix-suggestion'; +}; + +export type IssuesFixSuggestionApiIssuesFixSuggestionPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type IssuesFixSuggestionApiIssuesFixSuggestionPostError = IssuesFixSuggestionApiIssuesFixSuggestionPostErrors[keyof IssuesFixSuggestionApiIssuesFixSuggestionPostErrors]; + +export type IssuesFixSuggestionApiIssuesFixSuggestionPostResponses = { + /** + * Response Issues Fix Suggestion Api Issues Fix Suggestion Post + * + * Successful Response + */ + 200: unknown; +}; + +export type IssuesActionPlanApiIssuesActionPlanPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/issues/action-plan'; +}; + +export type IssuesActionPlanApiIssuesActionPlanPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type IssuesActionPlanApiIssuesActionPlanPostError = IssuesActionPlanApiIssuesActionPlanPostErrors[keyof IssuesActionPlanApiIssuesActionPlanPostErrors]; + +export type IssuesActionPlanApiIssuesActionPlanPostResponses = { + /** + * Response Issues Action Plan Api Issues Action Plan Post + * + * Successful Response + */ + 200: unknown; +}; + +export type AiFixSuggestionApiAiFixSuggestionPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/ai/fix-suggestion'; +}; + +export type AiFixSuggestionApiAiFixSuggestionPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type AiFixSuggestionApiAiFixSuggestionPostError = AiFixSuggestionApiAiFixSuggestionPostErrors[keyof AiFixSuggestionApiAiFixSuggestionPostErrors]; + +export type AiFixSuggestionApiAiFixSuggestionPostResponses = { + /** + * Response Ai Fix Suggestion Api Ai Fix Suggestion Post + * + * Successful Response + */ + 200: unknown; +}; + +export type KeywordsCompetitorImportApiKeywordsCompetitorImportPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/keywords/competitor-import'; +}; + +export type KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type KeywordsCompetitorImportApiKeywordsCompetitorImportPostError = KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors[keyof KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors]; + +export type KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses = { + /** + * Response Keywords Competitor Import Api Keywords Competitor Import Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponse = KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses[keyof KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses]; + +export type KeywordsContentBriefApiKeywordsContentBriefPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/keywords/content-brief'; +}; + +export type KeywordsContentBriefApiKeywordsContentBriefPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type KeywordsContentBriefApiKeywordsContentBriefPostError = KeywordsContentBriefApiKeywordsContentBriefPostErrors[keyof KeywordsContentBriefApiKeywordsContentBriefPostErrors]; + +export type KeywordsContentBriefApiKeywordsContentBriefPostResponses = { + /** + * Response Keywords Content Brief Api Keywords Content Brief Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type KeywordsContentBriefApiKeywordsContentBriefPostResponse = KeywordsContentBriefApiKeywordsContentBriefPostResponses[keyof KeywordsContentBriefApiKeywordsContentBriefPostResponses]; + +export type BacklinksVelocityApiBacklinksVelocityGetData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/backlinks/velocity'; +}; + +export type BacklinksVelocityApiBacklinksVelocityGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type BacklinksVelocityApiBacklinksVelocityGetError = BacklinksVelocityApiBacklinksVelocityGetErrors[keyof BacklinksVelocityApiBacklinksVelocityGetErrors]; + +export type BacklinksVelocityApiBacklinksVelocityGetResponses = { + /** + * Response Backlinks Velocity Api Backlinks Velocity Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type BacklinksVelocityApiBacklinksVelocityGetResponse = BacklinksVelocityApiBacklinksVelocityGetResponses[keyof BacklinksVelocityApiBacklinksVelocityGetResponses]; + +export type BacklinksCompetitorImportApiBacklinksCompetitorImportPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/backlinks/competitor-import'; +}; + +export type BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type BacklinksCompetitorImportApiBacklinksCompetitorImportPostError = BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors[keyof BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors]; + +export type BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses = { + /** + * Response Backlinks Competitor Import Api Backlinks Competitor Import Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponse = BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses[keyof BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses]; + +export type BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/backlinks/third-party-import'; +}; + +export type BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostError = BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors[keyof BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors]; + +export type BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses = { + /** + * Response Backlinks Third Party Import Api Backlinks Third Party Import Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponse = BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses[keyof BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses]; + +export type ContentAnalyzeApiContentAnalyzePostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/content/analyze'; +}; + +export type ContentAnalyzeApiContentAnalyzePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ContentAnalyzeApiContentAnalyzePostError = ContentAnalyzeApiContentAnalyzePostErrors[keyof ContentAnalyzeApiContentAnalyzePostErrors]; + +export type ContentAnalyzeApiContentAnalyzePostResponses = { + /** + * Response Content Analyze Api Content Analyze Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ContentAnalyzeApiContentAnalyzePostResponse = ContentAnalyzeApiContentAnalyzePostResponses[keyof ContentAnalyzeApiContentAnalyzePostResponses]; + +export type ContentScoreApiContentScorePostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/content/score'; +}; + +export type ContentScoreApiContentScorePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ContentScoreApiContentScorePostError = ContentScoreApiContentScorePostErrors[keyof ContentScoreApiContentScorePostErrors]; + +export type ContentScoreApiContentScorePostResponses = { + /** + * Response Content Score Api Content Score Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ContentScoreApiContentScorePostResponse = ContentScoreApiContentScorePostResponses[keyof ContentScoreApiContentScorePostResponses]; + +export type ContentWizardApiContentWizardPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/content/wizard'; +}; + +export type ContentWizardApiContentWizardPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ContentWizardApiContentWizardPostError = ContentWizardApiContentWizardPostErrors[keyof ContentWizardApiContentWizardPostErrors]; + +export type ContentWizardApiContentWizardPostResponses = { + /** + * Response Content Wizard Api Content Wizard Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ContentWizardApiContentWizardPostResponse = ContentWizardApiContentWizardPostResponses[keyof ContentWizardApiContentWizardPostResponses]; + +export type ListContentDraftsApiContentDraftsGetData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/content-drafts'; +}; + +export type ListContentDraftsApiContentDraftsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListContentDraftsApiContentDraftsGetError = ListContentDraftsApiContentDraftsGetErrors[keyof ListContentDraftsApiContentDraftsGetErrors]; + +export type ListContentDraftsApiContentDraftsGetResponses = { + /** + * Response List Content Drafts Api Content Drafts Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListContentDraftsApiContentDraftsGetResponse = ListContentDraftsApiContentDraftsGetResponses[keyof ListContentDraftsApiContentDraftsGetResponses]; + +export type CreateContentDraftApiContentDraftsPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/content-drafts'; +}; + +export type CreateContentDraftApiContentDraftsPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateContentDraftApiContentDraftsPostError = CreateContentDraftApiContentDraftsPostErrors[keyof CreateContentDraftApiContentDraftsPostErrors]; + +export type CreateContentDraftApiContentDraftsPostResponses = { + /** + * Response Create Content Draft Api Content Drafts Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type CreateContentDraftApiContentDraftsPostResponse = CreateContentDraftApiContentDraftsPostResponses[keyof CreateContentDraftApiContentDraftsPostResponses]; + +export type DeleteContentDraftApiContentDraftsDraftIdDeleteData = { + body?: never; + path: { + /** + * Draft Id + */ + draft_id: number; + }; + query?: never; + url: '/api/content-drafts/{draft_id}'; +}; + +export type DeleteContentDraftApiContentDraftsDraftIdDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteContentDraftApiContentDraftsDraftIdDeleteError = DeleteContentDraftApiContentDraftsDraftIdDeleteErrors[keyof DeleteContentDraftApiContentDraftsDraftIdDeleteErrors]; + +export type DeleteContentDraftApiContentDraftsDraftIdDeleteResponses = { + /** + * Response Delete Content Draft Api Content Drafts Draft Id Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeleteContentDraftApiContentDraftsDraftIdDeleteResponse = DeleteContentDraftApiContentDraftsDraftIdDeleteResponses[keyof DeleteContentDraftApiContentDraftsDraftIdDeleteResponses]; + +export type GetContentDraftApiContentDraftsDraftIdGetData = { + body?: never; + path: { + /** + * Draft Id + */ + draft_id: number; + }; + query?: never; + url: '/api/content-drafts/{draft_id}'; +}; + +export type GetContentDraftApiContentDraftsDraftIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetContentDraftApiContentDraftsDraftIdGetError = GetContentDraftApiContentDraftsDraftIdGetErrors[keyof GetContentDraftApiContentDraftsDraftIdGetErrors]; + +export type GetContentDraftApiContentDraftsDraftIdGetResponses = { + /** + * Response Get Content Draft Api Content Drafts Draft Id Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetContentDraftApiContentDraftsDraftIdGetResponse = GetContentDraftApiContentDraftsDraftIdGetResponses[keyof GetContentDraftApiContentDraftsDraftIdGetResponses]; + +export type UpdateContentDraftApiContentDraftsDraftIdPatchData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path: { + /** + * Draft Id + */ + draft_id: number; + }; + query?: never; + url: '/api/content-drafts/{draft_id}'; +}; + +export type UpdateContentDraftApiContentDraftsDraftIdPatchErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateContentDraftApiContentDraftsDraftIdPatchError = UpdateContentDraftApiContentDraftsDraftIdPatchErrors[keyof UpdateContentDraftApiContentDraftsDraftIdPatchErrors]; + +export type UpdateContentDraftApiContentDraftsDraftIdPatchResponses = { + /** + * Response Update Content Draft Api Content Drafts Draft Id Patch + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type UpdateContentDraftApiContentDraftsDraftIdPatchResponse = UpdateContentDraftApiContentDraftsDraftIdPatchResponses[keyof UpdateContentDraftApiContentDraftsDraftIdPatchResponses]; + +export type DeletePageMarkdownApiPageMarkdownDeleteData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/page-markdown'; +}; + +export type DeletePageMarkdownApiPageMarkdownDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeletePageMarkdownApiPageMarkdownDeleteError = DeletePageMarkdownApiPageMarkdownDeleteErrors[keyof DeletePageMarkdownApiPageMarkdownDeleteErrors]; + +export type DeletePageMarkdownApiPageMarkdownDeleteResponses = { + /** + * Response Delete Page Markdown Api Page Markdown Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeletePageMarkdownApiPageMarkdownDeleteResponse = DeletePageMarkdownApiPageMarkdownDeleteResponses[keyof DeletePageMarkdownApiPageMarkdownDeleteResponses]; + +export type ListPageMarkdownApiPageMarkdownGetData = { + body?: never; + path?: never; + query: { + /** + * Crawlrunid + */ + crawlRunId: number; + /** + * Page + */ + page?: number; + /** + * Limit + */ + limit?: number; + /** + * Q + */ + q?: string | null; + }; + url: '/api/page-markdown'; +}; + +export type ListPageMarkdownApiPageMarkdownGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListPageMarkdownApiPageMarkdownGetError = ListPageMarkdownApiPageMarkdownGetErrors[keyof ListPageMarkdownApiPageMarkdownGetErrors]; + +export type ListPageMarkdownApiPageMarkdownGetResponses = { + /** + * Response List Page Markdown Api Page Markdown Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ListPageMarkdownApiPageMarkdownGetResponse = ListPageMarkdownApiPageMarkdownGetResponses[keyof ListPageMarkdownApiPageMarkdownGetResponses]; + +export type PageMarkdownContentApiPageMarkdownContentGetData = { + body?: never; + path?: never; + query: { + /** + * Crawlrunid + */ + crawlRunId: number; + /** + * Url + */ + url: string; + }; + url: '/api/page-markdown/content'; +}; + +export type PageMarkdownContentApiPageMarkdownContentGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PageMarkdownContentApiPageMarkdownContentGetError = PageMarkdownContentApiPageMarkdownContentGetErrors[keyof PageMarkdownContentApiPageMarkdownContentGetErrors]; + +export type PageMarkdownContentApiPageMarkdownContentGetResponses = { + /** + * Response Page Markdown Content Api Page Markdown Content Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PageMarkdownContentApiPageMarkdownContentGetResponse = PageMarkdownContentApiPageMarkdownContentGetResponses[keyof PageMarkdownContentApiPageMarkdownContentGetResponses]; + +export type PageMarkdownExtractApiPageMarkdownExtractPostData = { + /** + * Body + */ + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/api/page-markdown/extract'; +}; + +export type PageMarkdownExtractApiPageMarkdownExtractPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PageMarkdownExtractApiPageMarkdownExtractPostError = PageMarkdownExtractApiPageMarkdownExtractPostErrors[keyof PageMarkdownExtractApiPageMarkdownExtractPostErrors]; + +export type PageMarkdownExtractApiPageMarkdownExtractPostResponses = { + /** + * Response Page Markdown Extract Api Page Markdown Extract Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PageMarkdownExtractApiPageMarkdownExtractPostResponse = PageMarkdownExtractApiPageMarkdownExtractPostResponses[keyof PageMarkdownExtractApiPageMarkdownExtractPostResponses]; + +export type PageMarkdownRunsApiPageMarkdownRunsGetData = { + body?: never; + path?: never; + query?: { + /** + * Propertyid + */ + propertyId?: number | null; + }; + url: '/api/page-markdown/runs'; +}; + +export type PageMarkdownRunsApiPageMarkdownRunsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PageMarkdownRunsApiPageMarkdownRunsGetError = PageMarkdownRunsApiPageMarkdownRunsGetErrors[keyof PageMarkdownRunsApiPageMarkdownRunsGetErrors]; + +export type PageMarkdownRunsApiPageMarkdownRunsGetResponses = { + /** + * Response Page Markdown Runs Api Page Markdown Runs Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PageMarkdownRunsApiPageMarkdownRunsGetResponse = PageMarkdownRunsApiPageMarkdownRunsGetResponses[keyof PageMarkdownRunsApiPageMarkdownRunsGetResponses]; + +export type OllamaStatusApiOllamaStatusGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/ollama/status'; +}; + +export type OllamaStatusApiOllamaStatusGetResponses = { + /** + * Response Ollama Status Api Ollama Status Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type OllamaStatusApiOllamaStatusGetResponse = OllamaStatusApiOllamaStatusGetResponses[keyof OllamaStatusApiOllamaStatusGetResponses]; + +export type McpToolsApiMcpToolsGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/mcp-tools'; +}; + +export type McpToolsApiMcpToolsGetResponses = { + /** + * Response Mcp Tools Api Mcp Tools Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type McpToolsApiMcpToolsGetResponse = McpToolsApiMcpToolsGetResponses[keyof McpToolsApiMcpToolsGetResponses]; + +export type DeletePortfolioItemApiPortfolioDeleteDeleteData = { + body: DeletePortfolioBody; + path?: never; + query?: never; + url: '/api/portfolio/delete'; +}; + +export type DeletePortfolioItemApiPortfolioDeleteDeleteErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeletePortfolioItemApiPortfolioDeleteDeleteError = DeletePortfolioItemApiPortfolioDeleteDeleteErrors[keyof DeletePortfolioItemApiPortfolioDeleteDeleteErrors]; + +export type DeletePortfolioItemApiPortfolioDeleteDeleteResponses = { + /** + * Response Delete Portfolio Item Api Portfolio Delete Delete + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeletePortfolioItemApiPortfolioDeleteDeleteResponse = DeletePortfolioItemApiPortfolioDeleteDeleteResponses[keyof DeletePortfolioItemApiPortfolioDeleteDeleteResponses]; + +export type AlertsCheckApiAlertsCheckPostData = { + body?: never; + path?: never; + query: { + /** + * Propertyid + */ + propertyId: number; + }; + url: '/api/alerts/check'; +}; + +export type AlertsCheckApiAlertsCheckPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type AlertsCheckApiAlertsCheckPostError = AlertsCheckApiAlertsCheckPostErrors[keyof AlertsCheckApiAlertsCheckPostErrors]; + +export type AlertsCheckApiAlertsCheckPostResponses = { + /** + * Response Alerts Check Api Alerts Check Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type AlertsCheckApiAlertsCheckPostResponse = AlertsCheckApiAlertsCheckPostResponses[keyof AlertsCheckApiAlertsCheckPostResponses]; + +export type ScheduleCheckApiScheduleCheckPostData = { + body?: never; + path?: never; + query?: never; + url: '/api/schedule/check'; +}; + +export type ScheduleCheckApiScheduleCheckPostResponses = { + /** + * Response Schedule Check Api Schedule Check Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ScheduleCheckApiScheduleCheckPostResponse = ScheduleCheckApiScheduleCheckPostResponses[keyof ScheduleCheckApiScheduleCheckPostResponses]; + +export type LogsUploadApiLogsUploadPostData = { + body: BodyLogsUploadApiLogsUploadPost; + path?: never; + query?: never; + url: '/api/logs/upload'; +}; + +export type LogsUploadApiLogsUploadPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type LogsUploadApiLogsUploadPostError = LogsUploadApiLogsUploadPostErrors[keyof LogsUploadApiLogsUploadPostErrors]; + +export type LogsUploadApiLogsUploadPostResponses = { + /** + * Response Logs Upload Api Logs Upload Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type LogsUploadApiLogsUploadPostResponse = LogsUploadApiLogsUploadPostResponses[keyof LogsUploadApiLogsUploadPostResponses]; + +export type CompareExportApiCompareExportPostData = { + body: CompareExportBody; + path?: never; + query?: never; + url: '/api/compare/export'; +}; + +export type CompareExportApiCompareExportPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CompareExportApiCompareExportPostError = CompareExportApiCompareExportPostErrors[keyof CompareExportApiCompareExportPostErrors]; + +export type CompareExportApiCompareExportPostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type PageCoachApiLinksPageCoachPostData = { + body: PageCoachBody; + path?: never; + query?: never; + url: '/api/links/page-coach'; +}; + +export type PageCoachApiLinksPageCoachPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PageCoachApiLinksPageCoachPostError = PageCoachApiLinksPageCoachPostErrors[keyof PageCoachApiLinksPageCoachPostErrors]; + +export type PageCoachApiLinksPageCoachPostResponses = { + /** + * Response Page Coach Api Links Page Coach Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type PageCoachApiLinksPageCoachPostResponse = PageCoachApiLinksPageCoachPostResponses[keyof PageCoachApiLinksPageCoachPostResponses]; + +export type RunAuditToolApiReportAuditToolPostData = { + body: AuditToolBody; + path?: never; + query?: never; + url: '/api/report/audit-tool'; +}; + +export type RunAuditToolApiReportAuditToolPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type RunAuditToolApiReportAuditToolPostError = RunAuditToolApiReportAuditToolPostErrors[keyof RunAuditToolApiReportAuditToolPostErrors]; + +export type RunAuditToolApiReportAuditToolPostResponses = { + /** + * Response Run Audit Tool Api Report Audit Tool Post + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type RunAuditToolApiReportAuditToolPostResponse = RunAuditToolApiReportAuditToolPostResponses[keyof RunAuditToolApiReportAuditToolPostResponses]; + +export type ExportReportApiReportExportGetData = { + body?: never; + path?: never; + query?: { + /** + * Format + */ + format?: string; + /** + * Reportid + */ + reportId?: number | null; + }; + url: '/api/report/export'; +}; + +export type ExportReportApiReportExportGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ExportReportApiReportExportGetError = ExportReportApiReportExportGetErrors[keyof ExportReportApiReportExportGetErrors]; + +export type ExportReportApiReportExportGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ExportSitemapApiReportExportSitemapGetData = { + body?: never; + path?: never; + query?: { + /** + * Reportid + */ + reportId?: number | null; + }; + url: '/api/report/export-sitemap'; +}; + +export type ExportSitemapApiReportExportSitemapGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ExportSitemapApiReportExportSitemapGetError = ExportSitemapApiReportExportSitemapGetErrors[keyof ExportSitemapApiReportExportSitemapGetErrors]; + +export type ExportSitemapApiReportExportSitemapGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ExportWorkbookApiReportExportWorkbookGetData = { + body?: never; + path?: never; + query?: { + /** + * Reportid + */ + reportId?: number | null; + }; + url: '/api/report/export-workbook'; +}; + +export type ExportWorkbookApiReportExportWorkbookGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ExportWorkbookApiReportExportWorkbookGetError = ExportWorkbookApiReportExportWorkbookGetErrors[keyof ExportWorkbookApiReportExportWorkbookGetErrors]; + +export type ExportWorkbookApiReportExportWorkbookGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ReportPortfolioApiReportPortfolioGetData = { + body?: never; + path?: never; + query?: { + /** + * Widget + */ + widget?: string; + /** + * Ids + */ + ids?: string | null; + /** + * Reportid + */ + reportId?: number | null; + /** + * Crawlrunid + */ + crawlRunId?: number | null; + }; + url: '/api/report/portfolio'; +}; + +export type ReportPortfolioApiReportPortfolioGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ReportPortfolioApiReportPortfolioGetError = ReportPortfolioApiReportPortfolioGetErrors[keyof ReportPortfolioApiReportPortfolioGetErrors]; + +export type ReportPortfolioApiReportPortfolioGetResponses = { + /** + * Response Report Portfolio Api Report Portfolio Get + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type ReportPortfolioApiReportPortfolioGetResponse = ReportPortfolioApiReportPortfolioGetResponses[keyof ReportPortfolioApiReportPortfolioGetResponses]; diff --git a/web/src/components/pagesMarkdown/ExtractorPanel.tsx b/web/src/components/pagesMarkdown/ExtractorPanel.tsx index e36cc10f..9be5c086 100644 --- a/web/src/components/pagesMarkdown/ExtractorPanel.tsx +++ b/web/src/components/pagesMarkdown/ExtractorPanel.tsx @@ -3,7 +3,13 @@ import { useCallback, useEffect, useState } from 'react'; import { AlertCircle, CheckCircle2, Loader2, Play, RefreshCw, Wifi } from 'lucide-react'; import { apiUrl } from '@/lib/publicBase'; -import type { PageMarkdownRunRow } from '@/server/pageMarkdownDb'; +interface PageMarkdownRunRow { + id: number; + created_at: string | null; + start_url: string; + html_page_count: number; + markdown_page_count: number; +} interface ExtractorPanelProps { propertyId: number | null; @@ -67,7 +73,7 @@ export default function ExtractorPanel({ const list = (data.runs ?? []) as PageMarkdownRunRow[]; setRuns(list); if (!selectedRunId && list.length > 0) { - onRunSelect(list[0].crawl_run_id); + onRunSelect(list[0].id); } } catch (e) { setRunsError(e instanceof Error ? e.message : String(e)); @@ -130,7 +136,7 @@ export default function ExtractorPanel({ if (captureJobId) setCaptureStatus('running'); }, [captureJobId]); - const selectedRun = runs.find((r) => r.crawl_run_id === selectedRunId) ?? null; + const selectedRun = runs.find((r) => r.id === selectedRunId) ?? null; const handleExtract = async () => { if (!selectedRunId) return; @@ -188,8 +194,8 @@ export default function ExtractorPanel({ onChange={(e) => onRunSelect(Number(e.target.value))} > {runs.map((r) => ( - ))} diff --git a/web/src/components/pagesMarkdown/PreviewPanel.tsx b/web/src/components/pagesMarkdown/PreviewPanel.tsx index 9753724b..891a1463 100644 --- a/web/src/components/pagesMarkdown/PreviewPanel.tsx +++ b/web/src/components/pagesMarkdown/PreviewPanel.tsx @@ -4,7 +4,21 @@ import { useCallback, useEffect, useState } from 'react'; import { ChevronLeft, ChevronRight, Code, Copy, Eye, Loader2, Search } from 'lucide-react'; import { apiUrl } from '@/lib/publicBase'; import MarkdownPreview from './MarkdownPreview'; -import type { PageMarkdownListItem, PageMarkdownContent } from '@/server/pageMarkdownDb'; +interface PageMarkdownListItem { + id: number; + url: string; + title?: string | null; + crawl_run_id: number | null; + created_at: string | null; + word_count?: number | null; +} + +interface PageMarkdownContent { + id: number; + url: string; + markdown: string | null; + created_at: string | null; +} const PAGE_SIZE = 25; @@ -179,7 +193,7 @@ export default function PreviewPanel({ crawlRunId, refreshKey }: PreviewPanelPro {item.url}

- {item.word_count.toLocaleString()} words + {(item.word_count ?? 0).toLocaleString()} words

@@ -291,7 +305,7 @@ export default function PreviewPanel({ crawlRunId, refreshKey }: PreviewPanelPro ) : !content ? (

No content.

) : ( - + )} diff --git a/web/src/lib/dashboard/ai/generate.test.ts b/web/src/lib/dashboard/ai/generate.test.ts new file mode 100644 index 00000000..83e41324 --- /dev/null +++ b/web/src/lib/dashboard/ai/generate.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + sanitizeChartSpec, + validateMeasure, + validateTransform, + assignLayouts, + generateWidget, + AiGenerateError, +} from '@/lib/dashboard/ai/generate'; +import type { Widget } from '@/lib/dashboard/types'; + +// ────────────────────────────────────────────────────────────────────────────── +// sanitizeChartSpec +// ────────────────────────────────────────────────────────────────────────────── + +describe('sanitizeChartSpec', () => { + it('accepts a valid minimal spec', () => { + const spec = sanitizeChartSpec({ type: 'bar' }); + expect(spec.type).toBe('bar'); + }); + + it('throws when type is missing', () => { + expect(() => sanitizeChartSpec({ labelField: 'x' })).toThrow(/type/); + }); + + it('throws when input is not an object', () => { + expect(() => sanitizeChartSpec('bar')).toThrow(); + }); + + it('drops undefined and function values via JSON round-trip', () => { + const raw = { + type: 'pie', + options: { onClick: undefined }, + }; + const spec = sanitizeChartSpec(raw); + // undefined props dropped by JSON serialization + expect(spec.options).not.toHaveProperty('onClick'); + }); + + it('caps dataset labels at 500', () => { + const labels = Array.from({ length: 600 }, (_, i) => `label-${i}`); + const spec = sanitizeChartSpec({ + type: 'bar', + data: { labels, datasets: [] }, + }); + expect(spec.data!.labels).toHaveLength(500); + }); + + it('caps dataset rows at 500 and datasets at 20', () => { + const manyDatasets = Array.from({ length: 25 }, (_, i) => ({ + label: `ds-${i}`, + data: Array.from({ length: 600 }, (_, j) => j), + })); + const spec = sanitizeChartSpec({ + type: 'radar', + data: { labels: [], datasets: manyDatasets }, + }); + expect(spec.data!.datasets).toHaveLength(20); + expect((spec.data!.datasets as { data: unknown[] }[])[0].data).toHaveLength(500); + }); + + it('caps series at 20', () => { + const series = Array.from({ length: 30 }, (_, i) => ({ label: `s${i}`, field: `f${i}` })); + const spec = sanitizeChartSpec({ type: 'line', series }); + expect(spec.series).toHaveLength(20); + }); + + it('passes through chartSpec type unchanged', () => { + const spec = sanitizeChartSpec({ type: 'polarArea', series: [] }); + expect(spec.type).toBe('polarArea'); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// DashScript validation +// ────────────────────────────────────────────────────────────────────────────── + +describe('validateMeasure', () => { + it('accepts a valid field call', () => { + expect(validateMeasure('field("health_score")')).toBeNull(); + }); + + it('accepts arithmetic', () => { + expect(validateMeasure('sum("count") / count()')).toBeNull(); + }); + + it('accepts an if expression', () => { + expect(validateMeasure('if(score >= 80, "Good", "Poor")')).toBeNull(); + }); + + it('returns an error string for invalid syntax', () => { + expect(validateMeasure('field(')).not.toBeNull(); + }); + + it('returns null for empty string', () => { + expect(validateMeasure('')).toBeNull(); + }); +}); + +describe('validateTransform', () => { + it('accepts a simple pipeline', () => { + expect(validateTransform('filter(count > 0) | sort(count, desc) | take(10)')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(validateTransform('')).toBeNull(); + }); + + it('returns an error for malformed pipeline', () => { + expect(validateTransform('filter( | sort')).not.toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// assignLayouts +// ────────────────────────────────────────────────────────────────────────────── + +describe('assignLayouts', () => { + type PartialWidget = Omit & { layout?: Widget['layout'] }; + const base: PartialWidget = { + title: 'W', + viz: 'kpi' as const, + binding: { source: 'audit-tool' as const, toolName: 'get_report_summary' }, + }; + + it('replaces Infinity y with bottomY', () => { + const widgets = assignLayouts([{ ...base, layout: { x: 0, y: Infinity, w: 3, h: 2 } }], 5); + expect(widgets[0].layout.y).toBe(5); + }); + + it('assigns unique ids', () => { + const widgets = assignLayouts([base, base]); + expect(widgets[0].id).not.toBe(widgets[1].id); + }); + + it('wraps widgets that exceed 12 columns', () => { + const wide: PartialWidget = { ...base, layout: { x: 0, y: 0, w: 8, h: 4 } }; + const narrow: PartialWidget = { ...base, layout: { x: 0, y: 0, w: 8, h: 4 } }; + const widgets = assignLayouts([wide, narrow], 0); + // Second widget should wrap to x: 0 on a new row + expect(widgets[1].layout.x).toBe(0); + expect(widgets[1].layout.y).toBeGreaterThan(0); + }); + + it('uses defaultWidgetLayout when layout is missing', () => { + const widgets = assignLayouts([base], 0); + expect(widgets[0].layout.w).toBeGreaterThan(0); + expect(Number.isFinite(widgets[0].layout.y)).toBe(true); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// generateWidget (mocked fetch) +// ────────────────────────────────────────────────────────────────────────────── + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('generateWidget', () => { + it('returns a widget with concrete layout', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: true, + widget: { + title: 'Health', + toolName: 'get_report_summary', + viz: 'kpi', + binding: { source: 'audit-tool', toolName: 'get_report_summary', valueField: 'health_score' }, + options: {}, + }, + explanation: 'Shows health score.', + }), + }); + const { widget } = await generateWidget('show health score'); + expect(widget.viz).toBe('kpi'); + expect(widget.id).toBeTruthy(); + expect(Number.isFinite(widget.layout.y)).toBe(true); + }); + + it('throws AiGenerateError on missing/disabled', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => ({ ok: false, error: 'AI insights are disabled.', missing: true }), + }); + await expect(generateWidget('test')).rejects.toBeInstanceOf(AiGenerateError); + }); + + it('sanitizes chartSpec in widget options', async () => { + const manyLabels = Array.from({ length: 600 }, (_, i) => `l-${i}`); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: true, + widget: { + title: 'Custom', + toolName: 'get_report_summary', + viz: 'custom-chart', + binding: { source: 'audit-tool', toolName: 'get_report_summary' }, + options: { + chartSpec: { type: 'bar', data: { labels: manyLabels, datasets: [] } }, + }, + }, + explanation: 'Chart', + }), + }); + const { widget } = await generateWidget('custom chart'); + expect(widget.options?.chartSpec?.data?.labels).toHaveLength(500); + }); +}); diff --git a/web/src/lib/dashboard/ai/generate.ts b/web/src/lib/dashboard/ai/generate.ts new file mode 100644 index 00000000..90d5e7f5 --- /dev/null +++ b/web/src/lib/dashboard/ai/generate.ts @@ -0,0 +1,280 @@ +/** + * Client-side helpers for the Dashboard AI generation API. + * Calls POST /api/dashboards/ai-generate and validates / sanitizes the response. + */ +import { tokenize } from '@/lib/dashboard/script/lexer'; +import { Parser } from '@/lib/dashboard/script/parser'; +import { newWidgetId, defaultWidgetLayout } from '@/lib/dashboard/types'; +import type { + Widget, + WidgetBinding, + WidgetOptions, + DashboardDoc, + VizType, + CustomChartSpec, +} from '@/lib/dashboard/types'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface AiScriptResult { + measure?: string; + transform?: string; + chartSpec?: CustomChartSpec | null; + explanation: string; +} + +export interface AiWidgetResult { + widget: Omit & { layout?: Widget['layout']; title: string; viz: VizType }; + explanation: string; +} + +export interface AiDashboardResult { + name: string; + widgets: (Omit & { layout?: Widget['layout'] })[]; + explanation: string; +} + +export interface AiGenerateOptions { + mode: 'script' | 'widget' | 'dashboard'; + prompt: string; + toolName?: string; + propertyId?: number; + reportId?: number | null; + /** Current widget binding / options to pass as context for script mode. */ + current?: { binding?: WidgetBinding; options?: WidgetOptions }; +} + +export class AiGenerateError extends Error { + constructor( + message: string, + public readonly missing?: boolean, + ) { + super(message); + this.name = 'AiGenerateError'; + } +} + +// --------------------------------------------------------------------------- +// Sanitization +// --------------------------------------------------------------------------- + +/** + * JSON-round-trip the spec to strip functions / undefined; validate required + * fields and enforce size caps. + */ +export function sanitizeChartSpec(raw: unknown): CustomChartSpec { + if (raw == null || typeof raw !== 'object') { + throw new Error('chartSpec must be an object'); + } + // Round-trip through JSON to drop functions/undefined + const spec = JSON.parse(JSON.stringify(raw)) as Record; + + if (!spec.type || typeof spec.type !== 'string') { + throw new Error('chartSpec.type must be a non-empty string'); + } + + // Cap explicit dataset point counts + if (spec.data && typeof spec.data === 'object') { + const d = spec.data as { datasets?: { data?: unknown[] }[]; labels?: unknown[] }; + if (Array.isArray(d.labels) && d.labels.length > 500) { + d.labels = d.labels.slice(0, 500); + } + if (Array.isArray(d.datasets)) { + d.datasets = d.datasets.slice(0, 20).map((ds) => ({ + ...ds, + data: Array.isArray(ds.data) ? ds.data.slice(0, 500) : ds.data, + })); + } + } + + // Cap series + if (Array.isArray(spec.series)) { + spec.series = (spec.series as unknown[]).slice(0, 20); + } + + return spec as unknown as CustomChartSpec; +} + +// --------------------------------------------------------------------------- +// DashScript validation +// --------------------------------------------------------------------------- + +/** Attempt to parse a measure expression; returns an error message or null on success. */ +export function validateMeasure(source: string): string | null { + if (!source.trim()) return null; + try { + const tokens = tokenize(source.trim()); + new Parser(tokens).parseExpr(); + return null; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } +} + +/** Attempt to parse a transform pipeline; returns an error message or null on success. */ +export function validateTransform(source: string): string | null { + if (!source.trim()) return null; + try { + const tokens = tokenize(source.trim()); + new Parser(tokens).parsePipeline(); + return null; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } +} + +// --------------------------------------------------------------------------- +// Layout assignment +// --------------------------------------------------------------------------- + +/** Assign concrete bottom-row y positions to a list of widget layout hints. */ +export function assignLayouts( + widgets: (Omit & { layout?: Widget['layout'] })[], + bottomY = 0, +): Widget[] { + let currentY = bottomY; + let rowMaxH = 0; + let rowX = 0; + + return widgets.map((w) => { + const viz = w.viz as VizType; + const hint = w.layout ?? defaultWidgetLayout(viz); + const layout = { ...hint }; + + // Replace Infinity y with computed bottom + if (!Number.isFinite(layout.y)) { + layout.y = currentY; + } + + // Ensure the widget fits in the row; wrap if needed + if (rowX + layout.w > 12) { + currentY += rowMaxH; + rowMaxH = 0; + rowX = 0; + layout.x = 0; + layout.y = currentY; + } else { + layout.x = rowX; + } + + rowX += layout.w; + rowMaxH = Math.max(rowMaxH, layout.h); + + const id = newWidgetId(); + return { ...w, id, layout } as Widget; + }); +} + +// --------------------------------------------------------------------------- +// API calls +// --------------------------------------------------------------------------- + +async function callAiGenerate(opts: AiGenerateOptions): Promise> { + const res = await fetch('/api/dashboards/ai-generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: opts.mode, + prompt: opts.prompt, + toolName: opts.toolName, + propertyId: opts.propertyId, + reportId: opts.reportId, + current: opts.current, + }), + }); + const data = (await res.json()) as Record; + if (!res.ok || data.ok === false) { + const msg = String(data.error || 'AI generation failed'); + const missing = Boolean(data.missing); + throw new AiGenerateError(msg, missing); + } + return data; +} + +/** + * Generate or improve a DashScript formula (+ optional chartSpec) for the widget being configured. + */ +export async function generateWidgetScript( + prompt: string, + opts: Pick = {}, +): Promise { + const data = await callAiGenerate({ mode: 'script', prompt, ...opts }); + const measure = typeof data.measure === 'string' ? data.measure : ''; + const transform = typeof data.transform === 'string' ? data.transform : ''; + const explanation = typeof data.explanation === 'string' ? data.explanation : ''; + + // Validate DashScript + const measureErr = validateMeasure(measure); + if (measureErr) throw new AiGenerateError(`Invalid measure: ${measureErr}`); + const transformErr = validateTransform(transform); + if (transformErr) throw new AiGenerateError(`Invalid transform: ${transformErr}`); + + let chartSpec: CustomChartSpec | null = null; + if (data.chartSpec) { + chartSpec = sanitizeChartSpec(data.chartSpec); + } + + return { measure, transform, chartSpec, explanation }; +} + +/** + * Generate a full single widget definition from a natural-language prompt. + */ +export async function generateWidget( + prompt: string, + opts: Pick = {}, + bottomY = 0, +): Promise<{ widget: Widget; explanation: string }> { + const data = await callAiGenerate({ mode: 'widget', prompt, ...opts }); + + const raw = data.widget as Omit & { layout?: Widget['layout']; title: string; viz: VizType }; + if (!raw || typeof raw !== 'object') { + throw new AiGenerateError('AI returned no widget definition'); + } + + // Sanitize chartSpec if present in options + if (raw.options?.chartSpec) { + raw.options = { + ...raw.options, + chartSpec: sanitizeChartSpec(raw.options.chartSpec), + }; + } + + const [widget] = assignLayouts([raw], bottomY); + widget.options = { ...(widget.options ?? {}), aiPrompt: prompt }; + + return { widget, explanation: String(data.explanation ?? '') }; +} + +/** + * Generate a full dashboard (name + widgets) from a natural-language prompt. + */ +export async function generateDashboard( + prompt: string, + opts: Pick = {}, +): Promise<{ name: string; doc: DashboardDoc; explanation: string }> { + const data = await callAiGenerate({ mode: 'dashboard', prompt, ...opts }); + + const name = String(data.name || 'AI Dashboard'); + const rawWidgets = ( + Array.isArray(data.widgets) ? data.widgets : [] + ) as (Omit & { layout?: Widget['layout'] })[]; + + // Sanitize any chartSpecs + const sanitized = rawWidgets.map((w) => { + if (w.options?.chartSpec) { + return { + ...w, + options: { ...w.options, chartSpec: sanitizeChartSpec(w.options.chartSpec) }, + }; + } + return w; + }); + + const widgets = assignLayouts(sanitized, 0); + const doc: DashboardDoc = { version: 1, widgets }; + + return { name, doc, explanation: String(data.explanation ?? '') }; +} diff --git a/web/src/lib/dashboard/builder/AiAssistModal.tsx b/web/src/lib/dashboard/builder/AiAssistModal.tsx new file mode 100644 index 00000000..d4dcae1a --- /dev/null +++ b/web/src/lib/dashboard/builder/AiAssistModal.tsx @@ -0,0 +1,301 @@ +'use client'; + +import { useState } from 'react'; +import { X, Sparkles, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; +import { + generateWidgetScript, + generateWidget, + generateDashboard, + AiGenerateError, + type AiScriptResult, +} from '@/lib/dashboard/ai/generate'; +import type { Widget, DashboardDoc, WidgetBinding, WidgetOptions } from '@/lib/dashboard/types'; + +// ────────────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────────────── + +type AiMode = 'script' | 'widget' | 'dashboard'; + +interface AiAssistModalBaseProps { + propertyId?: number; + reportId?: number | null; + onClose: () => void; +} + +interface ScriptModeProps extends AiAssistModalBaseProps { + mode: 'script'; + toolName: string; + currentBinding: WidgetBinding; + currentOptions: WidgetOptions; + onApplyScript: (result: AiScriptResult) => void; +} + +interface WidgetModeProps extends AiAssistModalBaseProps { + mode: 'widget'; + bottomY?: number; + onAddWidget: (widget: Widget) => void; +} + +interface DashboardModeProps extends AiAssistModalBaseProps { + mode: 'dashboard'; + onCreateDashboard: (name: string, doc: DashboardDoc) => void; +} + +export type AiAssistModalProps = ScriptModeProps | WidgetModeProps | DashboardModeProps; + +// ────────────────────────────────────────────────────────────────────────────── +// Component +// ────────────────────────────────────────────────────────────────────────────── + +const MODE_LABELS: Record = { + script: 'Improve script', + widget: 'Generate widget', + dashboard: 'Generate dashboard', +}; + +const PLACEHOLDERS: Record = { + script: 'e.g. "Show me the ratio of 4xx to total URLs as a percentage" or "Only count critical issues"', + widget: 'e.g. "Show top 10 broken links by page" or "KPI card for overall health score"', + dashboard: 'e.g. "Performance-focused dashboard with Core Web Vitals and Lighthouse scores"', +}; + +export default function AiAssistModal(props: AiAssistModalProps) { + const [prompt, setPrompt] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [explanation, setExplanation] = useState(null); + const [showExplanation, setShowExplanation] = useState(true); + const [pending, setPending] = useState<{ + script?: AiScriptResult; + widget?: Widget; + dashboard?: { name: string; doc: DashboardDoc }; + } | null>(null); + + const { mode, propertyId, reportId, onClose } = props; + + const handleGenerate = async () => { + if (!prompt.trim()) return; + setLoading(true); + setError(null); + setPending(null); + setExplanation(null); + + try { + if (mode === 'script') { + const sp = props as ScriptModeProps; + const result = await generateWidgetScript(prompt, { + toolName: sp.toolName, + propertyId, + reportId, + current: { binding: sp.currentBinding, options: sp.currentOptions }, + }); + setPending({ script: result }); + setExplanation(result.explanation); + } else if (mode === 'widget') { + const wp = props as WidgetModeProps; + const { widget, explanation: expl } = await generateWidget( + prompt, + { propertyId, reportId }, + wp.bottomY ?? 0, + ); + setPending({ widget }); + setExplanation(expl); + } else { + const { name, doc, explanation: expl } = await generateDashboard( + prompt, + { propertyId, reportId }, + ); + setPending({ dashboard: { name, doc } }); + setExplanation(expl); + } + } catch (e) { + if (e instanceof AiGenerateError && e.missing) { + setError('AI insights are disabled. Enable them in Settings → AI insights.'); + } else { + setError(e instanceof Error ? e.message : 'Generation failed'); + } + } finally { + setLoading(false); + } + }; + + const handleApply = () => { + if (!pending) return; + if (mode === 'script' && pending.script) { + (props as ScriptModeProps).onApplyScript(pending.script); + onClose(); + } else if (mode === 'widget' && pending.widget) { + (props as WidgetModeProps).onAddWidget(pending.widget); + onClose(); + } else if (mode === 'dashboard' && pending.dashboard) { + const dp = props as DashboardModeProps; + dp.onCreateDashboard(pending.dashboard.name, pending.dashboard.doc); + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +

{MODE_LABELS[mode]}

+
+ +
+ + {/* Body */} +
+
+ +